Migration Grav → Hugo : 32 fichiers, 0 régression

TL;DR
arleo.eu tournait sur Grav CMS depuis ~3 ans : flat-file, PHP-FPM, ModSecurity, Cloudflare. Tout marchait. Mais la dette opérationnelle s’accumulait : MAJ PHP, plugins Grav à patcher, TTFB qui grimpait à >800ms sur certaines pages.
J’ai migré vers Hugo (générateur de site statique en Go) en deux semaines, en gardant les mêmes URLs, le même contenu, et les deux langues FR/EN. Zéro régression côté SEO, indexation Cloudflare, ou liens internes. Voici comment.
Pourquoi Hugo
J’avais 3 critères :
- Performance — Statique pur. Pas de PHP, pas de DB. nginx sert directement les
.htmlpré-générés. - Sécurité — Surface d’attaque divisée par 10. Plus de PHP-FPM, plus d’exécution serveur sur les pages publiques.
- Multilingue propre — Hugo supporte nativement i18n via la convention
index.{lang}.md(page bundles).
Hugo coche tout. Les alternatives évaluées : Eleventy (JS, mais moins mature niveau i18n), Zola (Rust, super, mais écosystème thèmes plus restreint), Gatsby (trop lourd pour un homelab).
Stratégie : page bundles + URLs préservées
Le piège classique d’une migration de CMS : casser les URLs. Tous les liens entrants (Google, Reddit, hackernews) tomberaient sur des 404.
Solution : conserver les routes existantes. Sur Grav, j’avais /csp-nonce, /post-mortem-522-wan-failover, etc. → Sur Hugo, idem, via url: dans le front matter ou la convention de page bundle.
Convention LoveIt (le thème Hugo choisi) :
content/
csp-nonce/
index.fr.md ← variante française
index.en.md ← variante anglaise
featured.png ← image hero (page bundle)
post-mortem-522/
index.fr.md
index.en.mdHugo génère automatiquement :
https://arleo.eu/csp-nonce/(FR par défaut, sans préfixe)https://arleo.eu/en/csp-nonce/(EN avec préfixe)
Le paramètre clé dans hugo.toml :
defaultContentLanguage = "fr"
defaultContentLanguageInSubdir = false # FR à la racineLe scope réel : 32 fichiers
J’avais 16 articles éditoriaux sur Grav. × 2 langues = 32 fichiers Markdown à migrer.
Pour ne rien casser, j’ai d’abord posé une checklist par article :
- Front matter Grav (Twig metadata) → Front matter Hugo (YAML)
- Conversion des shortcodes Grav → shortcodes LoveIt
- Liens internes (
[lien](/posts/csp-nonce/)à vérifier dans les 2 sens) - Images : déplacer dans le page bundle, pas dans
static/images/ - Tags et categories alignés sur les conventions Hugo
- Date
date:en ISO 8601 (Grav utiliseDD-MM-YYYY HH:MM)
J’ai automatisé la conversion de date avec un petit script Python :
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:00Cohabitation FR/EN sans hack
LoveIt gère nativement les langues via l’URL. Le seul piège : le sélecteur de langue. Sur Grav, c’était une dropdown générée par Twig. Sur Hugo, c’est natif :
[languages]
[languages.fr]
weight = 1
languageName = "Français"
[languages.en]
weight = 2
languageName = "English"Le sélecteur (en haut à droite, icône globe 🌐) bascule automatiquement vers la version équivalente de la page courante.
Cohabitation static/ ↔ content/
Hugo a deux zones pour les ressources :
static/= assets globaux (favicon, logos, CSS custom). Servis directement.content/<route>/= page bundle (image hero d’un article spécifique).
Règle simple que j’ai adoptée :
- Si l’image est réutilisée sur plusieurs pages →
static/images/ - Si l’image appartient à une seule page → page bundle
Cette discipline évite les static/images/csp-nonce-diagram.png orphelins quand on supprime un article.
Migration MCP : chacun son rôle
J’ai gardé en parallèle un Hugo MCP Server (FastAPI, 7 tools) qui me permet d’éditer les articles depuis Claude.ai. Mais avec une règle : le MCP touche au contenu (content/), Git touche à la structure (layouts/, themes/, hugo.toml). C’est ce que j’appelle la « Stratégie 4 » — j’en parlerai dans un autre article.
Concrètement, content/ est dans .gitignore côté repo, mais sauvegardé via snapshots VM. Pas de conflit possible entre MCP et Git.
Ce qui m’a surpris (positivement)
- Build time : ~2 secondes pour les 16 articles × 2 langues. Sur Grav, le simple chargement d’une page passait par ~120 ms de PHP avant le rendu.
- Cohérence FR/EN : Hugo génère exactement les mêmes pages dans les deux langues sans config supplémentaire. Plus besoin du plugin
language-i18nde Grav. - Simplicité d’override : envie de modifier le rendu de la home ?
cp themes/LoveIt/layouts/index.html layouts/index.htmlpuis on édite. C’est le seul fichier custom dont j’ai eu besoin.
Ce qui m’a coûté
- Comprendre les nuances de
params.header.title: LoveIt a unthemes/LoveIt/hugo.tomlqui fournit des valeurs par défaut (name = "My cool site"). Si tu n’overrides pas explicitement dans tonhugo.toml, ces defaults polluent ton site. J’ai mis 1h à comprendre pourquoi mon header affichait “My cool site” alors quetitle = "arleo.eu"était bien défini. - L’override de
layouts/home.html: LoveIt filtre les articles avecwhere .Site.RegularPages "Type" "posts"mais mes articles sont à la racine decontent/(pas danscontent/posts/). Donc 0 article affiché en home. Solution : override minimal du layout (1 ligne de différence) + conventionhiddenFromHomePage: truedans le front matter pour les pages de référence (privacy, docs, scripts).
État final
| Mesure | Grav | Hugo |
|---|---|---|
| TTFB médian | ~480ms | ~50ms |
| Build | n/a (dynamique) | 2.1s |
| Surface PHP | 100% | 0% |
| Pages 404 après migration | 0 | 0 |
Le site Hugo tourne actuellement sur hugo-test.arleo.eu (VM KVM Ubuntu 24.04, dans le NUC). La bascule DNS finale arleo.eu → IP Hugo VM est planifiée d’ici quelques semaines, après validation finale du sprint sécurité MCP.
Conclusion
Une migration CMS dynamique → SSG, c’est moins risqué qu’on pense si on garde les URLs et qu’on prend le temps de comprendre les conventions du nouveau thème. Ne pas chercher à tout réinventer : Hugo + LoveIt sont matures, suivre les conventions par défaut paye.
Le travail le plus subtil n’a pas été la migration du contenu, mais la convergence visuelle avec le site Grav précédent (footer, menu, favicon, animations). C’est ce qui a pris le plus de temps en pratique. Mais ça, c’est une autre histoire.