---
title: "Grav → Hugo migration: 2 years of blog flipped in one day"
description: "Retrospective of the arleo.eu migration from Grav (PHP) to Hugo (Go static) — atomic flip with zero downtime, 22 articles preserved with SEO aliases, MCP plugin-system with IndexNow/Google Indexing. Open source script on GitHub."
url: "https://www.arleo.eu/en/posts/migration-grav-hugo/"
language: "en"
datePublished: "2026-05-09T22:33:22+02:00"
dateModified: "2026-05-15T23:51:25+02:00"
tags: ["migration","hugo","grav","cms","retex","devops"]
categories: ["infrastructure"]
contentSignal: "ai-train=no, search=yes, ai-input=yes"
---

# Grav → Hugo migration: 2 years of blog flipped in one day
Retrospective of the arleo.eu migration from Grav (PHP) to Hugo (Go static) — atomic flip with zero downtime, 22 articles preserved with SEO aliases, MCP plugin-system with IndexNow/Google Indexing. Open source script on GitHub.



## TL;DR

On May 9, 2026, I switched `arleo.eu` from **Grav** (PHP CMS) to **Hugo** (Go static site generator) in a single session. **Atomic flip** (≈ 0 second downtime), 22 legacy articles migrated under `/posts/` with **SEO aliases** to preserve Google-indexed URLs, BetterStack `/ping` monitoring intact throughout the operation.

