Cette transition difficile dev → prod — K3s, k8s, CI/CD bien rodée
46 jours de chantier pour passer d'un docker-compose sur ma machine à un GitOps qui redéploie tout seul quand je commit. Ce qui était dur, ce qui a payé, ce que je ferais différemment.
Pendant longtemps, mes services tournaient en docker-compose sur ma machine dev. Ça marche, jusqu'au jour où tu veux les rendre vraiment fiables. Cette page raconte les 46 jours de chantier qui m'ont fait passer du compose à un cluster Kubernetes maison piloté en GitOps, et les leçons que j'en tire.
C'est un chemin parsemé de patterns à connaître. Si tu envisages la même transition, ça peut t'épargner les ornières dans lesquelles je suis tombé.
L'état avant
Avant fin mars 2026, mon écosystème ressemblait à ceci :
- Un serveur (hub-srv) avec un cluster k3s existant qui hébergeait quelques services publics, mais sans pipeline CI/CD propre. Les déploiements se faisaient à la main : SSH,
kubectl apply, prie pour que ça passe. - Mon poste dev (dams) avec docker-compose pour tout ce qui était expérimental ou semi-permanent : memories, ledger, hub-vscode, hub-n8n.
- Un microk8s dormant sur dams, vestige d'un essai antérieur.
- Pas de registry Docker privé. Les images étaient pushées sur Docker Hub avec des tags timestampés.
- Pas de
versions.yaml. Chaque service avait sa propre version dans son repo, parfois synchronisée, parfois pas.
C'était fonctionnel. Ça ne tenait pas l'effort. Au moindre redémarrage de dams, la moitié des services étaient inaccessibles. Au moindre commit, je devais réfléchir à où était déployée la version actuelle.
J'ai ouvert le chantier le 28 mars 2026 avec un commit initial dans un nouveau repo nommé hub-deploy. Ce serait le GitOps repo — la source de vérité pour quoi tourne où, à quelle version.
Ce qui a été construit
Le repo hub-deploy contient cinq choses essentielles, qui se sont stabilisées au fil des semaines.
Un versions.yaml qui liste chaque service avec sa version actuelle. Deux sections : kubernetes pour les services déployés sur k3s, pc-dev pour les services en compose-dams. Un push qui modifie ce fichier déclenche le redéploiement.
Point fondamental qui rend l'ensemble vivable : chaque repo de service a sa propre CI qui auto-bumpe son
VERSIONà chaque push surmain, build l'image, push l'image et le chart Helm OCI dans harbor avec le nouveau tag, et tag le commit. Quand je veux déployer en prod, je ne touche qu'au numéro de version sur la ligne du service dansversions.yaml— je n'ajoute jamais de lignes, je ne calcule jamais le tag à la main, je ne pousse jamais une image manuellement. Le numéro existe parce que la CI vient de le créer ; le déploiement consomme une image qui est déjà là. Plus jamais de « j'ai déployé une version qui n'existe pas ».
Un script apply-helm-releases.sh qui lit versions.yaml, détecte les différences depuis le dernier commit (git diff $BASE_SHA), et fait helm upgrade --install pour chaque service modifié. Deux modes : oci pour les charts récents (Helm OCI dans harbor), legacy pour les services qui utilisent encore un chart générique central.
Un workflow GitHub Actions ci-deploy.yml qui se déclenche sur push de la branche main. Il prépare l'environnement (kubectl, helm), met à jour les ConfigMaps de versions, puis appelle le script ci-dessus.
Des values/<service>.yaml pour chaque service legacy, qui décrivent ses spécificités (env vars, secrets, ingress). Les services OCI ont leurs values dans leur propre repo.
Des secrets de cluster (hub-deploy-api, harbor-creds, mcp-secrets, etc.) créés par le workflow avant le déploiement. Pas de gestion de secrets externalisée pour l'instant — tout passe par des secrets Kubernetes natifs et des variables CI.
La chronologie
46 jours, 23 jours actifs, 189 commits utiles, 14 000 lignes de code nettes. Six sessions de travail séparées par des pauses, parce que le chantier était fait à côté du reste.
Session 1 (28 mars - 5 avril, 9 jours) — le gros œuvre. Le versions.yaml initial, le script apply-helm-releases.sh, le workflow ci-deploy.yml, les premières migrations de services (hub-dashboard, hubtree, MCP-Unified). 92 commits. Pendant cette session, l'écosystème entier a basculé depuis « déploiement manuel » vers « déploiement GitOps ».
Les commits qui marquent cette session : Initial commit: GitOps deployment repo, Add self-hosted runner as k3s pod + adapt deploy workflow, deploy: update service versions to 1.0.0 for hubtree and MCP-Unified, feat: enhance CI/CD pipeline with SonarQube quality gate check.
Session 2 (9 avril, 1 jour) — un fix urgent sur un service qui s'était cassé. Une journée, un commit.
Session 3 (11 avril, 1 jour) — petits ajustements sur le pipeline. Trois commits.
Session 4 (24-25 avril, 2 jours) — ajout d'authentik (l'auth OIDC) au déploiement. Six commits, 442 LOC.
Session 5 (1-5 mai, 5 jours) — l'amélioration du pipeline. Création des secrets via le workflow plutôt qu'à la main, restructuration des étapes, intégration de sonarqube Quality Gate. 26 commits. Le commit phare est feat(hub-agent): add secret creation for hub-agent-token le 3 mai (4 600 LOC en une journée — une grosse refonte des secrets et du runner).
Session 6 (8-12 mai, 5 jours) — la phase de stabilisation. Migration de ledger et memories vers le pattern OCI le 10 mai, inscription de nouveaux services (hub-projects, hub-www), corrections de bugs détectés en cours d'usage. 59 commits, beaucoup plus petits mais plus précis.
Ce qui était dur
Plusieurs choses ont été plus difficiles que je ne l'imaginais.
Les ConfigMaps de versions sont fragiles. L'application qui lit ses propres versions de dépendances depuis un ConfigMap doit être robuste aux versions absentes. Plusieurs services ont crashé silencieusement parce que le ConfigMap était à moitié peuplé. Solution : un schema strict et un endpoint /health qui valide la présence de tous les champs au boot.
Les self-hosted runners en pod sont complexes à debugger. J'ai installé un GitHub runner directement dans le cluster pour que les workflows puissent appliquer des manifests sans devoir s'authentifier auprès d'une API externe. C'est élégant en théorie. En pratique, quand le runner se casse, tu n'as pas de logs centralisés — tu dois kubectl logs dans le bon namespace pour voir ce qui se passe. Une journée perdue sur ce sujet.
La détection des changements est subtile. Le script apply-helm-releases.sh compare les inputs (versions.yaml + values/* + overrides/*) entre $BASE_SHA et HEAD. Au début, je passais HEAD~1 comme base, mais ça plantait sur les premiers commits de l'historique. Maintenant je passe github.event.before. Cas particulier : FORCE_ALL=1 permet de redéployer tout sans diff, en cas de recovery après drift manuel.
Les ordres de déploiement comptent. Quand postgres doit redémarrer, les services qui en dépendent (knowledge-api, MCP-Unified, hub-dashboard) sont en erreur jusqu'à ce qu'il soit Ready. Le helm --wait aide individuellement, mais ne respecte pas l'ordre inter-services. Solution : une section dependencies: dans versions.yaml qui décrit le graphe, exploitée par hub-dashboard pour le « restart cascade ».
Ce qui a payé
D'autres choses ont rendu cinq mois de stabilité possibles à partir de ce que j'avais.
Un seul versions.yaml. Avoir une vue de l'état désiré dans un seul fichier change la façon de penser. Quand quelqu'un demande « c'est quoi la version de knowledge-api en prod ? », la réponse est dans le fichier. Pas dans le cluster (qui peut être désynchronisé), pas dans un wiki, pas dans la tête.
Les charts OCI. Le pattern chart Helm embarqué dans le repo du service, publié dans harbor à chaque CI a éliminé une couche de friction. Avant, j'avais un chart générique central dans hub-deploy avec des values par service ; chaque évolution du service forçait à toucher le chart central. Maintenant, le chart évolue avec le service, et hub-deploy ne fait plus que tirer une version OCI.
Le sonarqube quality gate non-bloquant. Avoir un audit qualité à chaque déploiement, mais sans bloquer en cas d'échec. Ça donne un signal sans devenir un obstacle. Le CI continue, le résultat Sonar est visible dans le summary GitHub Actions et peut être adressé plus tard.
Le self-hosted runner dans le cluster. Une fois debug, le pattern est très propre : les workflows ont accès direct au kube-api, pas besoin de gérer des credentials externes. C'est ce qui rend le déploiement vraiment automatique.
Ce que je ferais différemment
Avec le recul, deux choses méritaient d'être faites plus tôt.
Faire un service pilote. J'ai migré quatre services en parallèle au début. C'était trop. Faire d'abord un seul service end-to-end (commit → CI → image dans registry → chart push → déploiement → vérification), valider, puis répliquer le pattern aurait été plus rapide. C'est exactement ce qu'on a fait plus tard avec ledger pour le pattern OCI, et c'est bien plus propre.
Documenter les patterns dans la Knowledge en parallèle. Quand j'ai commencé hub-deploy, je n'avais pas encore le réflexe MCP-first. Une grande partie des décisions étaient dans des commit messages, pas dans des ADR. Quand j'ai voulu, six semaines plus tard, expliquer pourquoi telle ou telle convention, j'ai dû relire l'historique Git. Aujourd'hui, je rédige l'ADR au moment où je prends la décision, pas après.
L'état après
Au 17 mai 2026, l'écosystème est entièrement déployé en GitOps, jusqu'aux nouvelles briques arrivées le jour-même. Un push sur hub-deploy met 3 à 5 minutes pour déployer les services modifiés. L'ensemble des services qui étaient en compose-dams sont passés sur k3s — seul harbor (le registry Docker) reste en compose, par décision assumée que je raconte ailleurs.
Le versions.yaml est devenu un fichier que je modifie aussi souvent que mon code applicatif. Pour le déploiement courant — bumper la version d'un service existant — il n'y a qu'un seul nombre à changer sur la ligne du service (la CI du repo a déjà publié l'image et le chart OCI sous ce tag). Pour intégrer un tout nouveau service la première fois, c'est trois lignes : une dans kubernetes:, une dans dependencies:, une dans apply-helm-releases.sh.
Le cluster a été consolidé en un seul, avec dams comme agent. La distinction « production » et « dev-box » se fait par taint et tolerations, pas par cluster séparé — c'est l'objet d'un autre article.
Voir aussi