Contenu

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 :

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 :

Diagram Diagram

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 publique
  • mcp-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é.

#PhaseDuréeRisque
0Cartographie auto (vhosts, DNS, mode Hugo, articles à migrer)15 minAucun (lecture seule)
1Création grav.arleo.eu LAN-only (DNS + WAF rule + vhost)30 minFaible
2Migration /posts/ + aliases sur VM Hugo45 minMoyen
3Préparation vhost www.arleo.eu Hugo (sites-available, pas activé)30 minAucun
4Atomic flip Grav → Hugo (≈ 0s downtime)15 minÉlevé
5Suppression hugo-test.arleo.eu (DNS + vhost)10 minFaible
6Plugin-system + IndexNow + Google Indexing1h30Moyen
7Bulk submission 24 URLs aux moteurs15 minFaible
8(J+7 à +30) Page d’au revoir Grav puis archivageFaible

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 :

  1. <link rel="canonical"> — Google sait que la vraie URL est /posts/..., pas la racine
  2. <meta http-equiv="refresh"> — redirection navigateur instantanée
  3. <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_NOW

Critè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 utilisateur

Chaque 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 automatique
  • nginx-templates/ — vhosts prêts à l’emploi (Hugo prod, Grav archive, reverse proxy NUC→VM)
  • hugo-config-skeleton/hugo.toml minimal LoveIt-ready, multilingue FR+EN
  • README.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 :

  1. Un staging complet (hugo-test.arleo.eu chez moi) où tout est validé avant la bascule prod
  2. Des aliases pour le SEO — Hugo natif, pas besoin de bricoler nginx
  3. Une bascule atomique avec critère de réussite mesurable et rollback automatique (/ping)
  4. 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.