Comment j'ai construit khodax.com — Ghost auto-hébergé sur mon cluster K3s

KEVIN DARMON · · 5 min read
Baie de serveurs dans une salle de datacenter
🇬🇧 English version available → Read this article in English
TL;DR
  • khodax.com tourne sur mon cluster K3s : Ghost + MySQL, GitOps via ArgoCD, thème maison versionné dans mon GitLab self-hosted
  • Claude Code pilote la moitié du travail éditorial via un serveur MCP maison branché sur l'API Admin de Ghost
  • Renovate a cassé le site deux fois en une semaine — voici précisément ce qui s'est passé et comment j'ai blindé le pipeline

Ce blog tourne sur la même infrastructure que mon Jellyfin, mon Pi-hole et mon instance n8n. Pas de Ghost(Pro), pas de Vercel, pas de plan managé à 9 $/mois. Un pod de plus dans un cluster K3s qui en fait déjà tourner trente-six autres.

Certains vont trouver ça absurde — un simple blog ne justifie pas cette complexité. Ils n'ont pas tort. Je l'ai fait quand même, et voici comment.


Pourquoi pas juste Ghost(Pro) ?

Parce que j'ai déjà le cluster. Sept nœuds K3s qui tournent 24/7 pour héberger mes services perso, un budget d'hébergement déjà amorti, et une pile GitOps (ArgoCD + un repo de config versionné) qui gère tout le reste. Ajouter Ghost dedans, c'était une ligne de manifeste de plus, pas un nouveau compte à gérer.

L'autre raison, plus honnête : je voulais un terrain de jeu. Un vrai projet avec de vrais enjeux — DNS, TLS, bilinguisme, SEO — pour tester deux ou trois idées que j'avais en tête depuis un moment, notamment faire écrire et publier le blog par Claude Code en quasi-autonomie. Ça, aucun hébergeur managé ne le permet.


L'architecture

Rien d'exotique, mais tout est connecté : Ghost tourne en pod dans le namespace blog, base MySQL dans le même namespace, Traefik comme ingress controller devant, Cloudflare en DNS/proxy devant Traefik. Le tout déclaré en YAML dans un repo Git, synchronisé automatiquement par ArgoCD — je ne touche jamais kubectl apply à la main sur cette app.

Le routage bilingue FR/EN vit dans routes.yaml, un fichier de config Ghost qui définit des collections d'URLs :

routes:
  /: /

collections:
  /:
    permalink: /{slug}/
    filter: tag:-en
  /en/:
    permalink: /en/{slug}/
    filter: tag:en

taxonomies:
  tag: /tag/{slug}/
  author: /author/{slug}/

Le tag en sur un article suffit à le faire apparaître sous /en/{slug}/ et à le sortir des flux FR — pas de plugin, pas de logique côté thème. C'est ce genre de détail qui m'a fait préférer Ghost à un CMS plus verrouillé : la config vit dans un fichier texte versionnable, pas dans une base de données propriétaire.


Le thème maison, sous Git

Le thème (khodax-theme) est du Handlebars pur, écrit à la main, sans thème de base Ghost derrière. Ça vivait dans un dossier local sans aucun contrôle de version jusqu'à début juillet — une négligence qui m'a coûté cher, j'y reviens plus bas.

C'est maintenant un repo Git normal, poussé sur mon GitLab self-hosted, avec un script de déploiement :

./scripts/deploy.sh
# → build un zip propre dans dist/
# → upload via l'API Admin Ghost (JWT)
# → active le thème
# → rappelle de vérifier le rendu en cache-busted

Chaque changement notable est un commit + un tag. Si le rendu casse après un déploiement, je peux enfin faire un git diff contre la dernière version qui marchait — avant, je devais deviner.


Claude Code comme rédacteur, via un serveur MCP maison

La partie la plus inhabituelle de ce setup : j'ai un petit serveur MCP (ghost-mcp, du Node.js maison) qui expose l'API Admin de Ghost à Claude Code — créer un brouillon, poser des tags, uploader une image à la une, publier. Une routine planifiée (mardi et jeudi 8h) lit ma roadmap éditoriale, rédige l'article en français puis l'adapte en anglais, cherche une photo libre de droits, et publie les deux versions sans que j'intervienne.

Je valide après coup, pas avant. C'est un choix assumé, pas une négligence — je voulais savoir si une IA pouvait tenir un rythme éditorial sur la durée sans supervision constante. Pour l'instant, ça tient. D'ailleurs cet article-là même est écrit par cette routine.


Renovate m'a offert deux pannes en prime

Honnêtement, la partie la plus instructive de ce projet, ce sont les deux fois où il est tombé en marche.

La première : Renovate a fusionné une mise à jour majeure de MySQL (8.4 → 9.7) sans que je surveille d'assez près. MySQL 9.7 a migré les données au premier boot, puis planté sur un flag d'authentification supprimé dans cette version. J'ai eu le réflexe de revenir en arrière sur le tag précédent — mauvaise idée : MySQL refuse catégoriquement de redescendre une base déjà migrée vers une version supérieure. Le seul chemin de sortie, c'était d'avancer, pas de reculer. Ghost est resté sans base de données pendant plusieurs heures le temps que je comprenne ça.

La seconde, plus vicieuse : ce même Renovate a fait passer Ghost en version majeure 6. Or Ghost 6 a changé la façon dont Handlebars expose le contexte dans les templates — {{content}} et {{title}} appelés hors du bloc {{#post}} ne remontent plus rien. Mon thème, écrit sous Ghost 5, appelait ces variables à la racine. Résultat : chaque page du site affichait littéralement le mot « undefined » à la place du contenu, alors que tous les codes HTTP restaient à 200. Une vérification qui se contente d'un curl et d'un code de statut ne voit rien passer.

Depuis : les montées de version majeure de bases de données (MySQL, Postgres, MariaDB) exigent une approbation manuelle via le dependency dashboard de Renovate, plus jamais un merge automatique. Et ma checklist de vérification post-déploiement grep désormais le contenu réel de la page, pas seulement le code de retour.


Les sauvegardes ont failli ne pas suffire

Un job nightly dump la base MySQL de Ghost chaque nuit à 3h — mysqldump classique, rien de sophistiqué. Pendant la panne MySQL décrite plus haut, ce job a échoué (base indisponible = rien à sauvegarder), et je me suis retrouvé avec un dump valide vieux de plusieurs jours comme seul filet de sécurité.

En creusant pour évaluer les dégâts potentiels, j'ai réalisé un détail qui m'a fait un peu froid dans le dos : le tout premier article de ce blog avait été publié à 16h, et le dernier dump réussi datait de 3h le même jour. Si j'avais dû restaurer, cet article aurait simplement disparu. Heureusement, le roll-forward MySQL a fonctionné et la base en place a survécu — je n'ai jamais eu à tester ce scénario en vrai.

Ça reste une faille que je n'ai pas encore fermée correctement : un dump quotidien protège contre la perte totale, pas contre treize heures de contenu entre deux sauvegardes. Sur la liste des chantiers, sans date fixée pour l'instant.


Ce que je referais différemment

Mettre le thème sous Git dès le premier jour plutôt qu'au bout de trois mois. Et configurer les garde-fous Renovate sur les bases de données avant la première mise à jour majeure, pas après le premier incident.

Si vous envisagez d'auto-héberger votre propre blog sur un cluster que vous possédez déjà : commencez par versionner votre thème, verrouillez les majeures de votre base de données dans votre outil de mise à jour, et testez votre pipeline de vérification post-déploiement en cassant volontairement quelque chose avant que ça n'arrive tout seul un mardi soir.