---
title: "Migration Grav → Hugo : 2 ans de blog basculés en une journée"
description: "Retex de la migration arleo.eu de Grav (PHP) vers Hugo (Go static) — bascule atomique zero downtime, 22 articles préservés avec aliases SEO, plugin-system MCP avec IndexNow/Google Indexing. Script open source sur GitHub."
url: "https://www.arleo.eu/posts/migration-grav-hugo/"
language: "fr"
datePublished: "2026-05-09T22:29:45+02:00"
dateModified: "2026-05-15T23:51:25+02:00"
tags: ["migration","hugo","grav","cms","retex","devops"]
categories: ["infrastructure"]
contentSignal: "ai-train=no, search=yes, ai-input=yes"
---

# Migration Grav → Hugo : 2 ans de blog basculés en une journée
Retex de la migration arleo.eu de Grav (PHP) vers Hugo (Go static) — bascule atomique zero downtime, 22 articles préservés avec aliases SEO, plugin-system MCP avec IndexNow/Google Indexing. Script open source sur GitHub.



## 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](https://github.com/jmrGrav/grav-to-hugo-migration).

<!--more-->

## Pourquoi quitter Grav

J'aimais beaucoup Grav. Pendant 2 ans, j'ai construit dessus :

- 5 plugins maison ([IndexNow](/posts/grav-plugin-indexnow/), [Google Indexing](/posts/grav-plugin-google-indexing/), [MCP server](/posts/grav-mcp-server/), [CSP nonce](/posts/csp-nonce/), Cache Purge)
- Un thème custom `arleov2` GPM-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 :

<!-- mermaid-source:Z3JhcGggTFIKICAgIFZpc2l0b3JbVmlzaXRldXJdIC0tPnxIVFRQU3wgQ0ZbQ2xvdWRmbGFyZV0KICAgIENGIC0tPnxjYWNoZSBISVR8IENGCiAgICBDRiAtLT58Y2FjaGUgTUlTU3wgTlVDW05VQyBuZ2lueF0KICAgIE5VQyAtLT58cmV2ZXJzZSBwcm94eXwgVk1bVk0gSHVnbyBuZ2lueF0KICAgIFZNIC0tPnxzZXJ0IEhUTUwgc3RhdGlxdWV8IFN0YXRpY1siL3Zhci93d3cvaHVnby8iXQogICAgCiAgICBzdWJncmFwaCAiVk0gSHVnbyAoMTkyLjE2OC4xMjIuNjkpIgogICAgICAgIFZNCiAgICAgICAgU3RhdGljCiAgICAgICAgTUNQW2h1Z28tbWNwIFB5dGhvbl0KICAgICAgICBNQ1AgLS0+fGfDqW7DqHJlfCBTdGF0aWMKICAgIGVuZAogICAgCiAgICBzdWJncmFwaCAiTlVDIChob3N0KSIKICAgICAgICBOVUMKICAgICAgICBHcmF2W2dyYXYuYXJsZW8uZXUgTEFOLW9ubHk8YnIvPmFyY2hpdmUgMzAgam91cnNdCiAgICBlbmQKICAgIAogICAgQ2xhdWRlW0NsYXVkZS5haV0gLS4tPnxNQ1AgdG9vbHMvY2FsbHwgTUNQ -->
{{< mermaid-svg hash="8a73bd3c" >}}

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

| # | 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**.

```yaml
# 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 :

```html
<!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](https://github.com/jmrGrav/grav-to-hugo-migration)) :

```bash
# 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](/posts/hugo-mcp-plugins-architecture/).

## 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](/posts/sprint-securite-mcp-livre/). 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](/posts/postmortem-typeit-mermaid/).

**`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](https://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.


## Tags

- migration
- hugo
- grav
- cms
- retex
- devops

## Categories

- infrastructure
