Migration Grav → Hugo : 2 ans de blog basculés en une journée

TL;DR
Le 9 mai 2026, j’ai basculé arleo.eu de Grav (CMS PHP) vers Hugo (générateur de site statique Go) en une session. Bascule atomique (≈ 0 seconde de downtime), 22 articles legacy migrés sous /posts/ avec aliases SEO pour préserver les URLs Google indexées, monitoring BetterStack /ping intact pendant toute l’opération.
Le code et le script de migration sont open source : github.com/jmrGrav/grav-to-hugo-migration.
Pourquoi quitter Grav
J’aimais beaucoup Grav. Pendant 2 ans, j’ai construit dessus :
- 5 plugins maison (IndexNow, Google Indexing, MCP server, CSP nonce, Cache Purge)
- Un thème custom
arleov2GPM-compliant - Un workflow MCP avec Claude.ai pour publier en langage naturel
Pourquoi changer alors ? Trois raisons convergentes :
1. Performance. Grav génère chaque page en PHP à chaque requête, même avec un microcache nginx devant. Hugo pré-build tout en HTML statique : nginx sert un fichier sur disque, point. Pour un blog perso sans contenu dynamique, c’est 10× plus rapide et 100× moins coûteux en CPU.
2. Surface d’attaque réduite. Plus de PHP-FPM, plus de routes admin Grav (/admin), plus de plugins exécutant du code à chaque requête. Le serveur sert des .html figés, point. ModSecurity et le WAF Cloudflare protègent une surface beaucoup plus petite.
3. Build reproductible. Hugo prend du Markdown + une config TOML, sort un dossier statique. Le résultat est déterministe : même contenu = même output. Versionnable, diffable, atomique. Avec Grav, deux saves sur la même page peuvent générer du HTML légèrement différent selon le cache.
L’inconvénient : perdre l’admin web Grav. Mais comme je publiais déjà 90% du temps via mon plugin MCP en parlant à Claude.ai, l’admin était devenu accessoire. Hugo + un MCP server custom = même workflow, infrastructure plus simple.
Architecture cible
Le résultat final ressemble à ça :
Trois composants principaux :
www.arleo.eu— public, prod, servi par nginx VM en static (reverse proxy NUC → VM)grav.arleo.eu— LAN-only via WAF Cloudflare (allow IP fixe), Grav en archive 30 jours puis page d’au revoir publiquemcp-hugo.arleo.eu— endpoint MCP pour Claude.ai (création/édition de pages en langage naturel)
L’ancien sous-domaine hugo-test.arleo.eu (staging Hugo) a été retiré après bascule.
La méthode : 8 phases
Pour ne pas casser le SEO ni le monitoring, j’ai découpé la migration en 8 phases atomiques avec critères de réussite mesurables. Chaque phase a un rollback documenté.
| # | Phase | Durée | Risque |
|---|---|---|---|
| 0 | Cartographie auto (vhosts, DNS, mode Hugo, articles à migrer) | 15 min | Aucun (lecture seule) |
| 1 | Création grav.arleo.eu LAN-only (DNS + WAF rule + vhost) | 30 min | Faible |
| 2 | Migration /posts/ + aliases sur VM Hugo | 45 min | Moyen |
| 3 | Préparation vhost www.arleo.eu Hugo (sites-available, pas activé) | 30 min | Aucun |
| 4 | Atomic flip Grav → Hugo (≈ 0s downtime) | 15 min | Élevé |
| 5 | Suppression hugo-test.arleo.eu (DNS + vhost) | 10 min | Faible |
| 6 | Plugin-system + IndexNow + Google Indexing | 1h30 | Moyen |
| 7 | Bulk submission 24 URLs aux moteurs | 15 min | Faible |
| 8 | (J+7 à +30) Page d’au revoir Grav puis archivage | — | Faible |
L’enchaînement est important : R4 (migration /posts/) avant le flip prod pour que les aliases soient prêts au moment du basculement, sinon les URLs Google indexées renvoient 404 le temps de la migration.
Préserver le SEO : le mécanisme des aliases Hugo
C’est le point le plus critique de la migration. Mon site a des URLs racine indexées par Google depuis 2 ans (ex: https://www.arleo.eu/grav-plugin-indexnow/). Si je bascule sur Hugo qui génère ces articles sous /posts/grav-plugin-indexnow/, je perds tous mes liens entrants et le PageRank accumulé.
La solution Hugo : aliases dans le frontmatter.
# content/posts/grav-plugin-indexnow/index.fr.md
---
title: "Plugin Grav IndexNow"
featuredImage: /images/migration-grav-hugo-featured.jpg
aliases:
- /grav-plugin-indexnow/ # ancienne URL Grav
---Hugo génère alors un fichier public/grav-plugin-indexnow/index.html avec :
<!DOCTYPE html>
<html lang="fr">
<head>
<title>https://www.arleo.eu/posts/grav-plugin-indexnow/</title>
<link rel="canonical" href="https://www.arleo.eu/posts/grav-plugin-indexnow/">
<meta http-equiv="refresh" content="0; url=https://www.arleo.eu/posts/grav-plugin-indexnow/">
</head>
<body>...</body>
</html>Trois mécanismes en un :
<link rel="canonical">— Google sait que la vraie URL est/posts/..., pas la racine<meta http-equiv="refresh">— redirection navigateur instantanée<title>= nouvelle URL — pédagogique pour les rares cas où la redirection ne se déclenche pas
Le résultat côté Google : en 3-12 semaines, le canonique est adopté, et le link juice (autorité accumulée) se transfère vers /posts/<slug>/. Pendant la transition, les deux URLs servent, donc aucun lien externe ne casse.
J’ai migré 12 articles legacy (10 autres natifs Hugo n’ont pas besoin d’alias). Pour chaque article, alias FR vers /<slug>/ et alias EN vers /en/<slug>/. Sitemap automatiquement régénéré sur les nouvelles URLs canoniques.
L’atomic flip : downtime quasi nul avec 100+ visiteurs/jour
Le moment critique. À T-0, www.arleo.eu doit basculer de Grav (root /var/www/grav) vers Hugo (reverse proxy vers la VM) sans coupure perceptible.
Le mécanisme nginx est simple mais demande de la rigueur. Voici le squelette du flip (le détail complet est dans le repo GitHub) :
# 1. Backup vhost actuel
sudo cp /etc/nginx/sites-enabled/www.arleo.eu \
/tmp/backup-$(date +%s).conf
# 2. Désactiver vhost Grav (mv vers backup)
sudo mv /etc/nginx/sites-enabled/www.arleo.eu \
/tmp/www.arleo.eu.disabled
# 3. Activer vhost Hugo (déjà en sites-available, déjà testé)
sudo ln -s /etc/nginx/sites-available/www.arleo.eu.hugo \
/etc/nginx/sites-enabled/
# 4. Test syntaxe (DERNIÈRE chance avant reload)
sudo nginx -t || ROLLBACK
# 5. Reload (instantané, ne ferme aucune connexion en cours)
sudo systemctl reload nginx
# 6. Vérifications immédiates avec rollback automatique
PING=$(curl -s -o /dev/null -w "%{http_code}" -u "$USER:$PASS" https://www.arleo.eu/ping)
[[ "$PING" != "200" ]] && ROLLBACK_NOWCritère de réussite non-négociable : /ping doit retourner 200 immédiatement après le reload. Si ce n’est pas le cas, rollback automatique sans question. Pourquoi ? Parce que BetterStack monitore /ping toutes les 30 secondes et alerterait par SMS dans les 2 minutes. Mieux vaut rollback dans 30 secondes que recevoir un SMS d’alerte un samedi soir.
Le nginx -s reload (signal SIGHUP) ne ferme pas les connexions en cours — il démarre de nouveaux workers avec la nouvelle config et laisse les anciens finir leurs requêtes. C’est ce qui rend le downtime perceptible nul, même avec 100+ visiteurs/jour.
Plugin-system MCP : IndexNow + Google Indexing
Hugo génère des fichiers statiques. Quand un nouvel article est publié, il faut notifier les moteurs de recherche sinon ils découvrent la nouvelle page lors de leur prochain crawl naturel (heures à jours). Avec Grav, j’avais 2 plugins PHP (indexnow, google-indexing) qui faisaient ce job sur les hooks onAdminAfterSave et onMcpAfterSave. Pour Hugo, il fallait l’équivalent.
J’ai construit un plugin-system Python dans hugo-mcp plutôt qu’un module monolithique. Architecture :
hugo-mcp/
├── core/
│ ├── plugin_base.py # Contrat HugoMcpPlugin (3 méthodes abstraites)
│ └── plugin_loader.py # Découverte automatique au démarrage
├── plugins/
│ ├── _template/ # Squelette pour contributeurs
│ ├── indexnow/ # Soumet à Bing/Yandex
│ └── google-indexing/ # Soumet à Google API v3
└── config/
└── plugins.yaml # Activation + secrets utilisateurChaque plugin implémente le contrat HugoMcpPlugin qui définit 3 méthodes :
is_enabled(config)— le plugin est-il activé ?validate_config(config)— la config est-elle valide ?on_page_event(event_type, urls, context)— hook async appelé après build
Le plugin loader scanne plugins/*/plugin.py au démarrage, instancie ceux activés dans config/plugins.yaml, et les appelle en parallèle après chaque create_page / update_page / delete_page avec un timeout de 10 secondes par plugin.
Détails complets dans le post dédié : Plugin-system Hugo MCP : architecture extensible.
Pièges rencontrés
Pour qui referait la migration, voici les pièges qui m’ont coûté du temps :
Ownership des dossiers content/ et public/. Hugo build veut écrire dans public/, mais hugo-mcp (qui tourne en hugo-mcp:hugo-mcp via systemd) veut aussi écrire dans content/. Solution : content/ en hugo-mcp:hugo-mcp avec g+w, l’utilisateur jm ajouté au groupe hugo-mcp pour pouvoir builder en CLI. Avant rebuild manuel, chown -R jm:jm public/ puis restauration.
systemd ProtectSystem=strict. Le service hugo-mcp ne peut écrire que dans les chemins listés dans ReadWritePaths. Le plugin Google Indexing voulait écrire /var/lib/hugo-mcp/google-indexing-quota.json → erreur EROFS. Fix : ajouter /var/lib/hugo-mcp à ReadWritePaths et créer le répertoire avec les bonnes permissions.
systemd IPAddressAllow. Le hardening systemd C1.7 que j’avais appliqué bloque tous les outbound non whitelistés. Le plugin Google Indexing échouait en timeout sur oauth2.googleapis.com et indexing.googleapis.com parce que les IPs Google n’étaient pas dans la whitelist. Fix : ajouter les plages Google (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 sur le contenu Markdown. Les posts techniques contiennent du jargon (token rotation, bcrypt, MITM, SQL injection) qui matche les règles SQLi/XSS/RCE par défaut. Score d’anomalie 30 sur seuil 10 → 403 silencieux côté nginx, mappé en “additional permissions required” côté claude.ai. J’ai brûlé 4 conversations à blâmer le mauvais coupable avant que Claude Code identifie la vraie cause en SSH. Récit complet dans le post sprint sécu. Fix : SecRuleRemoveById ciblées sur les rule IDs précises (932230, 932235, 932250, 932340, 941400, 942360, 949110, 959100), pas modsecurity off global.
TypeIt cassé par un Mermaid orphelin dans la home. Le typewriter LoveIt s’initialise via un sélecteur DOM #id-1. Si un bloc Mermaid apparaît dans le résumé de la home (ce qui se produit quand un post n’a pas de marqueur <!--more--> avant son premier diagramme), Mermaid prend cet id-1 en premier, plante l’init avec mermaid.initialize() undefined, et la chaîne d’initialisation JS s’arrête → TypeIt ne tourne jamais. Fix : ajouter <!--more--> avant le premier bloc Mermaid de tous les posts concernés. Postmortem dédié : TypeIt + Mermaid : conflit JS dans LoveIt.
hugo build sans --cleanDestinationDir. Quand on supprime un article via delete_page MCP, Hugo rebuild ne supprime pas le répertoire public/posts/<slug>/ correspondant. La page reste servie (état zombie) jusqu’à un build manuel avec --cleanDestinationDir. Bug à fixer côté hugo-mcp.
Le repo GitHub
Tout le tooling de migration est sur github.com/jmrGrav/grav-to-hugo-migration :
migrate.sh— script bash idempotent, mode dry-run par défaut, rollback automatiquenginx-templates/— vhosts prêts à l’emploi (Hugo prod, Grav archive, reverse proxy NUC→VM)hugo-config-skeleton/—hugo.tomlminimal LoveIt-ready, multilingue FR+ENREADME.md— step-by-step pour reproduire la migration sur ton infra
Licence MIT, contributions bienvenues.
Conclusion
Migrer un blog 2-ans-d’historique en une journée demande de la rigueur mais n’a rien d’inhumain. Les ingrédients :
- Un staging complet (
hugo-test.arleo.euchez moi) où tout est validé avant la bascule prod - Des aliases pour le SEO — Hugo natif, pas besoin de bricoler nginx
- Une bascule atomique avec critère de réussite mesurable et rollback automatique (
/ping) - Un script idempotent plutôt que des commandes manuelles à la chaîne
Le résultat aujourd’hui : arleo.eu charge en 80 ms (vs 350 ms en Grav avec microcache), nginx consomme 30 MB de RAM (vs 800 MB pour PHP-FPM), et je publie toujours via Claude.ai en parlant en français.
Et l’archive Grav reste accessible 30 jours sur grav.arleo.eu (LAN-only) avant la page d’au revoir publique. Continuité opérationnelle, pas d’orphelin.
Prochaine itération : un plugin Cloudflare pour le purge ciblé (URLs liées seulement, pas full purge à chaque modif), et la conversion hall-of-fame.html en page Hugo native pour intégrer au thème.
Bonne migration à qui s’y lance.