Contenu

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 :

  1. Performance — Statique pur. Pas de PHP, pas de DB. nginx sert directement les .html pré-générés.
  2. Sécurité — Surface d’attaque divisée par 10. Plus de PHP-FPM, plus d’exécution serveur sur les pages publiques.
  3. 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).

Diagram Diagram

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

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

Le 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 utilise DD-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:00

Cohabitation 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-i18n de Grav.
  • Simplicité d’override : envie de modifier le rendu de la home ? cp themes/LoveIt/layouts/index.html layouts/index.html puis 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 un themes/LoveIt/hugo.toml qui fournit des valeurs par défaut (name = "My cool site"). Si tu n’overrides pas explicitement dans ton hugo.toml, ces defaults polluent ton site. J’ai mis 1h à comprendre pourquoi mon header affichait “My cool site” alors que title = "arleo.eu" était bien défini.
  • L’override de layouts/home.html : LoveIt filtre les articles avec where .Site.RegularPages "Type" "posts" mais mes articles sont à la racine de content/ (pas dans content/posts/). Donc 0 article affiché en home. Solution : override minimal du layout (1 ligne de différence) + convention hiddenFromHomePage: true dans le front matter pour les pages de référence (privacy, docs, scripts).

État final

MesureGravHugo
TTFB médian~480ms~50ms
Buildn/a (dynamique)2.1s
Surface PHP100%0%
Pages 404 après migration00

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.