TL;DR
En auditant la PR #78, une phrase a mis la puce à l'oreille : « qui régénère les flux RSS en production ? publish-article.mjs appelle generate-rss.mjs à chaque publication ». C'était vrai dans le dépôt. C'était faux sur le serveur.
L'enquête a révélé que le workflow de déploiement du blog échouait 30 fois sur 30 depuis juillet. Il poussait le code vers /var/www/blog.vfg.lol — un vestige de l'export statique v1, propriété de root, qu'aucune configuration Caddy ne sert — alors que le service tourne dans /opt/rapport-experientiel.
Trois symptômes invisibles de l'extérieur : /arbre/ en 404, aucun fil d'Ariane ni article relié, et des flux RSS figés au 12 juillet.
Le correctif tient en trois points. Le plus important n'est pas le chemin : c'est le contrôle anti-dérive ajouté pour que ce type de panne se signale tout seul.
1. Contexte : une phrase qui ne collait pas
Le point de départ n'était pas une alerte, ni un utilisateur mécontent. C'était une affirmation à vérifier dans la description d'une pull request.
En auditant la PR #78 (détrackage des flux RSS), je suis tombé sur ce raisonnement, présenté comme une preuve de robustesse : les fichiers XML versionnés étaient des fantômes, puisque de toute façon publish-article.mjs régénère les flux à chaque publication sur le VPS.
Vérification faite dans le dépôt : exact. Le hook existe, il est appelé. La conclusion semblait donc bonne.
Sauf qu'une question restait ouverte, et personne ne l'avait posée : est-ce que c'est bien ce publish-article.mjs-là qui tourne sur le VPS ?
Réponse : non. Et derrière cette réponse, tout un édifice de choses cassées sans que rien ne le dise.
2. Ce qui a été fait
| # | Action | Résultat |
|---|---|---|
| 1 | Vérifier l'état réel du VPS en lecture seule | blog-read/blog-admin actifs, WorkingDirectory=/opt/rapport-experientiel |
| 2 | Lire la config Caddy de blog.vfg.lol | reverse_proxy 127.0.0.1:5195 — aucun file_server |
| 3 | Comparer la cible du workflow à la réalité | BLOG_DEPLOY_PATH=/var/www/blog.vfg.lol (root:root) ≠ /opt |
| 4 | Lire l'historique des exécutions code-deploy | 30 runs, 30 échecs, tous à l'étape rsync |
| 5 | Vérifier la version du code déployé | relations.cjs, templates/, toc.js absents — antérieur à la v3 |
| 6 | Tester la fraîcheur des flux | RSS figé : 0 article de septembre, dernier item le 12/07 |
| 7 | Tester le redémarrage sans sudo sur un service secondaire | Fonctionne : nouveau PID, service active, HTTP 200 |
| 8 | Corriger le workflow + créer la variable dédiée | PR #78, commit 1708681 |
| 9 | Documenter l'inventaire manquant | vps-infra/docs/vps-inventory/services-systemd.md |
| 10 | Ajouter le contrôle anti-dérive au healthcheck | vps-rescue-scripts/vps-health-check.sh |
3. Détail technique
Le piège : deux répertoires, un seul vivant
Caddy blog.vfg.lol → reverse_proxy 127.0.0.1:5195
blog-read.service User=deploy WorkingDirectory=/opt/rapport-experientiel ✅ servi
code-deploy.yml BLOG_DEPLOY_PATH=/var/www/blog.vfg.lol ❌ root:root
Le workflow envoyait donc son rsync vers un répertoire que le site ne lit pas, et dans lequel l'utilisateur deploy n'a même pas le droit d'écrire :
rsync: mkstemp "/var/www/blog.vfg.lol/..." failed: Permission denied (13)
rsync error: code 23
30 exécutions sur 30. De juillet à septembre. Toujours la même erreur, jamais remontée à personne.
Le plus révélateur : la variable BLOG_DEPLOY_PATH n'était pas absurde à l'origine. /var/www/blog.vfg.lol était la bonne cible du temps où le blog était un export statique (v1). Quand le blog est devenu dynamique — un serveur Node dans /opt, derrière un reverse_proxy — le workflow a gardé l'ancien chemin. Personne n'a repensé la variable parce que personne ne regardait les runs en échec.
Le vestige, prouvé par l'expérience
Plutôt que de faire confiance à la lecture de la config Caddy, j'ai cherché un fichier présent uniquement dans /var/www :
| Fichier | Présent dans /var/www | Présent dans /opt | Servi en public |
|---|---|---|---|
build-meta.json | oui | non | 404 |
index.html | oui | non | — |
Conclusion nette : /var/www/blog.vfg.lol n'est plus qu'un vestige de 16 Mo.
Le deuxième obstacle : pas de sudo
Le workflow faisait sudo systemctl restart blog-read blog-admin. Or sur ce VPS, l'utilisateur deploy n'a pas de sudo NOPASSWD :
$ sudo -n true
sudo: a password is required
Même avec le bon chemin, l'étape aurait échoué. La solution était déjà dans le dépôt, dans deploy/vps/rollout.sh : les units tournent en User=deploy avec Restart=always/RestartSec=5, donc un simple signal suffit à les redémarrer.
Testé en conditions réelles sur blog-admin, avant d'écrire quoi que ce soit :
PID avant : 962834
signal envoye a 962834
PID apres : 680247
REDEMARRAGE CONFIRME
is-active : active
blog-admin : 200
Les trois corrections
Variable dédiée
BLOG_CODE_DEPLOY_PATH(défaut/opt/rapport-experientiel). Choix délibéré de ne pas réutiliserBLOG_DEPLOY_PATH: celle-ci est partagée avecblog-deploy.yml, qui y poussedist/(export statique de secours). La rediriger vers/optaurait mélangé un export statique au code vivant.Redémarrage sans sudo — une fonction
reload_unit()qui tentesudo -npuis retombe sur un signal au PID. Elle dégrade proprement siblog-adminrefuse, sans faire échouer tout le déploiement.Contrôle anti-dérive — le point important, détaillé juste après.
Le contrôle qui manquait
C'est la partie qui compte vraiment. Un curl /healthz renvoyait 200 pendant tout l'incident. Le service allait bien. Il servait simplement du vieux code.
Vérifier « le service répond » ne dit rien sur « le service sert ce que je viens de déployer ». Il fallait un marqueur de version observable de l'extérieur. Le blog en avait un sans le savoir : la route /arbre/, apparue avec la v3.
arbre_code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:5195/arbre/)
if [ "$arbre_code" != "200" ]; then
echo "ERREUR: /arbre/ renvoie $arbre_code après déploiement." >&2
echo "Le service ne sert pas ${DEPLOY_PATH} : vérifier le WorkingDirectory" >&2
exit 1
fi
Un 404 ici n'est pas « une route absente ». C'est la preuve que le service tourne sur une copie antérieure du code. Un marqueur de fonctionnalité utilisé comme témoin de version.
Le workflow échoue désormais franchement dans ce cas, au lieu de sortir vert.
Le même contrôle, côté exploitation
Ce contrôle ne sert pas qu'au déploiement. Je l'ai porté dans le healthcheck d'exploitation (vps-rescue-scripts/vps-health-check.sh), qui ne vérifiait ni Caddy ni les applications HTTP — seulement Docker, le disque, la RAM et quelques services systemd.
Sa nouvelle section 6 vérifie que blog-read/blog-admin tournent avec le bon WorkingDirectory, que /healthz répond, et que /arbre/ répond 200. Elle signale aussi le vestige /var/www s'il existe encore, et un flux RSS non régénéré depuis plus de 30 jours — ce qui aurait suffi, à lui seul, à détecter la panne en août.
Testé contre l'état réel, il échoue correctement :
6. 📝 Blog vfg.lol (services systemd)...
✓ blog-read actif
✓ blog-admin actif
✓ :5195/healthz répond 200
❌ :5195/arbre/ répond 404 — DÉRIVE : le service sert une
copie antérieure du code (attendu 200 depuis blog v3).
⚠️ /var/www/blog.vfg.lol existe encore (root:root) — vestige statique v1
⚠️ flux RSS non régénéré depuis 65 jours
Un healthcheck qui échoue sur la vraie panne qu'il est censé attraper, c'est la seule façon de savoir qu'il fonctionne.
4. Risques & limites
Risque : la correction est sur une branche de PR, pas sur
main. Le déploiement ne se déclenche que surmain→ la production reste en retard jusqu'à la fusion. → Mitigation : la PR #78 estMERGEABLE/CLEAN, la fusion est un geste humain explicite. Le healthcheck échouera bruyamment en attendant, ce qui est le comportement voulu.Risque :
deploydépend d'unkillplutôt que desystemctl. Si quelqu'un passaitRestart=nosur les units, le déploiement resterait vert alors que le service ne reviendrait pas. → Mitigation : le contrôle/arbre/échoue dans ce cas — le filet existe.Risque :
/arbre/est un marqueur de version implicite. Si une future version renommait la route, le contrôle casserait pour une mauvaise raison. → Mitigation : à remplacer par un vrai endpoint de version (/healthz.version) lors de la migration.Limite : je n'ai touché à rien sur le VPS. Diagnostic en lecture seule, correctifs dans les dépôts. Le redémarrage testé portait sur
blog-admin(service secondaire), pas surblog-read.Limite :
/optcontient 538 articles, le dépôt 524. La publication local-first écrit directement sur le VPS, donc le dépôt n'est pas la source des articles. Ce n'est pas un risque d'écrasement (devblog/posts/est exclu dursync), mais ça invalide l'intuition « le dépôt est la vérité ».Limite : deux vestiges subsistent —
/var/www/blog.vfg.lolet le conteneurvfg-fleet-blog-static-1(nginx, actif depuis 2 mois, port 8092 non mappé, non référencé par Caddy). Non supprimés : décision d'infra, pas de nettoyage à chaud sur une prod.
5. Prochaine session
- Fusionner la PR #78 → déclenche le premier déploiement vert (et le passage du v3/v4 en production)
- Vérifier
https://blog.vfg.lol/arbre/→ attendu 200 - Confirmer que les flux RSS reprennent (article de septembre présent)
- Trancher le sort de
/var/www/blog.vfg.lolet devfg-fleet-blog-static-1 - Envisager un vrai endpoint
/healthz.versionpour remplacer le marqueur implicite - Optionnel : règle
sudoerspourdeploysi l'on veut cesser de dépendre du signal
6. Liens
- PR #78 — RAPPORT-EXPERIENTIEL — correctif + détrackage des flux
- Inventaire des services systemd (
vps-infra, dépôt privé) —docs/vps-inventory/services-systemd.md, le chaînon manquant de l'inventaire - Handoff de session (
vps-infra, dépôt privé) —docs/journal/handoffs/2026-09-20-2145_handoff-deploy-blog-chemin-divergent.md - Healthcheck VPS (
vps-rescue-scripts, dépôt privé) —vps-health-check.sh, contrôle de dérive
Épilogue : ce que cette panne dit vraiment
Le détail le plus instructif n'est pas le chemin erroné. C'est que le workflow était en rouge depuis deux mois et que cela n'a rien déclenché.
Il y avait pourtant trois garde-fous dans ce dépôt : la suite de tests, la vérification des liens, la vérification des compteurs. Tous verts. Ils ne pouvaient pas voir le problème, parce qu'aucun ne regardait le monde réel — ils regardaient le dépôt.
Le seul contrôle qui a détecté la panne est celui qui pose une question différente : « ce que je viens de déployer est-il effectivement servi ? » Un healthz en 200 ne répond pas à cette question. Une route qui n'existe que dans la version à jour, si.
Une garde éteinte ne prévient jamais. Un déploiement rouge non plus, si personne ne le lit.
Session du 20 septembre 2026 — audit de la PR #78, diagnostic VPS en lecture seule (SSH deploy@<vps>), correctifs sur RAPPORT-EXPERIENTIEL, vps-infra et vps-rescue-scripts. Aucune mutation sur le VPS de production.