The code and migration script are open source: [github.com/jmrGrav/grav-to-hugo-migration](https://github.com/jmrGrav/grav-to-hugo-migration).

<!--more-->

## Why leave Grav

I really enjoyed Grav. For 2 years, I built on top of it:

- 5 homemade plugins ([IndexNow](/en/posts/grav-plugin-indexnow/), [Google Indexing](/en/posts/grav-plugin-google-indexing/), [MCP server](/en/posts/grav-mcp-server/), [CSP nonce](/en/posts/csp-nonce/), Cache Purge)
- A custom GPM-compliant theme `arleov2`
- An MCP workflow with Claude.ai to publish in natural language

Why change then? Three converging reasons:

**1. Performance.** Grav generates each page in PHP on every request, even with an nginx microcache in front. Hugo pre-builds everything to static HTML: nginx serves a file from disk, period. For a personal blog with no dynamic content, it's 10× faster and 100× less CPU-hungry.

**2. Reduced attack surface.** No more PHP-FPM, no more Grav admin routes (`/admin`), no more plugins running code on every request. The server delivers frozen `.html` files, period. ModSecurity and the Cloudflare WAF protect a much smaller surface.

**3. Reproducible build.** Hugo takes Markdown + a TOML config and outputs a static folder. The result is deterministic: same content = same output. Versionable, diffable, atomic. With Grav, two saves on the same page can generate slightly different HTML depending on the cache state.

The downside: losing Grav's web admin. But since I was already publishing 90% of the time via my MCP plugin by talking to Claude.ai, the admin had become accessory. Hugo + a custom MCP server = same workflow, simpler infrastructure.

## Target architecture

The end result looks like this:

<!-- mermaid-source:Z3JhcGggTFIKICAgIFZpc2l0b3JbVmlzaXRvcl0gLS0+fEhUVFBTfCBDRltDbG91ZGZsYXJlXQogICAgQ0YgLS0+fGNhY2hlIEhJVHwgQ0YKICAgIENGIC0tPnxjYWNoZSBNSVNTfCBOVUNbTlVDIG5naW54XQogICAgTlVDIC0tPnxyZXZlcnNlIHByb3h5fCBWTVtWTSBIdWdvIG5naW54XQogICAgVk0gLS0+fHNlcnZlcyBzdGF0aWMgSFRNTHwgU3RhdGljWyIvdmFyL3d3dy9odWdvLyJdCiAgICAKICAgIHN1YmdyYXBoICJWTSBIdWdvICgxOTIuMTY4LjEyMi42OSkiCiAgICAgICAgVk0KICAgICAgICBTdGF0aWMKICAgICAgICBNQ1BbaHVnby1tY3AgUHl0aG9uXQogICAgICAgIE1DUCAtLT58Z2VuZXJhdGVzfCBTdGF0aWMKICAgIGVuZAogICAgCiAgICBzdWJncmFwaCAiTlVDIChob3N0KSIKICAgICAgICBOVUMKICAgICAgICBHcmF2W2dyYXYuYXJsZW8uZXUgTEFOLW9ubHk8YnIvPjMwLWRheSBhcmNoaXZlXQogICAgZW5kCiAgICAKICAgIENsYXVkZVtDbGF1ZGUuYWldIC0uLT58TUNQIHRvb2xzL2NhbGx8IE1DUA== -->
{{< mermaid-svg hash="9d86219d" >}}

Three main components:

- **`www.arleo.eu`** — public, prod, served by VM nginx as static (NUC → VM reverse proxy)
- **`grav.arleo.eu`** — LAN-only via Cloudflare WAF (allow fixed IP), Grav as archive for 30 days then public goodbye page
- **`mcp-hugo.arleo.eu`** — MCP endpoint for Claude.ai (page creation/edition in natural language)

The former staging subdomain `hugo-test.arleo.eu` was retired after the flip.

## The method: 8 phases

To avoid breaking SEO or monitoring, I split the migration into 8 atomic phases with measurable success criteria. Each phase has a documented rollback.

| # | Phase | Duration | Risk |
|---|---|---|---|
| 0 | Auto cartography (vhosts, DNS, Hugo mode, articles to migrate) | 15 min | None (read-only) |
| 1 | Create `grav.arleo.eu` LAN-only (DNS + WAF rule + vhost) | 30 min | Low |
| 2 | Migration `/posts/` + aliases on Hugo VM | 45 min | Medium |
| 3 | Prepare `www.arleo.eu` Hugo vhost (sites-available, not enabled) | 30 min | None |
| 4 | **Atomic flip** Grav → Hugo (≈ 0s downtime) | 15 min | **High** |
| 5 | Remove `hugo-test.arleo.eu` (DNS + vhost) | 10 min | Low |
| 6 | Plugin-system + IndexNow + Google Indexing | 1h30 | Medium |
| 7 | Bulk submission of 24 URLs to search engines | 15 min | Low |
| 8 | (D+7 to D+30) Goodbye Grav page then archival | — | Low |

The order matters: **R4 (`/posts/` migration) before the prod flip** so that aliases are ready at switch-over time, otherwise Google-indexed URLs return 404 during migration.

## Preserving SEO: the Hugo aliases mechanism

This is the most critical point of the migration. My site has root URLs Google has been indexing for 2 years (e.g. `https://www.arleo.eu/grav-plugin-indexnow/`). If I switch to Hugo which generates these articles under `/posts/grav-plugin-indexnow/`, I lose **all my inbound links** and the accumulated PageRank.

The Hugo solution: **frontmatter aliases**.

```yaml
# content/posts/grav-plugin-indexnow/index.en.md
---
title: "Grav IndexNow plugin"
featuredImage: /images/migration-grav-hugo-featured.jpg
aliases:
  - /en/grav-plugin-indexnow/    # old Grav URL
---
```

Hugo then generates a `public/en/grav-plugin-indexnow/index.html` file with:

```html
<!DOCTYPE html>
<html lang="en">
<head>
    <title>https://www.arleo.eu/en/posts/grav-plugin-indexnow/</title>
    <link rel="canonical" href="https://www.arleo.eu/en/posts/grav-plugin-indexnow/">
    <meta http-equiv="refresh" content="0; url=https://www.arleo.eu/en/posts/grav-plugin-indexnow/">
</head>
<body>...</body>
</html>
```

Three mechanisms in one:

1. **`<link rel="canonical">`** — Google knows the real URL is `/posts/...`, not the root
2. **`<meta http-equiv="refresh">`** — instant browser redirect
3. **`<title>` = new URL** — pedagogical for the rare cases where the redirect doesn't fire

Result for Google: in 3-12 weeks, the canonical is adopted and the link juice (accumulated authority) transfers to `/posts/<slug>/`. During the transition, **both URLs serve content**, so no external link breaks.

I migrated 12 legacy articles (10 others were Hugo-native, no aliases needed). For each article, FR alias to `/<slug>/` and EN alias to `/en/<slug>/`. Sitemap automatically regenerated on the new canonical URLs.

## The atomic flip: near-zero downtime with 100+ visitors/day

The critical moment. At T-0, `www.arleo.eu` must switch from Grav (`root /var/www/grav`) to Hugo (reverse proxy to the VM) without perceptible interruption.

The nginx mechanism is simple but requires rigor. Here's the flip skeleton (full detail in [the GitHub repo](https://github.com/jmrGrav/grav-to-hugo-migration)):

```bash
# 1. Backup current vhost
sudo cp /etc/nginx/sites-enabled/www.arleo.eu \
        /tmp/backup-$(date +%s).conf

# 2. Disable Grav vhost (mv to backup)
sudo mv /etc/nginx/sites-enabled/www.arleo.eu \
        /tmp/www.arleo.eu.disabled

# 3. Activate Hugo vhost (already in sites-available, already tested)
sudo ln -s /etc/nginx/sites-available/www.arleo.eu.hugo \
           /etc/nginx/sites-enabled/

# 4. Syntax test (LAST chance before reload)
sudo nginx -t || ROLLBACK

# 5. Reload (instant, doesn't close in-flight connections)
sudo systemctl reload nginx

# 6. Immediate checks with automatic rollback
PING=$(curl -s -o /dev/null -w "%{http_code}" -u "$USER:$PASS" https://www.arleo.eu/ping)
[[ "$PING" != "200" ]] && ROLLBACK_NOW
```

**Non-negotiable success criterion**: `/ping` must return 200 immediately after reload. If not, automatic rollback no questions asked. Why? Because BetterStack monitors `/ping` every 30 seconds and would alert by SMS within 2 minutes. Better to roll back in 30 seconds than receive an alert SMS on a Saturday evening.

`nginx -s reload` (SIGHUP signal) does **not** close in-flight connections — it spawns new workers with the new config and lets the old ones finish their requests. That's what makes perceptible downtime nil, even with 100+ visitors/day.

## MCP plugin-system: IndexNow + Google Indexing

Hugo generates static files. When a new article is published, search engines must be **notified** otherwise they discover the new page on their next natural crawl (hours to days). With Grav, I had 2 PHP plugins (`indexnow`, `google-indexing`) doing this job on the `onAdminAfterSave` and `onMcpAfterSave` hooks. For Hugo, an equivalent was needed.

I built a **Python plugin-system** in `hugo-mcp` rather than a monolithic module. Architecture:

```
hugo-mcp/
├── core/
│   ├── plugin_base.py        # HugoMcpPlugin contract (3 abstract methods)
│   └── plugin_loader.py      # Auto-discovery at startup
├── plugins/
│   ├── _template/            # Skeleton for contributors
│   ├── indexnow/             # Submit to Bing/Yandex
│   └── google-indexing/      # Submit to Google API v3
└── config/
    └── plugins.yaml          # Activation + user secrets
```

Each plugin implements the `HugoMcpPlugin` contract which defines 3 methods:

- `is_enabled(config)` — is the plugin enabled?
- `validate_config(config)` — is the config valid?
- `on_page_event(event_type, urls, context)` — async hook called after build

The plugin loader scans `plugins/*/plugin.py` at startup, instantiates those enabled in `config/plugins.yaml`, and calls them in parallel after each `create_page` / `update_page` / `delete_page` with a 10-second timeout per plugin.

Full details in the dedicated post: [Hugo MCP plugin-system: extensible architecture](/en/posts/hugo-mcp-plugins-architecture/).

## Pitfalls encountered

For anyone redoing the migration, here are the pitfalls that cost me time:

**Ownership of `content/` and `public/` folders.** Hugo build wants to write to `public/`, but hugo-mcp (which runs as `hugo-mcp:hugo-mcp` via systemd) also wants to write to `content/`. Solution: `content/` owned by `hugo-mcp:hugo-mcp` with `g+w`, user `jm` added to the `hugo-mcp` group to be able to build via CLI. Before manual rebuild, `chown -R jm:jm public/` then restore.

**`systemd ProtectSystem=strict`.** The hugo-mcp service can only write to paths listed in `ReadWritePaths`. The Google Indexing plugin wanted to write `/var/lib/hugo-mcp/google-indexing-quota.json` → EROFS error. Fix: add `/var/lib/hugo-mcp` to `ReadWritePaths` and create the directory with proper permissions.

**`systemd IPAddressAllow`.** The C1.7 systemd hardening I had applied blocks all non-whitelisted outbound. The Google Indexing plugin failed with timeout on `oauth2.googleapis.com` and `indexing.googleapis.com` because Google IPs weren't in the whitelist. Fix: add Google ranges (`172.217.0.0/16`, `142.250.0.0/15`, `64.233.160.0/19`, `74.125.0.0/16`, `192.178.128.0/17`).

**ModSec OWASP CRS on Markdown content.** Technical posts contain jargon (`token rotation`, `bcrypt`, `MITM`, `SQL injection`) that matches default SQLi/XSS/RCE rules. Anomaly score 30 on threshold 10 → silent 403 from nginx, mapped to "additional permissions required" by claude.ai. I burned 4 conversations blaming the wrong culprit before Claude Code identified the real cause via SSH. Full story in [the security sprint post](/en/posts/sprint-securite-mcp-livre/). Fix: targeted `SecRuleRemoveById` on specific rule IDs (932230, 932235, 932250, 932340, 941400, 942360, 949110, 959100), not global `modsecurity off`.

**TypeIt broken by orphan Mermaid in home.** The LoveIt typewriter initializes via DOM selector `#id-1`. If a Mermaid block appears in the home summary (which happens when a post lacks a `<!--more-->` marker before its first diagram), Mermaid grabs that `id-1` first, crashes init with `mermaid.initialize() undefined`, and the JS init chain stops → TypeIt never runs. Fix: add `<!--more-->` before the first Mermaid block of all affected posts. Dedicated postmortem: [TypeIt + Mermaid: JS conflict in LoveIt](/en/posts/postmortem-typeit-mermaid/).

**`hugo build` without `--cleanDestinationDir`.** When deleting an article via `delete_page` MCP, Hugo rebuild doesn't remove the corresponding `public/posts/<slug>/` directory. The page stays served (zombie state) until a manual build with `--cleanDestinationDir`. Bug to fix on the hugo-mcp side.

## The GitHub repo

All migration tooling is on [github.com/jmrGrav/grav-to-hugo-migration](https://github.com/jmrGrav/grav-to-hugo-migration):

- `migrate.sh` — idempotent bash script, dry-run by default, automatic rollback
- `nginx-templates/` — ready-to-use vhosts (Hugo prod, Grav archive, NUC→VM reverse proxy)
- `hugo-config-skeleton/` — minimal `hugo.toml`, LoveIt-ready, multilingual FR+EN
- `README.md` — step-by-step to reproduce the migration on your infra

MIT license, contributions welcome.

## Conclusion

Migrating a 2-year-old blog in one day requires rigor but isn't superhuman. The ingredients:

1. **A complete staging** (`hugo-test.arleo.eu` for me) where everything is validated before prod flip
2. **Aliases for SEO** — Hugo native, no nginx hacks needed
3. **An atomic flip** with measurable success criterion and automatic rollback (`/ping`)
4. **An idempotent script** rather than chained manual commands

Today's result: `arleo.eu` loads in 80 ms (vs 350 ms in Grav with microcache), nginx consumes 30 MB of RAM (vs 800 MB for PHP-FPM), and I still publish via Claude.ai by speaking French.

And the Grav archive remains accessible for 30 days on `grav.arleo.eu` (LAN-only) before the public goodbye page. Operational continuity, no orphans.

Next iteration: a Cloudflare plugin for targeted purge (linked URLs only, not full purge on every change), and converting `hall-of-fame.html` into a native Hugo page to integrate with the theme.

Good migration to whoever takes the leap.


## Tags

- migration
- hugo
- grav
- cms
- retex
- devops

## Categories

- infrastructure
