Contents

Grav → Hugo migration: 2 years of blog flipped in one day

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.

Why leave Grav

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

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:

Diagram Diagram

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.

#PhaseDurationRisk
0Auto cartography (vhosts, DNS, Hugo mode, articles to migrate)15 minNone (read-only)
1Create grav.arleo.eu LAN-only (DNS + WAF rule + vhost)30 minLow
2Migration /posts/ + aliases on Hugo VM45 minMedium
3Prepare www.arleo.eu Hugo vhost (sites-available, not enabled)30 minNone
4Atomic flip Grav → Hugo (≈ 0s downtime)15 minHigh
5Remove hugo-test.arleo.eu (DNS + vhost)10 minLow
6Plugin-system + IndexNow + Google Indexing1h30Medium
7Bulk submission of 24 URLs to search engines15 minLow
8(D+7 to D+30) Goodbye Grav page then archivalLow

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.

# 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:

<!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):

# 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.

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. 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.

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:

  • 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.