Migrating Grav → Hugo: 32 files, 0 regression

TL;DR
arleo.eu had been running on Grav CMS for ~3 years: flat-file, PHP-FPM, ModSecurity, Cloudflare. Everything worked. But operational debt was piling up: PHP upgrades, Grav plugins to patch, TTFB creeping past 800ms on some pages.
I migrated to Hugo (Go-based static site generator) in two weeks, keeping the same URLs, the same content, and both languages FR/EN. Zero regression on SEO, Cloudflare indexing, or internal links. Here’s how.
Why Hugo
I had 3 criteria:
- Performance — Pure static. No PHP, no DB. nginx serves pre-generated
.htmldirectly. - Security — Attack surface divided by 10. No more PHP-FPM, no server-side execution on public pages.
- Clean multilingual — Hugo natively supports i18n via the
index.{lang}.mdconvention (page bundles).
Hugo checks all the boxes. Alternatives evaluated: Eleventy (JS, but less mature i18n), Zola (Rust, awesome, but fewer themes), Gatsby (too heavy for a homelab).
Strategy: page bundles + preserved URLs
The classic CMS migration trap: breaking URLs. All inbound links (Google, Reddit, Hacker News) would 404.
Solution: keep existing routes. On Grav, I had /csp-nonce, /post-mortem-522-wan-failover, etc. → On Hugo, same thing, via url: in front matter or page bundle convention.
LoveIt convention (the chosen Hugo theme):
content/
csp-nonce/
index.fr.md ← French variant
index.en.md ← English variant
featured.png ← hero image (page bundle)
post-mortem-522/
index.fr.md
index.en.mdHugo auto-generates:
https://arleo.eu/csp-nonce/(FR by default, no prefix)https://arleo.eu/en/csp-nonce/(EN with prefix)
Key parameter in hugo.toml:
defaultContentLanguage = "fr"
defaultContentLanguageInSubdir = false # FR at rootReal scope: 32 files
I had 16 editorial articles on Grav. × 2 languages = 32 Markdown files to migrate.
To not break anything, I first laid out a per-article checklist:
- Grav front matter (Twig metadata) → Hugo front matter (YAML)
- Grav shortcodes → LoveIt shortcodes conversion
- Internal links (
[link](/posts/csp-nonce/)to verify both ways) - Images: move to page bundle, not
static/images/ - Tags and categories aligned with Hugo conventions
-
date:in ISO 8601 (Grav usesDD-MM-YYYY HH:MM)
I automated date conversion with a small Python script:
from datetime import datetime
grav_date = "08-04-2026 00:35"
iso = datetime.strptime(grav_date, "%d-%m-%Y %H:%M").isoformat()
# 2026-04-08T00:35:00FR/EN coexistence without hacks
LoveIt natively handles languages via URL. The only catch: the language switcher. On Grav, it was a Twig-generated dropdown. On Hugo, it’s native:
[languages]
[languages.fr]
weight = 1
languageName = "Français"
[languages.en]
weight = 2
languageName = "English"The selector (top right, globe icon) automatically toggles to the equivalent version of the current page.
static/ ↔ content/ coexistence
Hugo has two zones for resources:
static/= global assets (favicon, logos, custom CSS). Served directly.content/<route>/= page bundle (hero image for a specific article).
Simple rule I adopted:
- If the image is reused across multiple pages →
static/images/ - If the image belongs to one single page → page bundle
This discipline avoids orphan static/images/csp-nonce-diagram.png files when an article gets deleted.
MCP migration: each its own role
I kept a Hugo MCP Server running in parallel (FastAPI, 7 tools) that lets me edit articles from Claude.ai. But with one rule: MCP touches content (content/), Git touches structure (layouts/, themes/, hugo.toml). This is what I call “Strategy 4” — I’ll cover that in another article.
Concretely, content/ is in .gitignore repo-side, but backed up via VM snapshots. No conflict possible between MCP and Git.
What surprised me (positively)
- Build time: ~2 seconds for 16 articles × 2 languages. On Grav, just loading a page took ~120ms of PHP before render.
- FR/EN consistency: Hugo generates exactly the same pages in both languages with no extra config. No more Grav
language-i18nplugin needed. - Override simplicity: want to tweak the home rendering?
cp themes/LoveIt/layouts/index.html layouts/index.html, then edit. That’s the only custom file I needed.
What it cost me
- Understanding
params.header.titlenuances: LoveIt has athemes/LoveIt/hugo.tomlwith default values (name = "My cool site"). If you don’t explicitly override in yourhugo.toml, those defaults pollute your site. It took me 1h to figure out why my header showed “My cool site” whentitle = "arleo.eu"was clearly defined. layouts/home.htmloverride: LoveIt filters articles withwhere .Site.RegularPages "Type" "posts"but my articles are at the root ofcontent/(not incontent/posts/). So 0 articles displayed on home. Fix: minimal layout override (1 line diff) +hiddenFromHomePage: trueconvention in front matter for reference pages (privacy, docs, scripts).
Final state
| Metric | Grav | Hugo |
|---|---|---|
| Median TTFB | ~480ms | ~50ms |
| Build | n/a (dynamic) | 2.1s |
| PHP surface | 100% | 0% |
| 404s post-migration | 0 | 0 |
The Hugo site currently runs on hugo-test.arleo.eu (KVM Ubuntu 24.04 VM, on the NUC). Final DNS cutover arleo.eu → Hugo VM IP is planned within a few weeks, after the MCP security sprint validates.
Conclusion
A dynamic CMS → SSG migration is less risky than you’d think if you keep the URLs and take time to understand the new theme’s conventions. Don’t try to reinvent everything: Hugo + LoveIt are mature, following defaults pays off.
The most subtle work wasn’t migrating the content, but converging visually with the previous Grav site (footer, menu, favicon, animations). That’s what actually took the most time. But that’s another story.