Contents

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:

  1. Performance — Pure static. No PHP, no DB. nginx serves pre-generated .html directly.
  2. Security — Attack surface divided by 10. No more PHP-FPM, no server-side execution on public pages.
  3. Clean multilingual — Hugo natively supports i18n via the index.{lang}.md convention (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).

Diagram Diagram

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

Hugo 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 root

Real 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 uses DD-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:00

FR/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-i18n plugin 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.title nuances: LoveIt has a themes/LoveIt/hugo.toml with default values (name = "My cool site"). If you don’t explicitly override in your hugo.toml, those defaults pollute your site. It took me 1h to figure out why my header showed “My cool site” when title = "arleo.eu" was clearly defined.
  • layouts/home.html override: LoveIt filters articles with where .Site.RegularPages "Type" "posts" but my articles are at the root of content/ (not in content/posts/). So 0 articles displayed on home. Fix: minimal layout override (1 line diff) + hiddenFromHomePage: true convention in front matter for reference pages (privacy, docs, scripts).

Final state

MetricGravHugo
Median TTFB~480ms~50ms
Buildn/a (dynamic)2.1s
PHP surface100%0%
404s post-migration00

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.