runner forgejo pour l'instance EQS
  • Shell 76.9%
  • HTML 19.5%
  • JavaScript 2.9%
  • Dockerfile 0.7%
Find a file
Nicolas FRYDER 23d5d3b27b Jobs à 4 Go et 3 584 Mo de tas, dind à 5 Go
Le next build d'ATLAS culmine à 2,8 Go seul (mesuré aux limites du runner) ;
lancé depuis un test d'intégration, à côté de Vitest et d'autres fichiers, il
dépassait 3 Go et le noyau le tuait (« next build a échoué (code null) »,
EQS/ATLAS#8). Le jour prévu par le commentaire de config.yml : 4 Go et
3 584 Mo de tas, ensemble. dind à 5 Go pour un job, dockerd et le Postgres de
l'intégration. Un seul job à la fois ; l'hôte a 4 Go de swap depuis ce matin
pour protéger Forgejo.

Harnais e2e : 29/29, NODE_OPTIONS reçu, services plafonnés à 4 Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:29:27 +02:00
.githooks Une barrière de push, parce que la garantie reposait sur la discipline 2026-09-09 10:15:14 +02:00
dashboard Passe de sécurité : « privé » n'était pas la bonne frontière 2026-09-10 20:00:53 +02:00
scripts Jobs à 4 Go et 3 584 Mo de tas, dind à 5 Go 2026-10-15 16:29:27 +02:00
spinoff Le tableau de bord ne peut plus mentir, et Renovate est épinglé 2026-09-08 13:18:11 +02:00
.env.example Le runner s'enregistre au niveau de l'instance, et la doc dit comment avec Forgejo 15 2026-10-15 10:28:30 +02:00
.gitattributes Force les fins de ligne LF 2026-09-04 13:04:22 +02:00
.gitignore Durcit le runner et branche la supervision 2026-09-04 09:53:47 +02:00
config.yml Jobs à 4 Go et 3 584 Mo de tas, dind à 5 Go 2026-10-15 16:29:27 +02:00
docker-compose.yaml Jobs à 4 Go et 3 584 Mo de tas, dind à 5 Go 2026-10-15 16:29:27 +02:00
README.md Jobs à 4 Go et 3 584 Mo de tas, dind à 5 Go 2026-10-15 16:29:27 +02:00

forgejo-runner

Runner Forgejo Actions pour l'instance EQS, déployé comme ressource Docker Compose dans Coolify, sur le même serveur et dans le même environnement que Forgejo — mais en ressource séparée.

Ce dépôt ne contient que le runner. Les workflows qui l'utilisent (Renovate, scans de sécurité) vivent dans leurs propres dépôts : voir Découpage.

Fichiers

Fichier Rôle
docker-compose.yaml Runner + dind isolé + ménage disque, pour Coolify
config.yml Config du runner (label node-22, cache, capacité, limites de job)
.env.example Variables à renseigner dans l'UI Coolify
scripts/verify.sh Harnais : prouve que les protections échouent quand elles doivent
scripts/e2e/run.sh Test de bout en bout contre une instance Forgejo jetable
scripts/e2e/*-workflow.yml Les workflows hostiles que ce test exécute
scripts/e2e/lab.sh Banc d'essai manuel, pour investiguer (pas pour valider)
.githooks/pre-push Barrière de push : refuse d'envoyer ce qui ne passe pas les contrôles
scripts/e2e/endurance.sh Banc d'endurance : la vraie pile sous charge répétée, on regarde les pentes
scripts/page-hors-nominal.js Fait tourner le JS de la page sans navigateur, sur les relevés dégradés
scripts/e2e/coolify/ Recette : Coolify jetable qui déploie ce dépôt comme en production
.gitattributes Fins de ligne LF : un shebang CRLF fait sortir le conteneur en 127
dashboard/index.html Page de supervision (statique, sans dépendance)
dashboard/nginx.conf Serveur : authentification, en-têtes, page d'erreur
dashboard/40-auth.sh Rotation du mot de passe au démarrage
dashboard/401.html Page « accès restreint » (celle de nginx est illisible)
scripts/snapshot.sh Collecte de l'état, exécutée dans dind
scripts/runner-heartbeat.sh Battement de cœur du runner

Modèle de sécurité

Un runner exécute du code arbitraire : c'est sa raison d'être. Tout ici consiste à borner ce que ce code peut atteindre.

Le point à comprendre : les conteneurs de job tournent DANS dind. Ils sortent par l'interface réseau de dind et peuvent donc joindre son IP. Retirer l'accès Docker côté runner ne retire que la commodité, pas la capacité.

Les barrières, par ordre d'importance

  1. TLS mutuel sur le daemon dind (DOCKER_TLS_CERTDIR: /certs). Sans elle, le point d'entrée de l'image écoute en clair et sans authentification sur 2375, et n'importe quel job peut créer un conteneur privilégié montant / — root sur le VPS, donc Forgejo et les secrets de toutes les autres ressources Coolify. Ne jamais remettre DOCKER_TLS_CERTDIR: "".

  2. Pare-feu des conteneurs de job, posé dans dind. Un job sort par la passerelle de dind et atteignait donc l'hôte, donc tout port publié par les autres ressources Coolify — mesuré : HTTP 200 depuis un job vers une autre appli. Ce n'est pas de l'accès Docker mais du routage IP : ni le TLS ni docker_host n'y changent rien. Les règles refusent les plages privées, les métadonnées de l'hébergeur (169.254.0.0/16) et l'IPv6 en transit ; elles n'ouvrent que le proxy de cache (8089), pas le serveur derrière.

    Ce pare-feu a un point de rupture connu, et il a été mesuré. Les règles vivent dans DOCKER-USER, branchée sur la chaîne FORWARD : elle ne voit que le trafic qui transite. Un paquet émis depuis la pile réseau de dind elle-même passe par OUTPUT et n'est jamais examiné. Or la documentation de sécurité de Forgejo indique que container.network de la configuration ne s'applique pas aux conteneurs créés par les workflows, et que ceux-ci « peuvent définir --network=host ». Un job qui y parviendrait atterrirait dans la pile de dind et contournerait donc l'intégralité du filtrage.

    Mesuré sur le runner v13.1.0 : l'option est ignorée, le conteneur reste sur son réseau par workflow (WORKFLOW-<hash>). La barrière tient. Mais c'est un comportement de version, contraire à ce que la documentation laisse attendre : le harnais le rejoue à chaque exécution, et une mise à jour du runner qui le changerait ferait rougir le test au lieu d'ouvrir une brèche en silence.

    Elles vivent dans dind et non dans un conteneur annexe : un sidecar en network_mode: service:dind a été essayé puis rejeté. Au redémarrage de dind la pile réseau est recréée, le sidecar continue d'écrire dans l'ancienne, affiche des règles parfaites — et les jobs atteignent l'hôte. Un pare-feu qui ment.

  3. Plafonds CPU, mémoire et PID. Une limite mémoire seule n'arrête pas une fork bomb : les PID sont une ressource de l'hôte, et Docker n'en plafonne aucune par défaut.

  4. Aucun accès Docker monté dans les jobs (container.docker_host: "-"). Défense en profondeur. Conséquence assumée : pas de docker build en CI.

  5. Portée instance, pour le jeton d'enregistrement (2026-10-15) : une file pour toute la forge. La porte d'entrée se tient côté forge — inscription fermée, approbation des premiers contributeurs extérieurs — parce qu'un runner d'instance exécute les workflows de n'importe quel dépôt.

  6. Capabilities retirées partout où elles ne servent pas — runner, prune et dashboard tournent avec cap_drop: ALL et no-new-privileges.

  7. Aucun socket Docker de l'hôte n'est monté nulle part.

Ce qu'un workflow hostile ne peut pas faire

Rien de cette liste n'est déduit de la documentation : un workflow hostile le tente réellement à chaque sh scripts/e2e/run.sh, contre une instance Forgejo jetable.

Tentative Résultat mesuré
Joindre le daemon Docker 2375 inexistant, 2376 rejeté faute de certificat client
Atteindre l'hôte ou une autre ressource Coolify refusé par le pare-feu
options: "--privileged" ignoré — le job constate qu'il n'a aucune capability de plus
--volume /:/hostfs, socket Docker ignorés — aucun montage
options: "--network=host" ignoré — le job reste sur son réseau par workflow
--memory=8g refusé par le runner, qui abandonne le job
Fork bomb contenue par le cgroup (511 processus sur 512)
Lire le job voisin (fichiers, réseau) rien d'accessible
Empoisonner le cache d'un autre dépôt même clé, aucun contenu restitué
Lire l'environnement du runner, ses certificats ou son fichier .runner rien d'atteignable
Se servir du jeton du job au-delà de son dépôt autre dépôt privé 404, API admin 403, aucune énumération
Atteindre le serveur de cache (8088) expire ; seul le proxy est joignable
Se donner un conteneur sans plafond via services: il hérite des mêmes plafonds que le job (4 Go sans swap, 512 PID, non privilégié)

Le test du cache s'accompagne d'un témoin positif : le dépôt qui a écrit relit bien sa propre entrée. Sans lui, un cache simplement cassé se lirait comme un cloisonnement réussi.

Ce qu'un job reçoit, et rien de plus

Vérifié depuis l'intérieur d'un vrai job, à chaque exécution du harnais :

  • Aucune variable d'environnement du runner ne descend dans le job — ni DOCKER_HOST, ni DOCKER_CERT_PATH, ni le jeton d'enregistrement.
  • Aucun certificat, relevé de supervision ni fichier .runner n'est atteignable depuis un job.
  • /run/act — le répertoire où vivent les scripts d'étape, event.json et envs.txt — est propre à chaque job, bien qu'il soit inscriptible. Deux jobs simultanés n'y voient jamais les fichiers l'un de l'autre. S'il était partagé, un dépôt pourrait réécrire le script d'un autre : exécution de code croisée.
  • L'espace de travail est un volume par job, supprimé en fin de job : ni partage, ni accumulation sur le disque.
  • Le jeton remis au job ne porte que sur son propre dépôt : un autre dépôt privé répond 404 en lecture comme en écriture, l'API d'administration 403, et /user/repos renvoie une liste vide — aucune énumération des dépôts de l'instance n'est possible.

Ce qui reste exposé, sciemment

  • Internet sortant complet depuis un job — il en a besoin (npm ci, apt, téléchargement des outils). Un workflow hostile peut donc exfiltrer ce qu'il a en main : le code du dépôt et les secrets que TU passes à ce job. Ne mets aucun secret d'organisation dans Forgejo si un seul dépôt est moins fiable.

  • Nombre de conteneurs de service non borné par le runner — leur taille, elle, l'est : mesuré, chacun hérite exactement des plafonds du job (4 Go sans swap, 512 PID, non privilégié), même quand le workflow ne demande rien. Le garde-fou sur le total reste donc celui de dind : 5 Go de mémoire, sans swap non plus, et 4096 PID, qui plafonnent l'ensemble quoi qu'on déclare.

  • Le jeton d'enregistrement reste valide après usage : le révoquer une fois le runner Idle. Il est aussi visible dans docker inspect et dans Coolify.

  • DASHBOARD_PASSWORD est une variable d'environnement, donc lisible par docker inspect et dans l'interface Coolify, comme tout secret passé de cette façon. Qui a cet accès a déjà le socket Docker de l'hôte, c'est-à-dire root : ce n'est pas une aggravation, mais c'est à savoir avant de réutiliser ce mot de passe ailleurs. Ne pas le réutiliser.

  • Le volume runner-data contient le jeton permanent du runner : qui le lit peut se faire passer pour lui et recevoir de vrais jobs, avec leurs secrets.

  • Le volume docker-certs contient la clé cliente du daemon privilégié : qui la lit obtient root sur le VPS. Montée en lecture seule, et jamais dans le serveur web.

  • Rien ne borne le disque qu'un job consomme. C'est le principal risque de disponibilité restant. Mesuré : un job voit tout le système de fichiers de dind (744 Go libres sur le VPS de test) et y écrit à 1,9 Go/s. Un seul job suffirait donc à remplir le disque de l'hôte, ce qui emporte Forgejo avant la CI.

    Le quota par conteneur (--storage-opt size=) n'est pas utilisable : il exige overlay sur XFS monté en pquota, et dind tourne sur extfs. Ni le plafond mémoire, ni --pids-limit ne couvrent le disque.

    Ce qui existe à la place, faute de prévention : le ménage quotidien de prune (rétention 72 h), l'alarme à 80 % dans les journaux de dind, et la jauge du tableau de bord qui passe à l'ambre à 80 % et au rouge à 90 %. C'est de la détection, pas une barrière — à surveiller si des jobs manipulent de gros artefacts.

  • L'accès aux artefacts d'un autre dépôt n'est pas testé. La sonde écrite pour cela a été retirée : tous les chemins de l'API d'artefacts répondent 404 depuis un job, et rien ne permet de distinguer « accès refusé » de « mauvais chemin ». Un test incapable de faire cette différence vaut moins que pas de test — il rassurerait à tort. Le mécanisme est le même que celui du jeton de dépôt, dont le cloisonnement est prouvé, mais ce n'est pas une preuve.

  • Le tableau de bord est joignable depuis Internet. C'est un choix, pas un oubli : sans accès SSH au serveur, il n'existe aucune autre façon de lire l'état de la pile. Ce qui le rend acceptable — TLS terminé par Traefik, mot de passe d'au moins 12 caractères imposé par le conteneur et renouvelé à chaque démarrage tant qu'il n'est pas fixé, 60 requêtes par minute, et une page qui ne détient ni jeton, ni certificat, ni accès au daemon. Ce qu'il révèle malgré tout à qui franchit l'authentification : la topologie interne, les règles de pare-feu et les noms des jobs en cours.

  • Les images de job sont des tags flottants. Épingler par empreinte n'apporterait guère : un workflow peut de toute façon désigner l'image de son choix, et le runner n'offre pas de liste blanche de registres.

Le fond du sujet

Quiconque peut pousser sur un dépôt servi par ce runner peut y exécuter ce qu'il veut. Le modèle tient parce que les dépôts EQS sont internes ; il ne tiendrait pas tel quel pour des dépôts publics acceptant des contributions externes.

Ne pas gêner Forgejo

Forgejo partage le VPS. Trois ressources peuvent l'emporter, et chacune a son garde-fou — tous vérifiés réellement appliqués, pas seulement déclarés :

Ressource Garde-fou Pourquoi ça compte
CPU / RAM dind plafonné à 2,5 CPU / 5 Go, et chaque job à 2,5 / 4 Go — swap compris, un seul job à la fois (capacity: 1), avec un tas V8 de 3 584 Mo (NODE_OPTIONS) Relevé le 2026-10-14 : à 1 Go, pnpm install d'un monorepo Next mourait en 137 ; à 2 Go, next build une fois sur deux. Le 2026-10-15 : à 3 Go, le lint typé d'ATLAS mourait en SIGABRT (tas V8 plafonné à la moitié du conteneur) et son next build, qui culmine à 2,8 Go seul, était tué quand un test d'intégration le lançait. Un seul job à la fois laisse la marge à Forgejo sur un VPS de 8 Go. Les jobs tournent dans dind, pas dans le service runner : c'est là que la limite doit être. « Swap compris » n'est pas décoratif : par défaut le noyau accorde autant de swap que de mémoire, et ces plafonds valaient donc le double (voir plus bas).
PID pids: 4096 sur dind, --pids-limit=512 par job Une limite mémoire n'arrête pas une fork bomb. Les PID sont une ressource d'hôte, et Docker n'en plafonne aucune par défaut.
Disque prune quotidien (rétention 72 h) + alarme à 80 % Détection seulement. Aucun quota par job n'est possible ici — voir les risques résiduels. Le volume dind vit sur le disque de l'hôte : s'il le remplit, ce n'est pas la CI qui tombe, c'est Forgejo.

Le nombre de conteneurs de service qu'un job démarre n'est pas borné par le runner (la doc Forgejo l'admet) : c'est le plafond de dind qui fait rempart.

Le réseau est le quatrième garde-fou, et le plus facile à croire acquis. Le pare-feu des jobs ferme tout le registre IANA des adresses à usage spécial — pas seulement les plages « privées » — et, si VPS_PUBLIC_IP est renseignée, toute l'adresse publique du serveur hors 80 et 443. dind lui-même n'accepte plus rien de ses propres ponts. Sans cette variable, un workflow atteint sshd et tous les ports publiés par les autres applications de ce VPS : c'est le premier réglage à faire, et dind le rappelle dans ses journaux tant qu'il manque.

Installation

  1. Coolify : + New → Docker Compose → dépôt Infrastructure/forgejo-runner, fichier docker-compose.yaml.

    Ne pas activer « Connect to Predefined Network » : cette option placerait dind et le runner sur le réseau partagé de Coolify, aux côtés de toutes les autres ressources du serveur. Voir « Tableau de bord » pour ce que cela coûte exactement.

  2. Variables (Coolify → Environment Variables) :

    Variable Valeur
    FORGEJO_INSTANCE_URL https://git.equilibre-structures.fr — en https://, le démarrage refuse une URL en clair
    RUNNER_REGISTRATION_TOKEN GET /api/v1/admin/runners/registration-token avec un jeton d'accès à la portée admin — voir ci-dessous
    DASHBOARD_PASSWORD un vrai mot de passe, long. Sans lui, il est tiré au hasard à chaque démarrage et il faut le relire dans les journaux après chaque redéploiement
    RUNNER_CACHE_MAX_MB facultatif, 4096 par défaut. Plafond du cache des actions, purgé au démarrage du conteneur

    Le jeton est celui de l'instance. Forgejo 15 ne l'affiche plus : son bouton « Create new runner » donne un UUID et un token, faits pour server.connections de config.yml, que forgejo-runner register refuse (« runner registration token not found », mesuré le 2026-10-14). Le demander à l'API, sans le faire transiter par un tiers :

    read -rsp "Jeton d'accès (portée admin) : " PAT && echo && curl -s -H "Authorization: token $PAT" https://git.equilibre-structures.fr/api/v1/admin/runners/registration-token; echo; unset PAT
    

    Pour réenregistrer un runner déjà enregistré : rm /data/.runner dans le conteneur runner, nouveau jeton dans Coolify, Redeploy — puis supprimer l'ancien runner, hors ligne, dans Forgejo.

  3. Activer « Preserve Repository During Deployment ». C'est le réglage sans lequel ce dépôt ne peut pas se déployer, et il est désactivé par défaut.

    Vérifié en montant un Coolify jetable et en y déployant cette pile : sans ce réglage, Coolify ne dépose sur le serveur que le fichier compose et un .env — aucun fichier du dépôt. Docker, à qui l'on demande alors de monter ./dashboard/nginx.conf, ne trouve rien à ce chemin et crée un répertoire. Mesuré sur le serveur cible : config.yml, dashboard/nginx.conf, dashboard/index.html, dashboard/401.html et dashboard/40-auth.sh étaient tous des répertoires. Le déploiement échoue alors sur :

    mount src=.../dashboard/nginx.conf ... not a directory:
    Are you trying to mount a directory onto a file (or vice-versa)?
    

    L'échec est bruyant, et c'est une chance : il vient du tableau de bord. Si celui-ci n'était pas dans la pile, le runner démarrerait avec un config.yml qui est un répertoire, donc sur ses valeurs par défaut — sans plafond mémoire, sans plafond de PID, et surtout sans docker_host neutralisé, ce qui monte un accès Docker dans chaque job. Le tableau de bord le signalerait (config.yml n'est pas un fichier), mais après coup.

    Avec le réglage actif, les fichiers arrivent réellement — et 40-auth.sh avec son bit exécutable, sans lequel nginx l'ignorerait.

  4. Attribuer un domaine au service dashboard. Le service écoute sur le port 80 : le domaine seul suffit, Coolify pose la route Traefik et termine le TLS. C'est le seul accès à la supervision quand on n'a pas de shell.

  5. Deploy.

  6. Ouvrir le tableau de bord — c'est le test de recette. Le mot de passe est dans les journaux Coolify du service dashboard si tu ne l'as pas fixé.

    La bannière doit afficher « Tout est en ordre ». Si elle affiche des anomalies, elle nomme chacune et le geste correspondant. Les trois qui comptent au premier démarrage :

    • « config.yml n'est pas un fichier / est vide » → Coolify a transformé le bind-mount de fichier en entrée « Persistent Storage » vide. C'est le piège le plus courant de ce déploiement, et le seul dont l'échec serait autrement silencieux : le runner démarre sur ses valeurs par défaut, donc sans plafond mémoire ni plafond de PID sur les jobs.
    • « le daemon isolé tourne SANS plafond » → la version de Docker Compose de l'hôte a ignoré deploy.resources.limits. Rien ne l'aurait signalé.
    • « le pare-feu est incomplet » → les règles manquantes sont nommées ; redémarrer le service dind.

    Les autres montages échouent d'eux-mêmes et se voient sans la page : sans nginx.conf rien n'écoute, sans 40-auth.sh le conteneur dashboard est déclaré en échec par Coolify (mesuré : nginx démarre mais refuse tout le monde par un 403, y compris avec le bon mot de passe — d'où le contrôle du fichier d'authentification dans la sonde).

  7. Vérifier côté Forgejo que le runner apparaît Idle avec les labels node-22 et alpine.

  8. Révoquer le jeton d'enregistrement. Il reste valide après usage et permettrait d'enregistrer des runners pirates, qui recevraient de vrais jobs avec leurs secrets.

  9. Lancer un premier vrai workflow, puis regarder la colonne « plafonds » du tableau des jobs : elle doit afficher 4 Go · 512 proc.. Cette valeur est lue sur le conteneur, pas recopiée de la configuration — c'est la preuve que les limites sont réellement appliquées, pas seulement déclarées.

Vérifier

Deux harnais, complémentaires. Le premier teste la pile isolément :

sh scripts/verify.sh           # tout, y compris de vraies attaques
sh scripts/verify.sh --quick   # contrôles statiques seulement

Le second monte une instance Forgejo jetable et lui fait distribuer de vrais jobs, dont plusieurs workflows hostiles :

sh scripts/e2e/run.sh

Le banc d'endurance

Les trois harnais ci-dessus durent quelques minutes. Ils prouvent qu'une chose marche ; ils ne peuvent rien dire de ce qui dérive. Or cette pile est faite pour tourner des mois sans shell : les pannes qui la guettent réellement ne sont pas des refus nets, ce sont des pentes — de la mémoire qui monte, des volumes orphelins qui s'accumulent, des descripteurs qui fuient, un relevé qui met de plus en plus de temps à s'écrire.

sh scripts/e2e/endurance.sh 35      # minutes, défaut 30

Il monte la vraie pile contre une Forgejo jetable et lui envoie des vagues de trois profils mélangés — un job léger, un job qui écrit et relit le cache, un job avec conteneur de service — puis prélève une ligne de mesures toutes les 30 secondes. À la fin il compare le premier tiers au dernier tiers : pas le premier point au dernier, qui seraient l'un artificiellement bas et l'autre pris au hasard d'un pic.

Premier passage réel, 35 minutes, 39 runs, 20/20 : dockerd +4 %, runner 0 %, descripteurs −1 %, volumes et réseaux plats, mémoire du cgroup de dind −11 %. Aucune dérive. Une seule grandeur monte, et c'est celle qui doit monter : le cache des actions, 1,48 Mo/min sous cette charge — le plafond de 4 Go serait atteint après 45 h de charge continue. C'est ce chiffre-là qui permet de dimensionner RUNNER_CACHE_MAX_MB au lieu de le deviner.

Le ménage quotidien y tourne enfin sous observation. Son sleep 86400 était écrit en dur : c'était la seule partie de la pile dont personne n'avait jamais vu le code s'exécuter, parce que personne ne fait tourner un banc vingt-quatre heures. RUNNER_PRUNE_INTERVAL est devenue une variable — défaut inchangé, le banc la descend à 120 s — et le harnais exige d'avoir vu au moins deux cycles réels avec leur compte rendu de récupération. Une valeur en dur qui rend une fonction inobservable est un défaut à part entière.

Et la mesure qui compte le plus est l'âge des relevés sous charge. La page considère un relevé périmé au-delà de 45 s. Si la charge fait dépasser ce seuil, l'exploitant lit « le collecteur ne tourne plus » pendant que tout va bien — une porte qui ment, déclenchée par le succès. Pire cas mesuré sur 35 minutes : 11 s et 15 s.

Le banc a d'abord menti, et c'est instructif

Premier passage : 19 assertions vertes, dont quatre sur les fuites. Toutes fausses. Je lisais /proc/1 — or dans dind le PID 1 est docker-init (576 ko, immuable) et dans le runner c'est dumb-init, dont le VmRSS vaut littéralement 0. Le banc rapportait « mémoire stable : 576 → 576 » et « 0 → 0 » : des assertions qui ne pouvaient pas échouer, sur exactement les grandeurs qu'il existe pour surveiller. Un test comme celui-là rassure pendant des mois. Les sondes cherchent maintenant le processus par son nom, sans jamais supposer de PID.

Un garde-fou en est sorti : une colonne dont la valeur est identique sur tous les points est presque toujours une sonde cassée, pas une grandeur stable. Le banc refuse de conclure « stable » dessus et le dit. Ce garde-fou a lui-même eu un défaut — print "non"; exit au milieu d'une règle awk, alors que exit exécute quand même le bloc END, qui imprimait le verdict contraire derrière. Le contrôle écrit contre les fausses mesures en produisait une.

La barrière de push

Ce dépôt part en production directement depuis sa branche : rien ne sépare push de déployé. Les harnais ci-dessus ne valaient donc que par la discipline de les lancer — exactement l'écart que ce dépôt poursuit partout ailleurs. Un crochet le ferme :

git config core.hooksPath .githooks

À chaque push, verify.sh --quick s'exécute : 112 assertions en une minute, sans monter la pile. Il attrape ce qui casse un déploiement — CRLF sur un shebang, config.yml amputé, labels qui divergent, plafonds perdus, branches du tableau de bord. Le harnais complet reste accessible d'un mot :

GATE=complet git push     # verify.sh entier + e2e, une trentaine de minutes
git push --no-verify      # passer outre, en connaissance de cause

Le contournement est nommé dans le message de refus, délibérément : une barrière qu'on ne sait pas ouvrir se fait désinstaller au premier cas urgent, et on perd tout. Un push qui ne fait que supprimer une branche distante ne publie rien et n'est pas retenu ; Docker à l'arrêt est signalé comme tel et non comme un échec de test, sans quoi on irait chercher une panne inexistante dans le dépôt.

Deux assertions gardent la barrière elle-même. Le mode du crochet est lu dans l'index git : un crochet sans bit exécutable est ignoré en silence — mot pour mot la panne déjà vécue ici avec 40-auth.sh. Et core.hooksPath est vérifié : une barrière versionnée que personne n'a installée rassure sans protéger, ce qui est pire que pas de barrière du tout.

C'est ce second harnais qui a révélé que le runner crée un réseau par workflow : un pare-feu cadré sur docker0 affichait des règles impeccables, des compteurs à zéro, et laissait passer 100 % du trafic réel. Aucun test hors-ligne ne pouvait le montrer.

Les tests de comportement démarrent la vraie pile compose, pas une réplique : une réplique dérive, et on finirait par tester autre chose que ce qu'on déploie. Ils utilisent le même subnet que la production et ne peuvent donc pas tourner pendant que le runner sert — --quick est fait pour ça.

scripts/verify.sh Ce qu'il prouve
T1 Le compose et la config sont valides
T2 Les labels ne dérivent pas entre config.yml et le register
T3 Le plan d'adressage est cohérent et libre
T4 config.yml se charge, et ses clés critiques portent
T5 Le pare-feu des jobs est déclaré au bon endroit et bien cadré
T6 Le tableau de bord est inerte, et joignable
T7 Les relevés ne peuvent pas être invalidés par une valeur hostile
T8 Les workflows hostiles se déclenchent bien
T9 La CI partagée n'installe rien d'inépinglé
T10 Un conteneur de job ne peut pas atteindre le daemon
T11 Le chemin légitime fonctionne encore, en uid 1000
T12 Un job est confiné : ni hôte, ni cache interne, mais internet OK
T13 Le pare-feu survit à un redémarrage de dind
T14 Les garde-fous de ressources sont réellement posés
T15 Un redéploiement de dind ne coupe pas le runner
T16 Le tableau de bord ne peut rien joindre, et ne rapporte que du mesuré
scripts/e2e/run.sh Ce qu'il prouve
1 Instance Forgejo jetable
2 La vraie pile du dépôt, branchée dessus
3 Un job ordinaire doit s'exécuter
4 Un workflow HOSTILE ne doit rien obtenir
5 Un workflow ne peut pas s'octroyer de privilèges
6 Deux jobs simultanés restent cloisonnés
7 Rien n'est tombé sous l'attaque
8 Le tableau de bord dit la vérité
9 Le cache est cloisonné entre dépôts
10 Un job ne reçoit que ce qui le concerne
11 Les conteneurs de service sont bornés comme les jobs

Les deux tableaux sont tirés des scripts eux-mêmes ; s'ils divergent, c'est que cette page est périmée.

Le harnais a attrapé plusieurs régressions introduites pendant sa propre écriture — un pare-feu qui filtrait les paquets de retour, un dockerd démarré sans TLS, un CA qui tournait à chaque redéploiement. Cent mutants lui ont été soumis, tous détectés.

Recette de déploiement dans un vrai Coolify

Les deux harnais précédents lancent la pile avec docker compose en direct. En production c'est Coolify qui déploie, et il ne se contente pas d'appliquer le fichier : il le transforme. Un troisième banc a donc été monté — Coolify jetable, serveur cible jetable, forge jetable — pour éprouver ce chemin-là.

sh scripts/e2e/coolify/run.sh up     # amorce la recette
sh scripts/e2e/coolify/run.sh down   # démonte tout, données comprises

Le script fait tout : Coolify isolé, serveur cible, forge, dépôt poussé, application, déploiement, exécution d'un vrai workflow, et vérification du tableau de bord sur la pile déployée. 32 assertions, comptez une vingtaine de minutes au premier passage — les images sont volumineuses.

Isolement. Une installation Coolify occupe des noms globaux : conteneurs coolify*, volumes coolify-db et coolify-redis, réseau coolify, et le répertoire /data/coolify qui porte l'APP_KEY chiffrant tous ses secrets. Tout est préfixé recette-, les données vont dans un répertoire jetable, et Coolify déploie sur un serveur qui est lui-même un conteneur (Docker-in-Docker + sshd) : rien ne touche au daemon de la machine. Le script refuse de démarrer si on pointe son répertoire de données vers /data/coolify.

Ce que la recette a établi

Question Réponse mesurée
Coolify préserve-t-il deploy.resources ? Oui, cpus, memory et pids — relu dans le cgroup : pids.max = 4096
Que fait-il des réseaux ? Il ajoute le sien à tous les services, en plus de ceux déclarés ; le réseau runner et son subnet sont conservés
Des montages de fichiers du dépôt ? Réécrits en chemins absolus sous /data/coolify/applications/<uuid>/
Les volumes nommés ? Préfixés par l'uuid de la ressource
Les noms de conteneurs ? Remplacés (dind-<uuid>-…), mais les alias de service subsistent : tcp://dind:2376 résout toujours
Un job reçoit-il ses plafonds ? Oui : memory.max = 1073741824, pids.max = 512, mesurés dans le job
Le pare-feu tient-il ? Oui : « aucun accès au daemon », « hôte injoignable », « cache interne hors de portée », et le compteur DROP 10.0.0.0/8 monte réellement
Le tableau de bord ? 401 sans identifiants, 200 avec, config: ok, manquantes: ""
Un redéploiement crée-t-il un runner fantôme ? Non : toujours un seul runner, même UUID — le volume sur .runner fait son office, et Coolify le préserve
Le cache survit-il à un redéploiement ? Oui, et la purge au démarrage se journalise : [cache] 1 Mo (plafond 4096 Mo)
Attribuer un domaine au tableau de bord suffit-il ? Oui : Coolify génère les labels Traefik, détecte le port 80, et le routage répond 401/200 à travers le proxy
Où se retrouve le proxy de Coolify ? Sur tous les réseaux de la ressource, runner compris — le TLS mutuel refuse quand même 2376 sans certificat client

Les pièges de Coolify, tous silencieux

Chacun a coûté un passage complet, et chacun est désormais commenté à l'endroit du script qui le contourne :

Symptôme Cause
couldn't find env file Compose résout env_file côté client ; sous Docker Desktop, client et daemon ne partagent pas le système de fichiers
getaddrinfo for coolify-redis failed Coolify code en dur les noms d'hôtes de ses dépendances ; renommer les conteneurs les casse
utilisateur root jamais créé le seeder valide les enregistrements MX de l'adresse, et impose majuscule + minuscule + chiffre au mot de passe
API is disabled l'API est désactivée par défaut
jeton refusé à la création les jetons exigent un team_id, que createToken() ne pose pas
serveur « injoignable » sans motif le multiplexage SSH a besoin d'un vrai système de fichiers Linux pour ses sockets, et de la clé lisible par l'uid 9999
sh: bash: not found Coolify lance toutes ses commandes distantes via bash
Failed to read Git source git doit être présent sur le serveur lui-même, pas seulement dans le conteneur de build
dumb http transport does not support shallow capabilities Coolify clone en --depth=1 : il faut un vrai serveur git
The git repository cannot use IP addresses l'URL du dépôt doit être un nom, résolvable côté serveur et côté conteneur de build
runner sans URL ni jeton loadComposeFile efface les variables d'environnement posées avant lui : les poser après

La trouvaille qui bloque un déploiement

Sans « Preserve Repository During Deployment », cette pile ne démarre pas. Le réglage est désactivé par défaut. Sans lui, Coolify ne dépose sur le serveur que le compose et un .env : aucun fichier du dépôt. Docker, à qui l'on demande de monter ./dashboard/nginx.conf, ne trouve rien et crée un répertoire. Relevé sur le serveur cible — config.yml, nginx.conf, index.html, 401.html, 40-auth.sh : tous des répertoires. Le déploiement échoue alors sur Are you trying to mount a directory onto a file?.

L'échec est bruyant, et c'est un coup de chance : il vient du tableau de bord. Sans lui dans la pile, le runner démarrerait avec un config.yml qui est un répertoire — donc sur ses valeurs par défaut, sans plafond mémoire, sans plafond de PID, et sans docker_host neutralisé, ce qui monte un accès Docker dans chaque job.

Réglage activé, tout arrive correctement — y compris 40-auth.sh avec son bit exécutable, sans lequel nginx l'ignore et refuse tout le monde par 403.

memswap_limit survit-il à Coolify ?

Question posée par la correction des plafonds mémoire, et que seule cette recette peut trancher. memswap_limit n'a aucun équivalent sous deploy:, et Coolify réécrit le compose avant de l'appliquer : une clé qu'il ne connaîtrait pas disparaîtrait en silence, et le plafond redeviendrait le double de l'annoncé sans que rien ne le dise.

Mesuré sur le déploiement réel : dind sort avec Memory=3221225472 et MemorySwap=3221225472, et le cgroup du conteneur porte memory.swap.max=0. Le config.yml arrivé jusqu'au runner garde bien --memory-swap=1g, et un conteneur lancé avec ces options-là obtient memory.swap.max=0. Coolify conserve la clé ; la correction atteint la production. Deux assertions le vérifient désormais à chaque passage, en lisant les options dans le conteneur déployé et non dans le dépôt.

Ce que cette recette ne prouve pas

Elle tourne sur un noyau et une version de Docker Compose qui ne sont pas forcément les tiens. Que deploy.resources.limits.pids soit appliqué ici ne garantit pas qu'il le soit là-bas : c'est précisément pourquoi le tableau de bord lit ces valeurs dans le cgroup et lève une anomalie quand elles manquent. La recette valide les transformations de Coolify, qui sont les mêmes partout ; elle ne valide pas l'hôte.

Comment ces tests ont été écrits

Neuf assertions de ce dépôt ont d'abord échoué pour une raison étrangère à ce qu'elles prétendaient mesurer. Elles sont notées ici parce que la même erreur se reproduira sinon :

  • une recherche de fuite trouvait son propre motif, écrit en clair dans le script du chercheur ;
  • un exit 0 dans une règle awk était écrasé par le exit 1 du bloc END : le test ne rougissait jamais ;
  • un test de fins de ligne CRLF portait lui-même un retour chariot littéral ;
  • une assertion lisait les journaux de Forgejo en croyant qu'ils ne sont conservés que pour les jobs en échec. C'est faux — un job réussi peut avoir le sien, un job en échec peut ne pas l'avoir — et le test échouait par intermittence ;
  • un grep cherchait --pids-limit dans tout config.yml et trouvait le commentaire qui explique l'option, validant une configuration qui ne l'avait plus ;
  • un test de limitation de débit lançait 200 curl successifs. Chacun rouvrant une connexion, la rafale s'étalait au-delà d'une minute et restait sous le seuil de 60/min : le test échouait alors que la protection fonctionnait. Il mesurait sa propre lenteur. Une seule invocation de curl avec 200 URL, sur une connexion réutilisée, rend la rafale franche — 170 refus sur 200 ;
  • un test réussissait pour n'importe quelle configuration. Il concluait « toutes les clés acceptées » sur la présence du message 0 server connections configured dans la sortie du binaire. Or ce message apparaît pour toute config qui se charge : le témoin sain le produit aussi. Mesuré : une config.yml dont la section container était mal orthographiée — donc des jobs sans aucun plafond — passait le test. C'est la découverte du témoin qui l'a révélé, pas la relecture ;
  • le contrôle d'intégrité de config.yml cherchait --pids-limit dans tout le fichier, et le trouvait dans le commentaire qui explique l'option. Un options: amputé était donc rapporté « ok » par le tableau de bord. C'est la répétition exacte d'une erreur déjà listée ci-dessus, dans un autre fichier : la leçon n'avait été appliquée qu'à l'endroit où elle avait été apprise ;
  • un test observait le conteneur d'un job pendant une fenêtre fixe de 90 s. Les jobs d'un même workflow passant à capacity: 2, celui qu'on observait attendait derrière les autres : le test concluait « aucun conteneur créé » alors qu'il mesurait la longueur de la file. Il surveille désormais l'état du run plutôt que l'horloge, et relit une fois après l'état terminal pour fermer la course entre les deux lectures.

Deux règles en découlent, appliquées partout :

  1. Observer l'objet, pas un artefact intermédiaire. Le plafond mémoire d'un job se lit dans dind sur le conteneur réel, pas dans un journal.
  2. Un constat négatif exige un témoin positif. « La victime n'obtient rien » ne prouve le cloisonnement que si le dépôt d'origine, lui, retrouve bien son entrée. Sinon on mesure une panne et on la lit comme une protection.
  3. Lire la valeur d'une clé, jamais le fichier entier. Un commentaire qui documente une option contient le texte de cette option ; un grep global le trouve et valide une configuration qui ne l'a plus.
  4. Ce qui est permissif sur le poste ne l'est pas sur le serveur. Les permissions de fichier, les fins de ligne et la casse des chemins se comportent différemment sous Windows : ce qui doit voyager se vérifie dans l'index git, pas dans le répertoire de travail.
  5. Un test doit pouvoir dire « je n'ai rien mesuré ». Le test de force brute compte d'abord combien de réponses lui sont revenues, et refuse de conclure en dessous de la moitié. Sans ce garde-fou, une pile injoignable produirait zéro refus et se lirait comme une absence de protection — ou l'inverse, selon le sens de l'assertion.

Vérifier que la porte ne ment pas

Un runner absent laisse les workflows en attente pour toujours pendant que les dépôts affichent une CI « active ». Après installation, casser volontairement une commande dans un workflow de test : si le run reste vert, aucun runner ne l'a pris.

Le même souci vaut pour le tableau de bord : sans accès au serveur, il est le seul témoin, et un témoin qui se trompe coûte plus cher que pas de témoin. Cinq façons dont il aurait menti ont été trouvées en l'attaquant, toutes corrigées et toutes verrouillées par un test :

Ce qui se passait Ce que la page affichait Ce qu'elle affiche maintenant
Le pare-feu dépasse 100 000 paquets bloqués : iptables abrège le compteur en « 12M », qui partait comme nombre JSON « Le collecteur ne tourne pas » — la page s'éteignait toute seule, le jour où le pare-feu avait le plus servi compteurs exacts, relevé valide
L'horloge du serveur est en avance sur celle du navigateur « Relevé il y a 0 s », éternellement frais : un collecteur mort restait au vert « Horloges désaccordées », écart chiffré, fraîcheur déclarée invérifiable
Relevé valide mais amputé d'une section bannière verte figée sur du vide, en permanence « Relevé illisible », avec le service à inspecter
Relevé présent mais illisible « Le fichier d'état est introuvable » — envoyait chercher une panne inexistante la cause exacte : le contenu, pas la présence
Le collecteur ne sait pas lire le certificat du daemon date vide et « en service » : rassurait sur un contrôle non fait anomalie explicite

Combinaisons de pannes, et deux fenêtres refermées

Casser un composant à la fois finit par rendre. La tournée suivante a donc provoqué des combinaisons — et une mise à jour de Docker Desktop en cours de route a offert un cas qu'on ne peut pas fabriquer : toute la pile sur une version majeure différente.

Éprouvé Résultat
Docker 29.1.3 au lieu de 28.5.2 deploy.resources toujours appliqué, pare-feu complet, compteurs numériques — la pile passe d'une version majeure à l'autre
dind redémarre pendant un job le job meurt avec lui, aucun conteneur ne tourne dans l'intervalle, les règles reviennent en ~6 s
Saturation : 4 jobs pour capacity: 2 jamais plus de 2 conteneurs, la page reflète exactement ce qui tourne, la file se vide
La forge tombe puis revient le runner reprend les jobs sans redémarrage
Un job qui alloue sans fin mort lisible (Killed, exitcode 137) — mais à 2 048 Mo sous un plafond de 1 Go : voir plus bas, tous les plafonds mémoire valaient le double
La page sans relevé, dans un navigateur elle accusait le collecteur d'écrire un relevé illisible alors qu'il n'en écrivait aucun — voir plus bas
Un plafond de cache mal saisi (4G, abc) la purge ne se déclenchait plus jamais pendant que la page annonçait « 4096 Mo » — voir plus bas
Chronométrage des collecteurs docker system df appelé deux fois par tour, 6–7 s l'appel, sans borne — voir plus bas

Première fenêtre refermée — le pare-feu au redémarrage. Mesuré : après un redémarrage de dind, dockerd acceptait des connexions avant que la boucle n'ait reposé les règles. Le runner interroge Forgejo toutes les deux secondes : un job créé dans cet intervalle aurait tourné sans filtrage. Les règles sont désormais posées avant de lancer le daemon — vérifié que dockerd préserve une chaîne DOCKER-USER existante et se contente d'y ajouter le saut depuis FORWARD. La garantie est structurelle, plus une question de cadence, et le journal de dind s'ouvre sur [pare-feu] regles posees avant le demarrage du daemon.

Seconde fenêtre — dans la sonde que je venais d'ajouter. La sonde de la forge partageait le compteur qui espace la mesure du cache : elle ne tournait donc qu'une fois toutes les cinq minutes. Mesuré : forge arrêtée 80 secondes, la page a affiché « ok » du début à la fin. Un angle mort de cinq minutes sur le signal le plus important du relevé, créé par un couplage sans raison. Une requête HTTP toutes les quinze secondes ne coûte rien ; du sur plusieurs gigaoctets, si. Après correction : ok → injoignable en 45 s, et retour à ok au rétablissement.

Cinq pannes de plus, provoquées et observées

Deuxième tournée de pannes volontaires. Quatre comportements se sont révélés corrects — c'est un résultat aussi, et il vaut mieux l'avoir mesuré :

Panne provoquée Ce qui se passe réellement
Certificat client malformé le runner refuse de démarrer, healthcheck unhealthy
Certificat client valide mais d'un autre CA tls: certificate required, refus au démarrage, unhealthy — le runner pingue donc le daemon avant de servir
Rotation complète du CA, runner laissé en marche le job suivant réussit : le certificat client vit sur un volume partagé que dind réécrit, le runner reprend le nouveau
Arrêt propre du runner en plein job (ce que fait Coolify) le job est marqué failure dans Forgejo — pas bloqué en « running » — et il ne reste ni conteneur ni volume orphelin
Jeton d'enregistrement révoqué le runner redémarre et exécute des jobs : .runner suffit, register est sauté
Certificat proche de l'expiration seuil de 30 jours correct : 825 j → non, 20 j → oui, 2 j → oui

Cette tournée a surtout corrigé une affirmation de plus. Le dépôt annonçait qu'une rotation du CA laisserait un runner en marche avec un certificat périmé, échouant en silence derrière un healthcheck vert. Mesuré : cela n'arrive pas. Le volume docker-certs-ca garde son intérêt — des certificats stables d'un déploiement à l'autre, ce que vérifie T15 — mais pas celui qu'on lui prêtait.

Deux pannes réelles, provoquées sur le banc

Les cinq défauts ci-dessus ont été trouvés en lisant. Ces deux-là ont été trouvés en cassant volontairement la pile :

  • Forgejo devient injoignable. Le runner journalise l'erreur toutes les trois secondes — parfaitement diagnosticable — mais son healthcheck reste vert (le processus tourne, /data/.runner existe) et la page annonçait « enregistré ». Le tableau de bord affichait donc que tout allait bien pendant que la CI était morte. registered ne dit qu'une chose : l'enregistrement a réussi un jour. Le battement de cœur interroge désormais la forge pour de vrai, et la page affiche « forge injoignable » en rouge avec le geste à faire.
  • dind meurt. Le collecteur meurt avec lui, l'horodatage gèle, et la page bascule bien en « Données périmées » au-delà de 45 s. Vérifié sur une vraie panne cette fois, pas sur un relevé fabriqué.

Le remplissage de disque, de bout en bout

Le seul risque documenté et non corrigé du dépôt. Éprouvé sur un système de fichiers borné à 600 Mo, rempli par un conteneur de job : df voit la montée, l'alarme sort à 88 % avec son message exact, le collecteur rapporte le pourcentage, et la page passe ambre à 88 % puis rouge à 96 % en nommant la conséquence — « Forgejo est menacé avant la CI ». La chaîne complète tient.

Le ménage ne nettoyait pas ce qu'il fallait

Trouvé en observant prune plutôt qu'en le relisant. Depuis Docker 23, docker volume prune ne retire par défaut que les volumes anonymes. Or le runner nomme ceux de ses jobs. Mesuré : à la fin normale d'un job il fait le ménage lui-même — mais s'il est tué en cours de job, ce qui arrive à chaque redéploiement Coolify pendant qu'un job tourne, ses volumes restent orphelins pour toujours, sur le disque que Forgejo partage. La commande passe à -af. Vérifié aussi que -af n'emporte pas un job en cours : prune ignore les volumes attachés à un conteneur vivant, et le job concerné a bien abouti.

Le point commun de ces cinq premiers défauts : aucun ne se voit en regardant la page un jour où tout va bien. Trois d'entre eux se déclenchent avec le temps, pas avec une erreur — un compteur qui grossit, une horloge qui dérive. C'est la raison pour laquelle ils sont testés plutôt que relus.

Un processus qui ne se lançait pas du tout

Le plus dur à voir de tous, parce qu'il ne se manifestait que sur le serveur.

dashboard/40-auth.sh était stocké dans git en mode 100644, non exécutable. Or le point d'entrée de l'image nginx teste [ -x "$f" ] sur chaque fichier de /docker-entrypoint.d/ et journalise Ignoring …, not executable sinon. Mesuré : sans ce bit, nginx démarre normalement, aucun fichier d'authentification n'est écrit, et le bon mot de passe reçoit 403. Le tableau de bord aurait été inutilisable dès le premier déploiement.

Pourquoi rien ne l'avait signalé : Docker Desktop sous Windows monte les fichiers avec des permissions permissives. Toute la mise au point s'est donc faite sur une machine où le bit n'avait aucune importance, pour un déploiement sur une machine où il décide de tout. Le harnais vérifie désormais le mode dans l'index git — c'est lui qui voyage, pas celui du poste.

Ce que la page affirmait sans le vérifier

Une seconde famille de mensonges, plus discrète : la page rapportait ce que le dépôt déclare plutôt que ce que l'hôte applique. Trois écarts trouvés :

  • Les plafonds du daemon. Le compose les déclare sous deploy.resources. Selon la version de Docker Compose de l'hôte, une clé non reconnue — pids en particulier — est ignorée sans message. On croirait le garde-fou posé parce qu'il figure dans le fichier. Ces valeurs sont désormais lues dans le cgroup de dind, donc dans le noyau ; « aucune limite » devient une anomalie. Le harnais le vérifie de bout en bout : si ton hôte ignorait pids, T16 le dirait avant le déploiement.
  • La complétude du pare-feu. L'indicateur « actif » ne testait qu'une règle, la même que le healthcheck. Quatre des cinq auraient pu manquer sans que la carte cesse d'être verte — or chaque règle absente rouvre une plage entière. Les règles réellement en place sont maintenant comparées à celles attendues, et les manquantes sont nommées.
  • Les plafonds par job. La page écrivait, en texte figé, « chacun est plafonné à 1 Go de mémoire et 512 processus ». C'était une promesse, pas un constat : elle serait restée affichée mot pour mot si le runner avait démarré sur ses valeurs par défaut — exactement le cas d'un config.yml mal monté. La colonne « plafonds » du tableau des jobs est désormais lue sur chaque conteneur, et un job sans plafond devient une anomalie qui nomme le geste.

La règle qui en découle, appliquée partout : la page ne répète jamais le dépôt. Tout ce qu'elle affiche vient d'une lecture du système — cgroup, iptables, docker inspect, df. Quand les deux divergent, c'est le système qui a raison, et c'est lui qu'on affiche.

Tous les plafonds mémoire valaient le double de l'annoncé

Trouvé en faisant échouer un job exprès : un workflow qui alloue sans fin, sous --memory=1g, pour voir si la mort du job est lisible dans les journaux. Elle l'est — Killed, exitcode '137', Job failed, et le témoin d'après ne s'exécute pas. Mais le compteur du job disait autre chose : il avait écrit 2 048 Mo avant de mourir.

C'est le comportement documenté de Docker, et il prend tout le monde à revers : --memory=1g sans --memory-swap accorde au conteneur 1 Gio de RAM plus 1 Gio de swap. Le cgroup le confirmait sans ambiguïté — memory.max et memory.swap.max valaient tous deux 1073741824. Et la même chose valait pour dind (3 Go + 3 Go) et pour le service runner (512 Mo + 512 Mo) : chaque plafond de ce dépôt était le double de ce que le README, la page et les commentaires annonçaient.

Ce n'est pas qu'une erreur de comptabilité. Le gigaoctet supplémentaire est du disque, sur le même disque que Forgejo. Un job qui fuit ne mourait pas : il ramait en remplissant le swap, pendant tout le temps qu'il mettait à le faire. La panne ressemblait à « la CI est lente » et frappait Forgejo au passage.

Corrigé sur les trois étages — --memory-swap=1g dans config.yml, memswap_limit sur dind et sur runner (cette clé n'a pas d'équivalent sous deploy:, et le banc a servi à vérifier que les deux cohabitent). Le même workflow meurt maintenant à 1 024 Mo. Le collecteur remonte memory.swap.max au même titre que pids, et la page en fait une anomalie.

L'assertion écrite pour empêcher ce défaut a d'abord été fausse. Première rédaction : elle n'alarmait que si le swap valait max, c'est-à-dire illimité. Or l'état défectueux qu'on venait de corriger était 3221225472 — fini, borné, et pourtant le double de l'annoncé. Le test serait passé sur le bug qu'il existait pour attraper. Il exige maintenant zéro. C'est la deuxième fois dans ce dépôt qu'un contrôle de sécurité est écrit assez lâche pour valider l'état qu'il surveille ; la parade est la même que la première fois — l'éprouver sur l'état défectueux avant de le déclarer bon.

Trois trous dans le pare-feu des jobs, dont un très large

Passe de sécurité à froid. Le pare-feu bloque les plages privées — et c'est exactement là qu'était l'erreur de raisonnement : « privé » n'est pas la bonne frontière.

L'adresse publique du VPS lui-même n'est dans aucune plage privée. Un job l'atteignait donc librement. Mesuré, pas déduit : une adresse publique posée sur l'hôte du banc, un service qui écoute dessus, et un conteneur de job sur un réseau WORKFLOW-* s'y connecte. En production cela vise sshd, le proxy Coolify, le port de l'interface Coolify, et tout port qu'une autre application du serveur y publie. Les paquets arrivent à l'hôte SNATés en 10.89.7.20 : une restriction par IP source côté hôte les prendrait pour du trafic local.

La variable VPS_PUBLIC_IP referme cela — seuls 80 et 443 passent vers cette adresse, ce dont Forgejo a besoin. Elle ne peut pas être devinée depuis le conteneur, donc elle est vide par défaut ; non renseignée, dind l'annonce dans ses journaux au démarrage, en nommant ce qui reste joignable. Une alarme permanente sur la page pour un réglage optionnel apprendrait à ignorer la page, ce qui coûte plus cher que ça ne rapporte ; les journaux sont de toute façon la surface de l'exploitant.

100.64.0.0/10 manquait — la plage CGNAT (RFC 6598), c'est-à-dire celle de Tailscale. Elle n'est pas « privée » au sens RFC 1918, elle ne figurait donc nulle part. Sur un VPS raccordé à un tailnet, ou chez un hébergeur qui adresse en CGNAT, tout le réseau interne était ouvert aux workflows. Vérifié atteignable avant, jeté après.

Et un piège de configuration qui annulait la correction. FORGEJO_ALLOW_IP pose un RETURN sans port, et il finit en tête de chaîne. Réglé sur l'adresse publique du VPS — un réglage qui a l'air prudent, on nomme sa forge — il rouvrait sshd et tous les ports publiés. Le démarrage refuse maintenant ce cas et dit pourquoi.

Les trois sont éprouvés par les compteurs du pare-feu, pas par « nc a échoué » : nc ne distingue pas un port fermé d'un paquet jeté. Avec un contrôle apparié — le 443 doit toujours passer, sans quoi le checkout de tous les jobs serait cassé par une règle qui aurait l'air d'une protection.

La page se vantait d'un isolement qu'elle n'a pas

Le compose l'affirmait et le pied de page le répétait au lecteur : le conteneur web n'a « aucune route vers le réseau du runner ». C'est vrai avec docker compose seul — T6 et T16 le vérifient — et faux une fois déployé.

Mesuré dans la recette Coolify, sur la pile réellement déployée : Coolify raccorde son réseau applicatif à tous les services de la ressource, tableau de bord compris. Depuis le conteneur web, dind:2376 répond, et les deux ports du cache aussi. Déclarer un réseau dans le compose n'y changerait rien : celui de Coolify s'ajoute quoi qu'on fasse. Le harnais validait donc une topologie que Coolify ne déploie pas.

Ce qui tient réellement n'est pas l'absence de route, c'est l'authentification — et elle tient : depuis ce conteneur, 2376 refuse faute de certificat client, et le serveur de cache répond 403 faute de jeton. Le conteneur web n'a ni l'un ni l'autre. Le danger était donc dans la phrase, pas dans la pile : un exploitant qui lit « aucune route » ne va pas chercher ailleurs le jour où il raisonne sur un incident.

Corrigé sur les trois supports — le commentaire du compose, le pied de page, et la recette, qui rejoue désormais les deux refus sur la pile déployée. C'est le bon endroit : la seule mesure qui compte pour cette question ne peut se faire que là où la topologie est la vraie.

Le pare-feu ne protégeait pas dind de lui-même

DOCKER-USER ne filtre que le FORWARD. Or un paquet qu'un job envoie à la passerelle de son réseau — 172.18.0.1, c'est-à-dire dind — n'est pas forwardé : il est délivré localement, donc il passe par INPUT, où un seul port était fermé. Mesuré : un service mis à l'écoute dans dind sur le port 9999 a été joint depuis un conteneur de job du premier coup.

La règle sur 172.16.0.0/12 donnait en plus une fausse impression de couverture : 172.18.0.1 y figure bien, mais cette règle est dans la mauvaise chaîne pour ce trafic. Aujourd'hui seul dockerd écoute dans dind, et il est protégé par le TLS mutuel ; mais tout ce qui y écouterait un jour serait joignable depuis n'importe quel workflow, sans le moindre signal.

INPUT refuse désormais tout ce qui vient des ponts, en gardant le retour des connexions que dind a lui-même ouvertes.

Et le cadrage a dû changer, ce qui a refermé une fragilité plus ancienne. Première rédaction : ! -i <interface de sortie>, soit « tout sauf la route par défaut ». Dans Coolify, dind a deux interfaces d'infrastructure — eth0 sur le réseau applicatif, eth1 sur celui du compose — et ce complément englobe la seconde. Si le DNS de Docker rendait l'adresse eth1 pour le nom dind, la connexion du runner vers le daemon serait jetée par notre propre pare-feu. Et ce n'est pas une inquiétude théorique : deux passages consécutifs de la recette, sur le même dépôt, ont donné des noms d'interface inversés.

Passage eth0 eth1 route par défaut
1 172.31.55.4 (Coolify) 10.89.7.20 (compose) eth0
2 10.89.7.20 (compose) 172.31.55.4 (Coolify) eth1

Le complément ! -i <sortie> désigne donc un réseau différent à chaque déploiement. La pile ne tenait que parce que le nom dind résout vers l'adresse du réseau applicatif, qui se trouve porter la route par défaut — deux faits corrélés, pas une garantie. L'ancienne règle 2376 avait exactement le même défaut, depuis le début.

Les règles nomment donc les ponts : docker0, et le joker br+ d'iptables qui couvre les ponts par workflow créés plus tard sans aucune course. Les interfaces d'infrastructure ne sont plus jamais concernées. C'est à la fois plus précis pour la menace visée — ce qui vient d'un conteneur tournant dans dind — et cela referme la fragilité qui dormait.

La liste noire des plages est devenue un ensemble clos

Le cadrage précédent listait les trois plages RFC 1918 plus le lien-local : une liste noire ad hoc, donc incomplète par construction. Elle avait déjà laissé passer 100.64.0.0/10. En allonger la queue après chaque trouvaille garantit seulement la trouvaille suivante.

Les règles reprennent maintenant le registre IANA des adresses à usage spécial, fermé en entier — douze plages, dont le multicast, 0.0.0.0/8, la loopback, 192.88.99.0/24 et 198.18.0.0/15. Ce qui n'y figure pas est, par définition, une adresse publique d'Internet, dont les jobs ont besoin. Seules les plages TEST-NET restent ouvertes : aucun service réel n'y vit, et les harnais s'en servent pour simuler une adresse publique.

Ce que la même passe n'a PAS trouvé

Consigné parce qu'un résultat négatif mesuré vaut mieux qu'une zone d'ombre :

  • Le proxy de cache n'est pas un relais. C'est le seul service interne qu'un job a le droit de joindre, et le runner, lui, atteint l'hôte : un relais ouvert aurait été un pivot direct. Éprouvé depuis un vrai job, avec son vrai jeton de cache : forme absolue, CONNECT, Host détourné, traversées de chemin, curl -x, X-Forwarded-Host. Tout répond 404 ou ignore, et le piège posé sur l'hôte n'a jamais reçu la moindre connexion.
  • Aucune injection HTML dans la page. Les noms de jobs et d'images viennent de workflows tiers ; la page ne possède aucun innerHTML, tout passe par textContent et createTextNode.
  • Un mode de fichier hérité plutôt que choisi, en revanche, a été corrigé : snapshot.sh posait explicitement 0644, le battement de cœur non. Il l'est aujourd'hui par le seul umask de l'image. Le jour où cette image change, le relevé devient illisible pour nginx et la page annonce « le runner n'écrit plus son état » pendant qu'il l'écrit. L'asymétrie entre deux fichiers qui font le même travail était le signe.

Le collecteur appelait deux fois la commande la plus lente de Docker

docker system df parcourt toutes les images, tous les conteneurs et tous les volumes. Mesuré sur un hôte à 183 images : 6 à 7 secondes par appel. Le collecteur en faisait deux par tour, pour le même travail, sans aucune borne — treize secondes ajoutées à une boucle qui doit écrire son relevé plus souvent que le seuil de péremption de la page, qui est de 45 secondes.

Aujourd'hui ça passe, sur un dind quasi vide. Sur le VPS, dind accumule les images de chaque job pendant 72 heures avant le ménage, et rien ne garantit qu'il restera aussi rapide. Le jour où la boucle dépasse 45 secondes, la page annonce « le collecteur ne tourne plus » alors qu'il tourne — la panne exacte déjà rencontrée avec du dans le battement de cœur, retrouvée au même endroit du raisonnement dans l'autre collecteur.

Un seul appel désormais, borné par timeout, avec conservation des valeurs précédentes en cas de dépassement : une jauge disque figée une minute vaut mieux qu'un relevé en retard, qui éteint toute la page. Et l'assertion vérifie la règle plutôt que les deux cas connus — toute commande lente de ces deux boucles doit être précédée de timeout.

La page mentait sur la panne, dès qu'il n'y avait plus rien à afficher

Trouvé en regardant la page pour de vrai, dans un navigateur, avec un relevé absent — un cas qu'on ne voit jamais en développant, puisqu'on la regarde toujours avec un collecteur qui tourne.

Un seul bloc de render() déréférençait s sans garde. Tous les autres testent !s ou écrivent s && … ; celui-là non. Dès que le relevé manquait — collecteur arrêté, volume absent, 401 sur /data/ — une TypeError partait au milieu du rendu. Le filet de tick() la rattrapait, donc pas de page verte sur du vide, mais il affichait « La page n'a pas pu exploiter ce relevé : Cannot read properties of null » : un collecteur accusé d'écrire un relevé illisible alors qu'il n'en écrivait aucun. Les messages soigneusement rédigés pour ces cas — « le collecteur ne tourne pas », « authentification perdue » — n'étaient jamais atteints. Le diagnostic envoyait chercher la panne au mauvais endroit, ce qui est exactement le reproche que ce dépôt fait aux tableaux de bord.

Le harnais fait désormais tourner le JavaScript de la page dans node, avec un DOM factice (scripts/page-hors-nominal.js), sur dix-neuf situations : relevé absent, illisible, amputé, nominal, horloges désaccordées, plafond doublé par le swap, forge injoignable ou inconnue, config.yml vide ou amputé, runner non enregistré, cache au-dessus du plafond, certificat bientôt expiré ou illisible, pare-feu absent ou incomplet, job sans plafond, plafonds du compose ignorés par l'hôte, disque à 93 %. Chaque cas casse une chose dans un relevé sain — partir de l'état nominal, plutôt que fabriquer un relevé sur mesure qui donnerait raison au test par construction.

Le contrôle porte sur le titre et le motif. Le titre seul ne suffirait pas : « 2 anomalies » serait vrai avec deux mauvaises anomalies, et l'exploitant ne lit pas le compte, il lit la phrase. C'est le premier passage où ces dix-neuf branches de la page sont réellement exécutées ; elles tiennent toutes. node vient d'un conteneur — un test qui se saute quand l'outil manque ne prouve rien.

Une dernière chausse-trappe a failli tout annuler : la boucle était écrite printf … | while read, donc dans un sous-shell. Les OK et KO se seraient affichés, mais PASS et FAIL ne seraient jamais remontés — dix-neuf contrôles absents du décompte, et un échec incapable de faire rougir la suite. Un harnais qui sous-déclare ses résultats est le pire cas de figure : il rassure. La boucle est alimentée par un document en ligne, et la correction est vérifiée en cassant un motif exprès.

Deux corrections sont sorties de ce banc. La garde manquante, d'abord. Puis une phrase fausse : « son contenu n'est pas du JSON valide » servait aussi au relevé qui est du JSON parfaitement valide mais amputé de ses sections. Elle dit maintenant « pas exploitable : JSON invalide, ou sections manquantes ».

Et le harnais lui-même a d'abord fabriqué un faux défaut : son bouchon de fetch levait de façon synchrone sur du JSON invalide, là où une vraie Response.json() rend une promesse rejetée. La page paraissait confondre « illisible » et « absent ». Un bouchon doit imiter le contrat, pas le comportement le plus simple à écrire.

Deux lectures d'une même variable, et l'une des deux se taisait

RUNNER_CACHE_MAX_MB est interprété à deux endroits : la commande du service runner décide s'il faut purger, le battement de cœur décide de ce que la page affiche. Les deux ne lisaient pas la variable de la même façon.

Mesuré, en saisissant dans Coolify ce qu'on y saisit vraiment : 4G au lieu de 4096. Côté purge, [ "$taille" -gt "4G" ] échoue — ce n'est pas un nombre — et le test part dans la branche « pas de purge », définitivement. Côté page, seuls les chiffres étaient conservés : 4G devenait 4. Avec abc, pire encore : la purge ne se déclenchait plus jamais, et la page annonçait sereinement « plafond 4096 Mo ». Aucun signal nulle part, pour la seule croissance de disque continue de cette pile.

Les deux lectures sont maintenant identiques au caractère près, et le conteneur runner journalise le plafond qu'il a réellement retenu. Le test ne compare pas les deux bouts de code : il les extrait de leur fichier et les fait tourner côte à côte sur neuf valeurs, valides et non valides. Écrit d'abord autrement — lire le journal de la pile de test — il passait à vide, parce que ce runner ne s'enregistre pas et n'atteint donc jamais la purge. Un test qui ne peut pas échouer ne prouve rien ; celui-ci détecte les deux états d'avant.

Un test qui ne lisait rien, et le disait comme s'il avait lu

Le même passage a révélé une fragilité du harnais lui-même. Git Bash sous Windows convertit les chemins absolus des arguments : docker exec X cat /snapshot/status.json devient cat C:/Users/…/Git/snapshot/status.json, le conteneur répond « No such file », et le test lit une chaîne vide. La forme docker exec X sh -c '…' y échappe — l'argument ne commence pas par un slash — et les scripts mélangeaient les deux. lab.sh log rendait donc un journal vide au lieu d'une erreur, ce qui se lit exactement comme « rien à signaler ». Les quatre harnais posent maintenant MSYS_NO_PATHCONV=1 ; sous Linux la variable n'existe pas et ne coûte rien.

Et ce correctif était lui-même une demi-vérité. Sur Windows la variable n'est pas gratuite : la conversion était portante pour les chemins de l'hôte que le binaire Windows lit lui-même. La recette Coolify l'a montré au coup suivant — --env-file /tmp/recette-coolify/source/.env résolu en C:\tmp\…, et le même écart sur les sources de montage : le .env écrit à un endroit, cherché à un autre. La règle exacte : un chemin destiné à un conteneur ne doit pas être converti ; un chemin lu sur l'hôte doit l'être. On désarme donc globalement — le premier cas est le plus nombreux, et surtout le seul qui échoue en silence — et les rares chemins hôte passent par une fonction hote() qui les reconvertit, sans effet sous Linux.

Cet échec a révélé un troisième défaut, dans la recette : son compose up jetait sa sortie (>/dev/null 2>&1 || true). Le démarrage échouait en une ligne, le script attendait ensuite sept minutes et demie un conteneur jamais créé, puis annonçait « Coolify n'est jamais devenu healthy » — un diagnostic faux, cher, et qui pointait le mauvais coupable. La sortie est désormais conservée et affichée dès qu'aucun conteneur n'apparaît.

Décisions à connaître

  • Les images de la pile portent une version, pas une empreinte — et c'est une asymétrie assumée avec le reste du dépôt, qui exige l'épinglage par empreinte dans la CI partagée (T9) et pour Renovate. Ici, docker:28-dind suit les correctifs de la branche 28 ; l'épingler par empreinte figerait le conteneur le plus privilégié de tout le montage sur une version qui ne recevrait plus aucun correctif tant que personne ne la bumpe à la main. Sur un VPS sans mise à jour automatique, le risque « Docker non patché » est plus concret que le risque « tag Docker Hub compromis ». Ce qui reste interdit, et vérifié : :latest ou une image sans tag, dans le compose comme dans les labels du runner. Le même commit doit redéployer la même pile. Si tu branches Renovate sur ce dépôt, passer aux empreintes devient praticable — c'est le seul cas où ce compromis mérite d'être revu.

  • Plafond sur le cache des actions (RUNNER_CACHE_MAX_MB, 4 Go par défaut). Le runner ne purge jamais /data/cache, et le ménage quotidien ne le voit pas : celui-ci n'agit que sur les données du daemon, dans dind. Or les deux volumes vivent sur le même système de fichiers — vérifié, même device — que celui de Forgejo. C'est la seule croissance de disque réellement continue de cette pile, et ce n'est pas théorique : l'infrastructure de Forgejo elle-même a connu une partition racine pleine à cause de ce cache.

    La purge a lieu au démarrage du conteneur, avant que le daemon du runner ne s'exécute. C'est le seul instant où rien ne tient bolt.db ouvert, donc le seul purge qui ne puisse pas corrompre un cache en cours d'usage. Conséquence utile ici : elle se déclenche par un « Redeploy » dans Coolify, sans shell. La taille courante s'affiche sur la carte « Runner », et le dépassement y devient une anomalie qui nomme le geste à faire.

  • Volume sur .runner : sans lui, chaque redéploiement Coolify ré-enregistre un runner fantôme. L'image tourne en user 1000:1000 ; garder un volume nommé (un bind-mount hôte serait root, et l'écriture de /data/.runner échouerait).

  • IP fixes : 10.89.7.10 (runner) et 10.89.7.20 (dind). Le runner doit avoir une IP fixe parce que les conteneurs de job tournent dans dind, sur un autre réseau : sans cache.host, ils ne savent pas joindre le serveur de cache.

    Si le subnet 10.89.7.0/24 est déjà pris sur le VPS, le déploiement Coolify échoue avec Pool overlaps with other one on this address space. Sans SSH, ce message du journal de déploiement est le seul signal — il est suffisant. Le corriger demande quatre endroits, tous dans le dépôt : le subnet: du réseau runner, les deux ipv4_address:, le DOCKER_TLS_SAN de dind (sinon le certificat serveur ne couvre plus l'IP et le runner ne peut plus parler au daemon), et cache.host dans config.yml. Le test T3 vérifie ces correspondances, mais il tourne sur le poste : il ne peut pas connaître les réseaux du VPS.

  • Image pinnée à l'exact (13.1.0). Le bump passera par Renovate plutôt que par un tag flottant.

  • Un label = une image explicite (node-22, alpine). Ajouter un label dans config.yml et dans la commande register du compose : un label présent d'un seul côté laisse les jobs en attente pour toujours. Le test T2 interdit cette dérive.

  • register est déprécié côté Forgejo (remplacé par forgejo forgejo-cli actions register, à lancer côté serveur, qui fournit un UUID + jeton à mettre dans le bloc server.connections de config.yml). On le garde parce qu'il permet un déploiement Coolify en un clic sans aller chercher un UUID à la main. Il fonctionne toujours ; à migrer le jour où il disparaît.

  • Runners éphémères non retenus. --ephemeral existe et la doc sécurité Forgejo le recommande : le runner est supprimé après un job, ce qui borne les dégâts d'un jeton compromis. Incompatible avec un service Compose toujours allumé — il faudrait un orchestrateur qui réenregistre à chaque job. À reconsidérer si des dépôts publics acceptant des PR externes arrivent.

  • Arrêt propre : shutdown_timeout (60 s) < stop_grace_period (90 s). Sinon un redéploiement Coolify tue le runner en 10 s et Forgejo affiche les jobs « running » jusqu'à expiration de leur propre timeout.

Tableau de bord

C'est un service web ordinaire : un nginx qui écoute sur le port 80 et sert une page statique plus les relevés JSON déposés sur un volume. Rien de plus exotique que n'importe quelle application déployée dans Coolify.

Accès à la page

Attribuer un domaine au service dashboard dans Coolify. C'est tout. Le service écoute sur 80, donc le domaine seul suffit : Coolify génère les règles Traefik, termine le TLS, et la page est joignable en HTTPS. Aucun accès SSH, aucun terminal, aucune ligne à ajouter au dépôt.

Le TLS n'est pas un détail de confort : il corrige le seul vrai défaut de l'authentification Basic, qui sans lui ferait circuler le mot de passe en clair.

Le compose publie aussi 127.0.0.1:8090 sur le serveur. Ce port n'est pas le moyen d'accès — c'est une commodité pour les harnais de test et pour un éventuel tunnel ssh -L 8090:127.0.0.1:8090 <vps>. Traefik, lui, joint le conteneur par le réseau Docker et se moque des ports publiés.

Pourquoi le service ne déclare aucun réseau

Le point qui décide de tout, et qui n'est visible nulle part une fois déployé :

dind, runner et prune déclarent tous explicitement le réseau runner. Le service dashboard, lui, ne déclare rien — il rejoint donc le réseau default de la pile, où il se retrouve seul. L'isolement est le même que celui d'un réseau dédié : aucune route vers dind, aucune route vers le runner, le seul lien entre les deux mondes reste un fichier sur un volume.

Correction, établie par la recette. J'ai d'abord écrit ici qu'un réseau nommé par nous ne serait jamais rejoint par Traefik, et que la page serait donc injoignable. C'est faux. Mesuré dans un vrai Coolify : il ajoute son réseau d'application à tous les services, y compris à ceux qui en déclarent un, et il raccorde son proxy à tous les réseaux de la ressource. La page aurait été joignable dans les deux cas.

Ne pas déclarer de réseau ici reste préférable — un réseau de moins pour le même isolement, et rien qui dépende d'un comportement non documenté — mais c'est une simplification, pas la correction d'une panne. verify.sh (T6) maintient la règle avec cette justification-là.

Deux réglages Coolify à ne pas manquer

  • Ne pas activer « Connect to Predefined Network » sur cette ressource. Cette option place tous les services de la pile sur le réseau partagé de Coolify — donc dind et le runner aux côtés de toutes les autres ressources du serveur. Le pare-feu des jobs n'est pas contourné pour autant (il filtre par destination, pas par réseau d'origine), mais deux choses changent : le daemon privilégié devient joignable depuis n'importe quelle autre ressource — seul le TLS mutuel l'arrête alors — et le conteneur runner, qui détient à la fois le jeton du runner et la clé cliente du daemon, se retrouve exposé au voisinage. Le cloisonnement par réseau est la barrière la moins chère de cette pile ; autant la garder.
  • Le proxy de Coolify se retrouve sur le réseau runner. Mesuré : il raccorde son proxy à tous les réseaux de la ressource, pas seulement au sien. Le composant exposé à Internet partage donc un réseau avec le daemon privilégié. Ce qui tient malgré cela, vérifié depuis le proxy lui-même : 2375 n'existe pas, et 2376 refuse sans certificat client — le TLS mutuel est bien la barrière. En revanche le serveur de cache interne du runner (8088) lui répond : c'est pourquoi ce port reste fermé côté jobs et pourquoi le cache signe ses requêtes. Rien à changer, mais à savoir.
  • Garder un mot de passe long. Une fois la page joignable depuis Internet, elle n'est plus protégée que par lui. Le minimum de 12 caractères est imposé par le conteneur, mais viser plus.

Ce qui freine la force brute, et ce qu'il a fallu corriger

Trois compteurs, parce qu'un seul ne suffisait pas :

Compteur Clé Rôle
60 req/min adresse du client (via X-Forwarded-For) l'usage normal (~13 req/min) passe sans gêne
300 req/min pair réel de la connexion plafond absolu, qu'aucun en-tête ne peut contourner
600 req/min pair réel compteur séparé pour /healthz

Chacun vient d'une mesure, pas d'une intuition :

  • La limitation vivait dans location / et ne couvrait donc que la page. Les relevés /data/*.json, authentifiés eux aussi, passaient 80 essais de mot de passe sur 80 contre 37 sur la page. Un 401 se distinguant d'un 200, c'était un oracle de force brute sans aucun frein. Elle est désormais au niveau server, dont toutes les routes héritent.
  • Le compteur par client se fonde sur un en-tête, donc sur une valeur que le client choisit : X-Forwarded-For: 127.0.0.1 passait 50 essais sur 50. D'où le plafond fondé sur $realip_remote_addr, le pair réel de la connexion, qu'aucun en-tête ne modifie. Vérifié : la rotation d'adresses dans l'en-tête ne donne plus que 232 essais sur 300 au lieu de 300.
  • Sans compteur séparé, une attaque depuis Internet aurait épuisé le quota partagé, /healthz aurait répondu 503, et Coolify aurait redémarré en boucle un conteneur parfaitement sain. La sonde tient maintenant 100 sur 100 pendant l'attaque.

Autre piège nginx corrigé au passage : dès qu'une location déclare un add_header, elle n'hérite plus d'aucun en-tête du bloc parent. Les relevés /data/*.json — le seul contenu influencé par un tiers, puisqu'il porte des noms de jobs — étaient donc servis sans nosniff ni CSP. Chaque route les redéclare, et un test refuse toute route qui les oublierait.

Enfin, HSTS n'est envoyé que si la requête est arrivée en HTTPS. Sans cette condition, il épinglerait aussi l'origine http://127.0.0.1:8090 du tunnel local, que le navigateur refuserait ensuite de charger en clair, durablement.

Question tranchée par la recette : attribuer le domaine suffit. Sur Coolify 4.3.18, poser un domaine sur le service dashboard génère automatiquement les labels Traefik complets — traefik.enable=true, un routeur avec Host(`ton-domaine`), et loadbalancer.server.port=80 détecté tout seul. Le routage a été vérifié de bout en bout à travers le proxy : 401 sans identifiants, 200 avec. Aucun label à ajouter au dépôt.

Si malgré tout le domaine répondait 404 page not found (Traefik n'a aucune route) ou no available server (route posée, mais le proxy n'atteint pas le conteneur), le symptôme se lit sans shell et le repli serait d'écrire les labels à la main. Ce n'a pas été nécessaire ici.

Ce qui se vérifie sans shell

Le tableau de bord a été conçu pour être la surface d'exploitation, pas un gadget. Sans accès au serveur :

Question Où
Le runner est-il enregistré, avec quels labels ? page, carte « Runner »
config.yml est-il bien monté ? page, anomalie explicite si absent, vide ou incomplet
Le pare-feu est-il actif, et complet ? page, carte : les règles manquantes sont nommées
Les plafonds déclarés sont-ils appliqués par l'hôte ? page, anomalie si le noyau ne les a pas posés
Un job tourne-t-il sans plafond ? page, colonne « plafonds » du tableau des jobs
Un plafond mémoire est-il doublé par le swap ? page : anomalie sur le daemon, « + swap » dans la colonne « plafonds »
Le plafond du cache est-il celui que j'ai saisi ? journaux Coolify du service runner, ligne [cache] — la page affiche exactement la même valeur
Le cache des actions grossit-il ? page, carte « Runner » ; anomalie au-delà du plafond
Le disque se remplit-il ? page, jauge ; alarme à 80 % dans les journaux dind
Le certificat du daemon expire-t-il ? page, carte « Daemon isolé »
Quels jobs tournent ? page, tableau des jobs
Le mot de passe du tableau de bord journaux Coolify du service dashboard
Le ménage disque passe-t-il ? journaux Coolify du service prune
La CI fonctionne-t-elle vraiment ? Forgejo, onglet Actions d'un dépôt

Chacune de ces réponses est une branche de la page, et les dix-neuf sont exécutées à chaque passage du harnais — pas seulement écrites. Voir « La page mentait sur la panne » : celle qu'on croyait la mieux rédigée était justement celle qui ne s'affichait jamais.

Le contrôle de config.yml mérite une mention : c'est la panne la plus dangereuse et la plus silencieuse de ce déploiement. Un fichier mal monté par Coolify ne fait pas échouer le runner — il démarre sur ses valeurs par défaut, et les jobs tournent alors sans plafond mémoire ni plafond de PID. Rien d'autre ne le signalerait.

Ce que le tableau de bord ne remplace pas : les harnais verify.sh et e2e/run.sh exigent un shell et l'accès à Docker. Ils se lancent depuis un poste qui a le dépôt, avant de déployer — pas sur le serveur.

Identifiants

Identifiant informatique@equilibre-structures (variable DASHBOARD_USER)
Mot de passe variable DASHBOARD_PASSWORD

Le mot de passe de démonstration ne survit pas au déploiement. Si DASHBOARD_PASSWORD est vide ou vaut encore change_me, le conteneur en tire un au hasard à chaque démarrage et l'affiche dans ses journaux :

docker compose logs dashboard | grep -A6 "NOUVEAU MOT DE PASSE"

(dans Coolify : onglet Logs du service dashboard.)

C'est volontairement pénible : un identifiant par défaut laissé en place sur une page qui décrit l'état des défenses est exactement ce qu'on cherche à éviter. Pour un mot de passe stable, renseigner DASHBOARD_PASSWORD dans Coolify avec autre chose que change_me — la rotation cesse alors, et le journal l'indique.

DASHBOARD_PORT change le port publié (défaut 8090). Les deux harnais de test utilisent 8790 et 8791 pour pouvoir tourner pendant que la pile sert.

Elle affiche un verdict global, l'état du runner et du daemon, les jobs en cours, l'occupation disque de l'hôte, et les règles de filtrage avec leurs compteurs de paquets refusés.

Un mot de passe de moins de 12 caractères est refusé et remplacé par un mot de passe aléatoire : le hachage utilisé ({SHA}, seul format que l'image alpine sait produire sans openssl ni htpasswd) n'est pas salé, et ne protège donc qu'un secret long. Un identifiant contenant : fait refuser le démarrage — il couperait la ligne htpasswd en deux.

Tout est authentifié, la page comme les relevés JSON. Seule /healthz reste ouverte : sans elle, le healthcheck recevrait un 401 et Coolify afficherait le conteneur en panne alors qu'il fonctionne. Elle ne renvoie que ok.

Ce que coûte le fait de l'exposer. La page décrit la topologie interne et l'état des défenses : exactement ce qu'un attaquant veut lire avant d'agir. La mettre derrière un domaine, c'est offrir un formulaire à Internet — un coût accepté sciemment, parce que sans accès SSH il n'existe aucune autre façon de lire l'état de la pile. Ce qui rend le compte acceptable : TLS terminé par Traefik (l'authentification Basic ne voyage donc pas en clair), un mot de passe d'au moins 12 caractères imposé par le conteneur, une limitation à 60 requêtes par minute, et une page qui ne détient aucun secret — ni jeton, ni certificat, ni accès au daemon.

Elle ne peut rien casser. Le serveur web n'a aucun accès au daemon Docker, aucun certificat, et n'est même pas sur le réseau du runner : il sert un fichier déposé par dind, qui est le seul à voir à la fois le daemon, iptables et le système de fichiers. Aucun composant privilégié n'a été ajouté pour afficher une page. Les tests T6 et T13 verrouillent ces trois propriétés.

Ce qu'elle ne peut pas garantir, en revanche. Elle affiche ce que dind et le runner déposent sur le volume de supervision. Sa fidélité ne va donc pas plus loin que celle de ces deux conteneurs : qui obtiendrait l'exécution de code dans le runner pourrait y écrire un relevé mensonger. Ce n'est pas une faille séparée — ce même accès donne déjà la clé cliente du daemon, donc root sur le VPS — mais c'est la limite exacte de ce que la page démontre. Elle atteste de l'état du système tant que le système n'est pas compromis ; elle ne le détecte pas une fois qu'il l'est.

Elle ne ment pas. Au-delà de 45 secondes sans nouveau relevé, elle bascule en « données périmées » et le dit en tête, au lieu d'afficher un vert rassurant sur un état mort. C'est le même principe que pour les healthchecks.

Observabilité

Ce qu'on peut savoir, et par quel moyen :

Question Réponse Où
Le daemon tourne-t-il ? healthcheck dind Coolify
Le runner est-il enregistré ? healthcheck runner (teste /data/.runner, pas juste le process) Coolify
Le runner prend-il vraiment des jobs ? canary toutes les 30 min → healthchecks.io alerte push
Le disque se remplit-il ? docker system df avant/après chaque ménage logs prune
Le pare-feu est-il actif ? compteurs de paquets refusés tableau de bord, ou iptables -n -L DOCKER-USER -v dans dind
Vue d'ensemble en un coup d'œil verdict global + 6 indicateurs tableau de bord
Le certificat du daemon expire-t-il ? date d'expiration, alerte sous 30 j tableau de bord
Le disque de l'hôte se remplit-il ? ligne [disque] ALERTE au-delà de 80 % logs dind
Les journaux saturent-ils le disque ? non : rotation 10 Mo × 5 par service —
Que fait un job ? logs du job Forgejo, onglet Actions

Tout ce tableau est accessible sans shell sur le serveur : interface Coolify, tableau de bord, ou Forgejo. La seule entrée qui demande un terminal (iptables -n -L DOCKER-USER -v) est donnée en second recours — le tableau de bord affiche les mêmes règles et les mêmes compteurs.

Le seul avertissement actif est le canary (dans Infrastructure/ci) : les healthchecks ne sont visibles que si quelqu'un ouvre Coolify. Sans canary, un runner mort est indétectable — les workflows restent simplement en attente, et les dépôts affichent une CI « active » qui ne s'exécute jamais.

Trou connu : rien ne remonte de métrique CPU/RAM par job. Les limites sont posées (container.options), mais leur atteinte ne produit qu'un OOM kill visible dans les logs du job.

Découpage

Dépôt Contenu Pourquoi séparé
Infrastructure/forgejo-runner ce dépôt Infra. Ne détient aucun secret applicatif.
Infrastructure/renovate workflow + config Renovate Détient le PAT du bot. La politique de dépendances n'a pas le cycle de vie de l'infra.

L'image de Renovate est épinglée par empreinte, comme le reste de la CI partagée. Elle ne l'était pas : elle tournait sur le tag flottant :44, alors que ce conteneur détient un PAT en écriture sur tous les dépôts découverts. Une republication sous ce tag — compromission amont ou simple erreur — se serait exécutée avec ces droits au passage nocturne suivant, sans que personne n'ait rien changé. C'était le workflow le plus privilégié et le seul non épinglé ; le test T9 refuse désormais toute image de conteneur sans empreinte. | Infrastructure/ci | security.yml réutilisable, canary | CI par dépôt, consommée via uses: — pas copiée-collée. |

Le contenu prêt à pousser pour les deux autres dépôts est dans spinoff/, à supprimer une fois les dépôts créés.

Licences

Runner (GPL-3.0-or-later), Docker Engine (Apache 2.0), Renovate CLI (AGPL, payant seulement en édition Enterprise), OSV.dev (service public). Aucune clé, aucune limite, usage interne sans obligation.