<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Docker on Guillaume Delré</title><link>https://guillaumedelre.github.io/fr/tags/docker/</link><description>Recent content in Docker on Guillaume Delré</description><generator>Hugo</generator><language>fr-FR</language><lastBuildDate>Sun, 17 May 2026 15:00:00 +0000</lastBuildDate><atom:link href="https://guillaumedelre.github.io/fr/tags/docker/index.xml" rel="self" type="application/rss+xml"/><item><title>Onze sur douze</title><link>https://guillaumedelre.github.io/fr/2026/05/17/onze-sur-douze/</link><pubDate>Sun, 17 May 2026 15:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/fr/2026/05/17/onze-sur-douze/</guid><description>Part 8 of 8 in &amp;quot;Symfony vers le Cloud : Douze Facteurs, Treize Services&amp;quot;: Onze facteurs résolus proprement. Le douzième : des migrations Doctrine dans l&amp;#39;entrypoint, en attente d&amp;#39;une question de gouvernance que le code seul ne peut pas trancher.</description><category>symfony-to-the-cloud</category><content:encoded><![CDATA[<p>Le <code>composer.json</code> de chaque service avait ça dans sa section <code>post-install-cmd</code> :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span><span style="color:#e6db74">&#34;post-install-cmd&#34;</span><span style="color:#960050;background-color:#1e0010">:</span> [
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;bin/console cache:clear --env=prod&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;bin/console doctrine:migrations:migrate --no-interaction&#34;</span>
</span></span><span style="display:flex;"><span>]
</span></span></code></pre></div><p><code>post-install-cmd</code> s&rsquo;exécute pendant <code>composer install</code>, qui dans le Dockerfile de production tourne au moment du build de l&rsquo;image. Il n&rsquo;y a pas de base de données disponible pendant un build Docker. La commande de migration échouait silencieusement, se connectait à rien, ou était ignorée par Doctrine faute de schéma à comparer. Dans tous les cas, elle ne migrait rien.</p>
<p>C&rsquo;est une violation nette du <a href="https://12factor.net/admin-processes" target="_blank" rel="noopener noreferrer">Facteur XII</a>
 : les processus d&rsquo;administration — migrations, scripts ponctuels, commandes console — doivent s&rsquo;exécuter dans le même environnement que l&rsquo;application, contre les vraies données de production. Les faire tourner au build inverse la relation. L&rsquo;image ne devrait pas savoir qu&rsquo;il y a une base de données. La base devrait être là quand l&rsquo;image en a besoin.</p>
<h2 id="le-déplacement-vers-lentrypoint">Le déplacement vers l&rsquo;entrypoint</h2>
<p>La commande de migration a quitté <code>composer.json</code> pour <code>docker-entrypoint.sh</code>. Le changement semble petit dans un diff. Les implications ne le sont pas.</p>
<p>L&rsquo;entrypoint s&rsquo;exécute quand le container démarre, pas quand l&rsquo;image est construite. La base de données est accessible. L&rsquo;entrypoint l&rsquo;attend — jusqu&rsquo;à 60 secondes, une tentative par seconde — avant de faire quoi que ce soit :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span>ATTEMPTS_LEFT_TO_REACH_DATABASE<span style="color:#f92672">=</span><span style="color:#ae81ff">60</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">until</span> <span style="color:#f92672">[</span> $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq <span style="color:#ae81ff">0</span> <span style="color:#f92672">]</span> <span style="color:#f92672">||</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  DATABASE_ERROR<span style="color:#f92672">=</span><span style="color:#66d9ef">$(</span>php bin/console dbal:run-sql -q <span style="color:#e6db74">&#34;SELECT 1&#34;</span> 2&gt;&amp;1<span style="color:#66d9ef">)</span>; <span style="color:#66d9ef">do</span>
</span></span><span style="display:flex;"><span>    sleep <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>    ATTEMPTS_LEFT_TO_REACH_DATABASE<span style="color:#f92672">=</span><span style="color:#66d9ef">$((</span>ATTEMPTS_LEFT_TO_REACH_DATABASE <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span><span style="color:#66d9ef">))</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">done</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> <span style="color:#f92672">[</span> $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq <span style="color:#ae81ff">0</span> <span style="color:#f92672">]</span>; <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>    echo <span style="color:#e6db74">&#34;</span>$DATABASE_ERROR<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>    exit <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">fi</span>
</span></span></code></pre></div><p>Si la base ne répond pas dans les 60 secondes, le container sort en erreur et Kubernetes le redémarre. Une fois la base prête, la migration tourne :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span><span style="color:#66d9ef">if</span> <span style="color:#f92672">[</span> <span style="color:#e6db74">&#34;</span><span style="color:#66d9ef">$(</span> find ./migrations -iname <span style="color:#e6db74">&#39;*.php&#39;</span> -print -quit <span style="color:#66d9ef">)</span><span style="color:#e6db74">&#34;</span> <span style="color:#f92672">]</span>; <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>    php bin/console doctrine:migrations:migrate --no-interaction --all-or-nothing
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">fi</span>
</span></span></code></pre></div><p>Deux changements par rapport à la commande d&rsquo;origine : <code>--all-or-nothing</code> garantit que si une migration dans un batch échoue, le batch entier est annulé. Et le guard <code>find</code> passe la commande si aucun fichier de migration n&rsquo;existe — utile pour les services qui n&rsquo;utilisent pas les migrations Doctrine.</p>
<p>C&rsquo;est franchement mieux. La base est là. La migration tourne dans le vrai environnement. Le flag <code>--all-or-nothing</code> apporte une atomicité que la version au build n&rsquo;avait jamais eue.</p>
<h2 id="ce-que-ça-ne-résout-pas">Ce que ça ne résout pas</h2>
<p>Deux pods qui redéploient simultanément exécutent tous les deux l&rsquo;entrypoint. Tous les deux atteignent la base. Tous les deux trouvent des migrations en attente. Tous les deux appellent <code>doctrine:migrations:migrate</code>.</p>
<p>Doctrine a un mécanisme de verrouillage : une table <code>doctrine_migration_versions</code> qui enregistre quelles migrations ont tourné, et la commande la consulte avant d&rsquo;appliquer quoi que ce soit. Dans les conditions normales c&rsquo;est suffisant : le deuxième pod trouve la table à jour et sort proprement. Les cas de défaillance réels sont plus précis : une migration assez longue pour dépasser le timeout du verrou de base de données, ce qui laisse un deuxième runner démarrer la même migration avant que le premier ait terminé ; ou un pod qui se vautre à mi-migration avant d&rsquo;avoir enregistré la version dans la table, laissant le schéma dans un état appliqué-mais-non-enregistré que le pod suivant va tenter d&rsquo;appliquer à nouveau.</p>
<p>La position de l&rsquo;équipe est explicite : un downtime léger au déploiement est acceptable. Les versions d&rsquo;application ne sont pas nécessairement compatibles avec des versions de schéma plus anciennes, donc faire tourner N et N+1 simultanément contre la même base ne serait de toute façon pas sûr. La stratégie de déploiement est Recreate : tous les anciens pods sont terminés avant que les nouveaux ne démarrent. La migration tourne au premier démarrage, sans chevauchement entre les versions. Ça fonctionne.</p>
<p>Mais &ldquo;ça fonctionne&rdquo; et &ldquo;c&rsquo;est la bonne architecture&rdquo; sont deux réponses différentes.</p>
<h2 id="ce-que-feraient-les-alternatives">Ce que feraient les alternatives</h2>
<p>Le <a href="https://12factor.net/admin-processes" target="_blank" rel="noopener noreferrer">Facteur XII</a>
 dit que les processus d&rsquo;administration doivent tourner dans des &ldquo;processus ponctuels&rdquo;. Un processus qui tourne une fois, dans un but précis, contre l&rsquo;environnement de production. L&rsquo;entrypoint n&rsquo;est pas ponctuel — il tourne à chaque démarrage de container, y compris les redémarrages, les événements de scaling, et les déplacements de pods par Kubernetes.</p>
<p>Trois alternatives existent, chacune avec une réponse différente à la question de la propriété :</p>
<p><strong>Un init container Kubernetes</strong> tourne avant le container principal, dans le même pod. Il peut exécuter la migration, sortir, et laisser le container principal démarrer seulement après son succès. La migration est isolée du runtime applicatif. Le problème : l&rsquo;init container est une image supplémentaire à construire et maintenir, et il tourne à chaque démarrage de pod — une plateforme de 14 services démarrant simultanément a toujours une race potentielle.</p>
<p><strong>Un Kubernetes Job</strong> tourne une fois, à la demande ou déclenché par le pipeline de déploiement. Il peut être configuré pour s&rsquo;exécuter avant la mise à jour des pods — séquentiel, isolé, avec un signal clair de succès ou d&rsquo;échec. La race condition disparaît. La complexité se déplace vers le processus de déploiement : le Job doit se terminer avant que le rollout du Deployment commence, et le pipeline CI doit coordonner les deux.</p>
<p><strong>Un hook Helm</strong> est le même concept exprimé de façon déclarative dans le chart Helm. Un hook <code>pre-upgrade</code> exécute la migration avant la mise à jour des pods applicatifs. C&rsquo;est la réponse la plus idiomatique pour Kubernetes. Ça signifie aussi que le chart Helm est désormais responsable de l&rsquo;exécution des migrations — une décision qui appartient à qui possède le chart.</p>
<p>Cette dernière phrase explique pourquoi l&rsquo;entrypoint n&rsquo;a pas changé. Déplacer les migrations hors de l&rsquo;application signifie décider que l&rsquo;infrastructure de déploiement — pas l&rsquo;application elle-même — est responsable du schéma. C&rsquo;est une question de gouvernance autant que de technique, et les questions de gouvernance prennent plus de temps à résoudre que les changements de code.</p>
<h2 id="la-fin-honnête">La fin honnête</h2>
<p>Le bloc de migration dans l&rsquo;entrypoint, c&rsquo;est deux lignes. Littéralement : le guard <code>if [ &quot;$( find ./migrations... )&quot; ]</code>, et le <code>php bin/console doctrine:migrations:migrate</code> qui suit. Onze autres facteurs ont des résolutions nettes. Le cache est passé sur Redis. Les logs vont vers stdout. Le système de fichiers est un bucket S3. Le CI assemble les images de production depuis le même commit qu&rsquo;il teste. Les secrets ne voyagent plus dans les layers d&rsquo;image.</p>
<p>Le Facteur XII a une réponse. Ce n&rsquo;est juste pas la réponse finale.</p>
<p>Les migrations tournent au démarrage, avec une vraie base de données, avec atomicité, avec une fenêtre de retry bornée. C&rsquo;est mieux que de tourner au build contre rien. La question de savoir si elles finiront dans un Job ou un hook Helm est une conversation sur qui possède le schéma — une question à laquelle un <code>kubectl apply</code> ne peut pas répondre.</p>
]]></content:encoded></item><item><title>Démarré ne veut pas dire prêt</title><link>https://guillaumedelre.github.io/fr/2026/05/17/d%C3%A9marr%C3%A9-ne-veut-pas-dire-pr%C3%AAt/</link><pubDate>Sun, 17 May 2026 10:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/fr/2026/05/17/d%C3%A9marr%C3%A9-ne-veut-pas-dire-pr%C3%AAt/</guid><description>Part 7 of 8 in &amp;quot;Symfony vers le Cloud : Douze Facteurs, Treize Services&amp;quot;: Le script d&amp;#39;entrypoint qui fonctionne parfaitement sous Docker Compose a cinq responsabilités. Sur Kubernetes, chacune d&amp;#39;elles a sa place ailleurs.</description><category>symfony-to-the-cloud</category><content:encoded><![CDATA[<p>Le rolling deploy avait l&rsquo;air propre. Un nouveau pod démarrait. Kubernetes voyait le healthcheck passer — <code>php -v</code> renvoyait zéro — et commençait à router du trafic vers le nouveau container.</p>
<p>Pendant les quarante secondes suivantes — sur les soixante possibles — ce container était en train de poller la base de données.</p>
<p>Les requêtes qui atterrissaient dessus pendant cette fenêtre récoltaient des erreurs. Pas beaucoup — la fenêtre était courte — mais assez pour apparaître comme du bruit dans le monitoring. Le genre de bruit qu&rsquo;on classe comme « problème réseau transitoire » et qu&rsquo;on ne signale nulle part. Le déploiement a réussi. Le pod a fini par devenir prêt. Le mécanisme qui en était la cause était toujours là, attendant le prochain déploiement.</p>
<p>Le script d&rsquo;entrypoint fait cinq choses avant que FrankenPHP démarre : copier un fichier de version, vérifier le répertoire vendor, attendre jusqu&rsquo;à soixante secondes la base de données, jouer les migrations en attente, installer les assets et configurer les permissions filesystem. Sous Docker Compose, c&rsquo;est invisible. Sur Kubernetes, l&rsquo;écart devient du trafic en erreur.</p>
<h2 id="lécart-entre-démarré-et-prêt">L&rsquo;écart entre démarré et prêt</h2>
<p>Kubernetes décide d&rsquo;envoyer du trafic à un pod en surveillant sa readiness probe. Un pod dont la readiness probe passe reçoit des requêtes. Un pod dont la readiness probe échoue est retiré de la rotation du load balancer jusqu&rsquo;à ce qu&rsquo;il récupère. C&rsquo;est le mécanisme qui rend les rolling deploys sûrs : Kubernetes ne bascule pas vers un nouveau pod tant que ce pod n&rsquo;indique pas qu&rsquo;il est prêt.</p>
<p>Le compose.yaml définit un healthcheck sur chaque service :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">healthcheck</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">test</span>: [ <span style="color:#e6db74">&#34;CMD&#34;</span>, <span style="color:#e6db74">&#34;php&#34;</span>, <span style="color:#e6db74">&#34;-v&#34;</span> ]
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">interval</span>: <span style="color:#ae81ff">30s</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">timeout</span>: <span style="color:#ae81ff">10s</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">retries</span>: <span style="color:#ae81ff">3</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">start_period</span>: <span style="color:#ae81ff">10s</span>
</span></span></code></pre></div><p><code>php -v</code> réussit dès que le binaire PHP est présent — ce qui est vrai depuis la première milliseconde de vie du container. Le <code>start_period: 10s</code> donne dix secondes avant que les vérifications commencent. Mais la boucle de polling de l&rsquo;entrypoint tourne jusqu&rsquo;à soixante secondes avant que FrankenPHP démarre. À la dixième seconde, le healthcheck passe. L&rsquo;application attend toujours la base de données.</p>
<p>Le Dockerfile a un meilleur signal :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-dockerfile" data-lang="dockerfile"><span style="display:flex;"><span><span style="color:#66d9ef">HEALTHCHECK</span> --start-period<span style="color:#f92672">=</span>60s CMD curl -f http://localhost:2019/metrics <span style="color:#f92672">||</span> exit <span style="color:#ae81ff">1</span><span style="color:#960050;background-color:#1e0010">
</span></span></span></code></pre></div><p>Le port 2019 est le serveur de métriques intégré à Caddy, embarqué directement dans FrankenPHP. L&rsquo;endpoint est compatible Prometheus et ne répond qu&rsquo;une fois que la stack HTTP de Caddy est pleinement initialisée et que les workers PHP acceptent des connexions. <code>php -v</code> se termine en cinquante millisecondes quel que soit l&rsquo;état de l&rsquo;application — il vérifie le binaire, pas le serveur. <code>:2019/metrics</code> ne répond que quand le serveur sert vraiment. Ce n&rsquo;est pas non plus un endpoint ajouté exprès pour la probe : chaque service de la plateforme l&rsquo;a déjà scraped par Prometheus, donc le signal est actif indépendamment de toute configuration de healthcheck.</p>
<p>C&rsquo;est plus proche. Mais sur Kubernetes, l&rsquo;instruction <code>HEALTHCHECK</code> du Dockerfile est totalement ignorée. Kubernetes utilise sa propre configuration de probes. Sans définitions de probes explicites dans les manifests Kubernetes, il n&rsquo;y a aucune vérification de readiness — et un pod est considéré prêt dès que son container démarre.</p>
<p>Ce qui signifie : le pod démarre, l&rsquo;entrypoint commence à poller, Kubernetes route du trafic, l&rsquo;application n&rsquo;est pas encore en état de le traiter. Les requêtes arrivent sur un container qui n&rsquo;est pas prêt à les gérer.</p>
<h2 id="trois-signaux-trois-questions">Trois signaux, trois questions</h2>
<p>Kubernetes sépare le cycle de vie d&rsquo;un container en trois questions distinctes, chacune avec son propre type de probe :</p>
<p><strong>startupProbe</strong> — « L&rsquo;application a-t-elle fini de démarrer ? » Se déclenche à répétition jusqu&rsquo;à ce qu&rsquo;elle passe, puis passe la main à la liveness. Empêche la liveness probe de tuer un container qui est légitimement long à initialiser. Pour un container dont l&rsquo;entrypoint peut prendre soixante secondes, c&rsquo;est l&rsquo;outil adapté.</p>
<p><strong>readinessProbe</strong> — « L&rsquo;application est-elle prête à traiter des requêtes ? » Échoue et passe tout au long de la vie du container. Quand elle échoue, le pod est retiré du load balancer. C&rsquo;est ce qui rend un rolling deploy sûr.</p>
<p><strong>livenessProbe</strong> — « L&rsquo;application est-elle toujours vivante ? » Si elle échoue, Kubernetes redémarre le container. Conçue pour détecter les processus bloqués, pas les démarrages lents.</p>
<p>La boucle de polling de soixante secondes appartient à la patience de la startupProbe, pas au code applicatif :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">startupProbe</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">httpGet</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">path</span>: <span style="color:#ae81ff">/metrics</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">port</span>: <span style="color:#ae81ff">2019</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">failureThreshold</span>: <span style="color:#ae81ff">12</span>    <span style="color:#75715e"># 12 tentatives × 5s = 60s max</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">periodSeconds</span>: <span style="color:#ae81ff">5</span>
</span></span></code></pre></div><p>Une fois la startupProbe passée, une readinessProbe sur le même endpoint prend le relais — indiquant à Kubernetes quand le pod peut recevoir du trafic — et une livenessProbe surveille les processus bloqués. Mais c&rsquo;est la startupProbe qui absorbe le démarrage lent. La boucle de polling de l&rsquo;entrypoint devient redondante : son rôle était de maintenir le container en vie pendant que la base de données devenait disponible. Sans elle, l&rsquo;application tente de se connecter, échoue, et le container quitte — Kubernetes redémarre alors le pod, et la startupProbe maintient son cycle de tentatives jusqu&rsquo;à ce que la base réponde et que l&rsquo;application démarre proprement. La responsabilité du retry passe de l&rsquo;intérieur de l&rsquo;entrypoint à l&rsquo;orchestrateur, ce qui est exactement là où elle devrait être.</p>
<h2 id="le-problème-des-migrations">Le problème des migrations</h2>
<p>La boucle de polling est le problème le plus visible, mais les migrations en créent un plus subtil.</p>
<p>Avec un rolling deploy et deux replicas, Kubernetes démarre un nouveau pod pendant que l&rsquo;ancien sert encore du trafic. Les deux pods jouent le même entrypoint. Les deux atteignent <code>doctrine:migrations:migrate</code>.</p>
<p>La table de migrations de Doctrine trace quelles migrations ont déjà été exécutées, donc une migration complétée ne se jouera pas deux fois. Mais si deux pods démarrent simultanément et voient tous les deux une migration en attente, les deux tentent de la jouer en même temps. Si c&rsquo;est sûr ou non dépend de la migration : les changements de schéma additifs passent en général bien ; les destructifs moins. Et on ne choisit pas lesquels s&rsquo;exécutent lors d&rsquo;un déploiement qui n&rsquo;a pas prévu de se coordonner. <code>--all-or-nothing</code> enveloppe les migrations dans une transaction et fait un rollback si l&rsquo;une échoue — c&rsquo;est une question d&rsquo;atomicité au sein d&rsquo;une seule exécution, pas de coordination entre processus.</p>
<p>L&rsquo;approche plus propre sépare ces deux préoccupations en deux init containers : l&rsquo;un qui attend la base de données, l&rsquo;autre qui joue les migrations. Le container principal ne démarre qu&rsquo;une fois les deux terminés :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">initContainers</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">wait-for-db</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">image</span>: <span style="color:#ae81ff">authentication:latest</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">command</span>: [<span style="color:#e6db74">&#34;php&#34;</span>, <span style="color:#e6db74">&#34;bin/console&#34;</span>, <span style="color:#e6db74">&#34;dbal:run-sql&#34;</span>, <span style="color:#e6db74">&#34;-q&#34;</span>, <span style="color:#e6db74">&#34;SELECT 1&#34;</span>]
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">migrate</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">image</span>: <span style="color:#ae81ff">authentication:latest</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">command</span>: [<span style="color:#e6db74">&#34;php&#34;</span>, <span style="color:#e6db74">&#34;bin/console&#34;</span>, <span style="color:#e6db74">&#34;doctrine:migrations:migrate&#34;</span>, <span style="color:#e6db74">&#34;--no-interaction&#34;</span>, <span style="color:#e6db74">&#34;--all-or-nothing&#34;</span>]
</span></span></code></pre></div><p>Les deux init containers réutilisent la même image que l&rsquo;application. Ce n&rsquo;est pas du gaspillage : ils ont besoin du même binaire PHP et du même câblage d&rsquo;environnement pour atteindre la base de données et trouver les classes de migration. Une image dédiée plus légère réduirait le temps de démarrage, mais nécessiterait de maintenir une installation PHP séparée en synchronisation avec l&rsquo;image principale.</p>
<p>Même avec des init containers, plusieurs pods démarrant simultanément — déploiement initial, après une défaillance de nœud, ou sous pression d&rsquo;autoscaling — tenteront chacun de jouer les migrations. Le résoudre proprement — via un hook pre-upgrade Helm, une stratégie <code>maxSurge: 0</code>, ou un Job de migration séparé — est un sujet en soi. Ce qui compte ici, c&rsquo;est que l&rsquo;entrypoint est le mauvais endroit pour prendre cette décision : il ne peut pas se coordonner entre pods, et il lie l&rsquo;exécution des migrations au démarrage de l&rsquo;application d&rsquo;une façon difficile à démêler plus tard. La question de quelle alternative convient à cette codebase — et pourquoi l&rsquo;entrypoint n&rsquo;a pas encore été remplacé — fait l&rsquo;objet de <a href="/2026/05/17/eleven-out-of-twelve/">l&rsquo;article suivant dans cette série</a>
.</p>
<p>Le Facteur XII de la <a href="https://12factor.net/admin-processes" target="_blank" rel="noopener noreferrer">méthodologie twelve-factor</a> — les processus d&rsquo;administration tournent dans le même environnement que l&rsquo;application — est respecté dans les deux cas. La question est de savoir si « même environnement » signifie « même script d&rsquo;entrypoint » ou « même image, processus séparé ». Sur Kubernetes, le second est plus sûr.</p>
<h2 id="la-vraie-responsabilité-de-lentrypoint">La vraie responsabilité de l&rsquo;entrypoint</h2>
<p>Enlever l&rsquo;attente de la base de données (maintenant une startupProbe ou un init container), les migrations (maintenant un init container ou un Job), et l&rsquo;installation des assets (une opération de build-time qui appartient au Dockerfile), et l&rsquo;entrypoint n&rsquo;a plus qu&rsquo;une seule responsabilité : démarrer l&rsquo;application.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span>exec docker-php-entrypoint <span style="color:#e6db74">&#34;</span>$@<span style="color:#e6db74">&#34;</span>
</span></span></code></pre></div><p>Le Facteur IX de la twelve-factor app demande un démarrage rapide et un arrêt propre. Un container dont le démarrage prend soixante secondes parce qu&rsquo;il attend des dépendances externes n&rsquo;est pas rapide. Ça signifie des rolling deploys lents, une reprise après crash lente, et un scale-out horizontal qui crée une fenêtre de soixante secondes avant que chaque nouveau pod contribue.</p>
<p>Le démarrage rapide n&rsquo;est pas juste un confort. C&rsquo;est ce qui fait fonctionner le reste du modèle cloud. Quand un pod peut démarrer en secondes, l&rsquo;orchestrateur peut scaler agressivement et récupérer vite. Quand ça prend une minute, on ajoute des marges partout — timeouts de probes plus longs, fenêtres de déploiement plus larges, politiques de scaling plus conservatrices — et le système devient rigide.</p>
<h2 id="la-taxe-docker-compose">La taxe Docker Compose</h2>
<p>L&rsquo;entrypoint accumule ces responsabilités pour une raison. Sous Docker Compose, il n&rsquo;y a pas de concept d&rsquo;init container. Pas de startupProbe. Les services déclarent <code>depends_on</code>, mais sans conditions de santé, c&rsquo;est juste de l&rsquo;ordre de démarrage — pas de la readiness. L&rsquo;entrypoint comble le vide.</p>
<p>Ce n&rsquo;est pas un défaut de conception. C&rsquo;est une adaptation raisonnable aux contraintes de Docker Compose. Le script fonctionne. Il gère les cas limites (le timeout de la base, les erreurs irrécupérables, le répertoire de migrations absent). Quelqu&rsquo;un l&rsquo;a testé.</p>
<p>Le problème, c&rsquo;est l&rsquo;hypothèse que le même script fonctionne aussi bien sur Kubernetes. Il tourne. L&rsquo;application finit par démarrer. Mais il contourne le système de probes qui rend les déploiements Kubernetes fiables, et il place la responsabilité des migrations à un endroit où la coordination entre pods est difficile à raisonner.</p>
<p>Plusieurs des changements de cette série — <a href="/2026/05/14/the-ghost-of-the-ci-runner/">stockage des médias</a>
, <a href="/2026/05/14/what-survives-the-build/">secrets dans les images</a>
, <a href="/2026/05/15/no-witnesses/">handlers de logs</a>
, <a href="/2026/05/15/the-host-that-hid-the-graph/">dépendances de services</a>
, <a href="/2026/05/16/fifteen-minutes-before-the-first-test/">parité d&rsquo;environnement CI</a>
, <a href="/2026/05/16/the-cache-that-was-lying-to-us/">adaptateurs de cache</a>
 — étaient des changements au code applicatif ou à la configuration. Celui-ci est différent. Il demande à l&rsquo;infrastructure de comprendre ce que « prêt » signifie pour cette application, et il demande à l&rsquo;entrypoint de céder des responsabilités qu&rsquo;il détient actuellement.</p>
<p>C&rsquo;est une conversation plus difficile. Mais la startupProbe attend.</p>
]]></content:encoded></item><item><title>Quinze minutes avant le premier test</title><link>https://guillaumedelre.github.io/fr/2026/05/16/quinze-minutes-avant-le-premier-test/</link><pubDate>Sat, 16 May 2026 10:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/fr/2026/05/16/quinze-minutes-avant-le-premier-test/</guid><description>Part 5 of 8 in &amp;quot;Symfony vers le Cloud : Douze Facteurs, Treize Services&amp;quot;: Comment un pipeline CI qui provisionnait une VM Azure par run — sans RabbitMQ, MinIO ni Varnish — est devenu un pipeline qui assemble l&amp;#39;environnement de production depuis les mêmes images qu&amp;#39;il livre.</description><category>symfony-to-the-cloud</category><content:encoded><![CDATA[<p>Le pipeline avait deux stages qui n&rsquo;avaient rien à voir avec le code : <code>provision</code> et <code>deprovision</code>. Entre eux, dans l&rsquo;ordre : <code>phpunit</code>, <code>phpmetrics</code>, <code>behat</code>.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">stages</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">build</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">provision</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">phpunit</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">phpmetrics</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">behat</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">deprovision</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">deploy</span>
</span></span></code></pre></div><p>Avant que la première assertion s&rsquo;exécute, quinze minutes s&rsquo;étaient écoulées. Terraform avait cloné un dépôt d&rsquo;infrastructure, s&rsquo;était authentifié sur Azure, avait appliqué une configuration de VM. Ansible s&rsquo;était connecté à la nouvelle VM, avait installé PHP, configuré l&rsquo;application, câblé une base de données et une instance Redis. Ensuite les tests tournaient. Ensuite Terraform détruisait ce qu&rsquo;Ansible avait construit.</p>
<p>Pour chaque pipeline. Depuis chaque branche. Pour chaque pull request, de l&rsquo;ouverture au merge.</p>
<h2 id="ce-que-ces-quinze-minutes-ne-contenaient-pas">Ce que ces quinze minutes ne contenaient pas</h2>
<p>Le stage <code>provision</code> mettait en place deux services : PostgreSQL et Redis. Trois services dont l&rsquo;application dépendait en production étaient absents : RabbitMQ, MinIO et Varnish.</p>
<p>RabbitMQ traitait tout le travail asynchrone — 56 consumers sur 14 microservices. MinIO gérait le stockage de médias. Varnish était devant le cache HTTP. En CI, aucun d&rsquo;eux n&rsquo;existait. Les tests qui couvraient les files de messages ou le stockage de fichiers avaient deux options : ignorer ces chemins, ou les laisser non testés jusqu&rsquo;au staging. Varnish est un cas à part : les tests tapent directement dans l&rsquo;application et contournent intentionnellement la couche de cache, son absence en CI est donc un choix délibéré plutôt qu&rsquo;un manque.</p>
<p>C&rsquo;est le problème que le <a href="https://12factor.net/dev-prod-parity" target="_blank" rel="noopener noreferrer">Facteur X</a>
 décrit comme l&rsquo;écart d&rsquo;environnement. L&rsquo;écart ici n&rsquo;était pas une question de configuration — il était structurel. La VM était construite par Ansible depuis un script dans un dépôt séparé. Ce n&rsquo;était pas une image de container. Elle n&rsquo;était pas versionnée aux côtés de l&rsquo;application. Si une branche modifiait la topologie de messages RabbitMQ, il n&rsquo;y avait aucun moyen de tester cette modification en CI. Le changement de topologie et le code qui en dépendait ne se rencontreraient qu&rsquo;en staging.</p>
<p>Le script de provisioning Ansible lui-même fait partie du problème :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">launch_vm</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">stage</span>: <span style="color:#ae81ff">provision</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">script</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#ae81ff">git clone git@gitlab.internal/infra/ci-vm.git</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#ae81ff">cd ci-vm</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#ae81ff">az login --service-principal -u $ARM_CLIENT_ID ...</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#ae81ff">terraform apply -var &#34;prefix=${CI_PIPELINE_ID}-vm&#34; ...</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#ae81ff">sleep 45</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#ae81ff">ansible-playbook behat/test-env.yml ...</span>
</span></span></code></pre></div><p>Le <code>sleep 45</code> est là parce qu&rsquo;Ansible a besoin que la VM finisse de booter avant de pouvoir s&rsquo;y connecter. Ce n&rsquo;est pas un oubli — c&rsquo;est le délai minimum qu&rsquo;une VM fraîchement provisionnée nécessite avant que SSH fonctionne. C&rsquo;est inscrit dans le processus.</p>
<h2 id="ce-qui-la-remplacé">Ce qui l&rsquo;a remplacé</h2>
<p>Le nouveau pipeline n&rsquo;a pas de stage <code>provision</code>. Il n&rsquo;a pas de stage <code>deprovision</code>. L&rsquo;environnement, ce sont les images, et les images existent avant que les tests commencent.</p>
<p>Chaque job de test déclare ses dépendances comme des services Docker :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">services</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">$REGISTRY_URL/platform/rabbitmq:$CI_COMMIT_REF_SLUG</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">alias</span>: <span style="color:#ae81ff">rabbitmq</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">$REGISTRY_URL/platform/minio:$CI_COMMIT_REF_SLUG</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">alias</span>: <span style="color:#ae81ff">minio</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">redis:7.4.1</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">alias</span>: <span style="color:#ae81ff">redis</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">$ARTIFACTORY_URL/postgresql:13</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">alias</span>: <span style="color:#ae81ff">postgresql</span>
</span></span></code></pre></div><p>Les services démarrent en parallèle quand le job commence. Avant que le script de test tourne, un <code>before_script</code> attend qu&rsquo;ils soient tous prêts :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">before_script</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">$CI_PROJECT_DIR/dockerize</span>
</span></span><span style="display:flex;"><span>      -<span style="color:#ae81ff">wait tcp://postgresql:5432</span>
</span></span><span style="display:flex;"><span>      -<span style="color:#ae81ff">wait tcp://rabbitmq:5672</span>
</span></span><span style="display:flex;"><span>      -<span style="color:#ae81ff">wait tcp://minio:9000</span>
</span></span><span style="display:flex;"><span>      -<span style="color:#ae81ff">wait tcp://redis:6379</span>
</span></span><span style="display:flex;"><span>      -<span style="color:#ae81ff">timeout 120s</span>
</span></span></code></pre></div><p>Du démarrage du pipeline à la première assertion : quatre-vingt-dix secondes — en supposant que les images sont déjà dans le cache du runner ; un cold pull rallonge les choses, mais devient négligeable une fois que le pipeline a tourné une fois sur une branche donnée.</p>
<h2 id="ce-que-signifie-ci_commit_ref_slug">Ce que signifie <code>$CI_COMMIT_REF_SLUG</code></h2>
<p>Le timing est le résultat visible. Ce qui le produit est plus intéressant encore : les noms des images.</p>
<p><code>$REGISTRY_URL/platform/rabbitmq:$CI_COMMIT_REF_SLUG</code> n&rsquo;est pas l&rsquo;image officielle RabbitMQ de Docker Hub. C&rsquo;est une image construite par le même pipeline, depuis la même branche, au même commit que le code testé. L&rsquo;image RabbitMQ embarque la topologie : un <code>definitions.json</code> avec chaque exchange, chaque queue, chaque binding, chaque configuration de dead-letter — versionné dans git aux côtés de l&rsquo;application qui en dépend.</p>
<p>Si une branche modifie la topologie de messages, le pipeline CI construit une nouvelle image RabbitMQ qui inclut ces modifications, puis exécute les tests contre elle. Le changement de topologie et le code qui en dépend sont testés ensemble, au même commit, avant que quoi que ce soit n&rsquo;atteigne le staging.</p>
<p>La même logique s&rsquo;applique à MinIO, décrite dans le <a href="/2026/05/14/the-ghost-of-the-ci-runner/">premier article de cette série</a>
 : l&rsquo;image MinIO embarque des fixtures de test préchargées. L&rsquo;environnement CI n&rsquo;a pas besoin d&rsquo;une étape de setup pour peupler le stockage. L&rsquo;état est intégré à l&rsquo;artefact.</p>
<p>Le runner de tests lui-même suit le même pattern. Chaque job utilise une variante debug de l&rsquo;image applicative — construite depuis la même branche, au même commit — avec les dépendances de test incluses :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">image</span>: <span style="color:#ae81ff">$REGISTRY_URL/platform/$service:$CI_COMMIT_REF_SLUG-debug</span>
</span></span></code></pre></div><p>Tout l&rsquo;environnement s&rsquo;assemble depuis des artefacts construits au même point de l&rsquo;historique git.</p>
<h2 id="ce-que-ça-a-demandé-dabandonner">Ce que ça a demandé d&rsquo;abandonner</h2>
<p>Behat et la VM provisionnée étaient couplés. La suite de tests Behat tournait contre un serveur HTTP sur la VM ; supprimer la VM signifiait supprimer Behat.</p>
<p>Ça s&rsquo;est révélé moins bloquant que ça n&rsquo;en avait l&rsquo;air. La suite Behat vivait dans un dépôt séparé, nécessitait la VM pour tourner, et avait accumulé une charge de maintenance significative. PHPUnit, tournant dans le container applicatif avec les services Docker, couvrait les mêmes scénarios par un chemin plus direct : tests fonctionnels qui exercent la couche HTTP, tests unitaires pour les composants individuels, suites organisées par domaine fonctionnel et générées dynamiquement en jobs CI parallèles.</p>
<p>La couche BDD a disparu. La couverture de tests est restée — et pouvait désormais tourner contre les vrais services.</p>
<h2 id="le-facteur-x-appliqué">Le Facteur X, appliqué</h2>
<p>Le <a href="https://12factor.net/dev-prod-parity" target="_blank" rel="noopener noreferrer">Facteur X</a>
 se lit souvent comme &ldquo;utilise la même base de données en local qu&rsquo;en production.&rdquo; C&rsquo;est la version la plus simple. La version plus profonde concerne l&rsquo;écart entre ce qu&rsquo;on teste et ce qu&rsquo;on livre.</p>
<p>L&rsquo;écart dans l&rsquo;ancien pipeline était large : une VM configurée manuellement, privée de services clés, reconstruite de zéro à chaque run. L&rsquo;écart dans le nouveau pipeline est étroit : le CI assemble l&rsquo;environnement depuis les mêmes images que la production, construites au même commit que le code sous test.</p>
<p>Les quinze minutes de Terraform et Ansible n&rsquo;étaient pas seulement lentes. Elles construisaient quelque chose qui n&rsquo;était pas ce que la production faisait tourner, à chaque fois, avant que le moindre test puisse commencer. Les quatre-vingt-dix secondes de <code>docker pull</code> construisent exactement ce que la production fait tourner — et les tests qui suivent testent ça, pas une approximation.</p>
]]></content:encoded></item><item><title>Ce qui survit au build</title><link>https://guillaumedelre.github.io/fr/2026/05/14/ce-qui-survit-au-build/</link><pubDate>Thu, 14 May 2026 15:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/fr/2026/05/14/ce-qui-survit-au-build/</guid><description>Part 2 of 8 in &amp;quot;Symfony vers le Cloud : Douze Facteurs, Treize Services&amp;quot;: Comment des fichiers .env committés alimentent un build qui grave les credentials dans les layers Docker — et ce qu&amp;#39;il faut pour vider le fichier jusqu&amp;#39;à quatre lignes.</description><category>symfony-to-the-cloud</category><content:encoded><![CDATA[<p>À un moment de l&rsquo;audit de migration cloud, quelqu&rsquo;un a lancé ça :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker run --rm &lt;image&gt; php -r <span style="color:#e6db74">&#34;var_dump(require &#39;.env.local.php&#39;);&#34;</span>
</span></span></code></pre></div><p>La sortie montrait tout ce que <code>composer dump-env prod</code> avait compilé dans l&rsquo;image au moment du build. Ce qui voulait dire tout ce qui se trouvait dans le fichier <code>.env</code> quand l&rsquo;image avait été construite. Ce qui voulait dire, entre autres, ça :</p>
<pre tabindex="0"><code class="language-dotenv" data-lang="dotenv">INFLUXDB_INIT_ADMIN_TOKEN=&lt;influxdb-admin-token&gt;
GF_SECURITY_ADMIN_USER=admin
GF_SECURITY_ADMIN_PASSWORD=admin123
BLACKFIRE_CLIENT_ID=&lt;blackfire-client-id&gt;
BLACKFIRE_CLIENT_TOKEN=&lt;blackfire-client-token&gt;
BLACKFIRE_SERVER_ID=&lt;blackfire-server-id&gt;
BLACKFIRE_SERVER_TOKEN=&lt;blackfire-server-token&gt;
NGROK_AUTHTOKEN=replace-me-optionnal
</code></pre><p>Vingt-cinq variables au total. Chaque credential accumulé dans le <code>.env</code> racine sur trois ans, désormais permanent dans un layer d&rsquo;image.</p>
<h2 id="comment-dump-env-fonctionne">Comment <code>dump-env</code> fonctionne</h2>
<p><code>composer dump-env prod</code> est une optimisation Symfony légitime. Au lieu de parser les fichiers <code>.env</code> à chaque requête, le runtime charge un tableau PHP pré-compilé depuis <code>.env.local.php</code>. Plus rapide et plus simple.</p>
<p>Le problème, c&rsquo;est ce qu&rsquo;il lit. Le Dockerfile copie le dépôt dans l&rsquo;image avec <code>COPY . ./</code>, <code>.env</code> inclus. Ensuite <code>dump-env prod</code> lit ce fichier et compile chaque variable dans <code>.env.local.php</code>. L&rsquo;image est livrée avec une capture figée des credentials qui se trouvaient dans <code>.env</code> au moment du build.</p>
<p>Les layers Docker sont des archives immuables. Même si une étape ultérieure supprimait <code>.env</code> du système de fichiers du container, le layer qui le contient existerait toujours dans l&rsquo;image. <code>docker save &lt;image&gt;</code> produit une archive tar de chaque layer ; extraire un fichier spécifique de n&rsquo;importe quel point de l&rsquo;historique de build est une opération simple. Les credentials sont invisibles à l&rsquo;exécution. Ils ne sont pas partis.</p>
<p>Le <a href="https://12factor.net/build-release-run" target="_blank" rel="noopener noreferrer">Facteur V</a>
 est explicite là-dessus : un artefact de build doit être agnostique à l&rsquo;environnement, la config arrivant à l&rsquo;étape de release depuis l&rsquo;extérieur. Dès que des credentials sont compilés dedans, l&rsquo;image n&rsquo;est plus portable. On ne peut plus la promouvoir entre environnements. On builde deux fois en espérant que le deuxième se comporte comme le premier.</p>
<h2 id="comment-vingt-cinq-variables-saccumulent">Comment vingt-cinq variables s&rsquo;accumulent</h2>
<p>Avant de voir comment on a réparé ça, il vaut la peine de comprendre comment on en est arrivé là.</p>
<p>Les tokens <code>BLACKFIRE_*</code> sont le cas facile à comprendre. Un membre de l&rsquo;équipe configure le profiling, a besoin de partager la configuration, et le dépôt est déjà ouvert à tout le monde. Une ligne dans <code>.env</code> est la voie de moindre résistance. Les credentials InfluxDB et Grafana suivent la même logique — outillage partagé, dépôt partagé, un commit.</p>
<p>Puis il y a les variables qui révèlent une autre dérive. Dans certains <code>.env</code> de services :</p>
<pre tabindex="0"><code class="language-dotenv" data-lang="dotenv">APP__RATINGS__SERIALS=&#39;{&#34;marque1&#34;:{&#34;fr&#34;:&#34;12345&#34;},...}&#39;  # ~40 lignes de JSON
APP__YOUTUBE__CREDENTIALS=&#39;{&#34;marque1&#34;:{&#34;client_id&#34;:&#34;xxx&#34;,&#34;refresh_token&#34;:&#34;yyy&#34;},...}&#39;
</code></pre><p>Des numéros de série pour la mesure d&rsquo;audience. Des refresh tokens YouTube par marque. Ce ne sont pas des secrets au sens des tokens Blackfire. Ce sont des données métier — le genre de valeurs qui varient entre marques et environnements, que quelqu&rsquo;un a décidé de versionner dans <code>.env</code> parce qu&rsquo;elles se comportaient comme de la configuration et que <code>.env</code> était l&rsquo;endroit où vivait la configuration.</p>
<p>Vingt-cinq variables, c&rsquo;est la somme de décisions incrémentales, dont aucune ne semblait fausse isolément. Le problème est structurel : quand <code>.env</code> est la seule réponse disponible, tout finit par y ressembler.</p>
<h2 id="où-les-choses-appartiennent-vraiment">Où les choses appartiennent vraiment</h2>
<p>Vider le fichier exigeait de répondre à une question pour chaque variable : <em>où est-ce que ça appartient vraiment ?</em></p>
<p>Les réponses ont révélé trois catégories que l&rsquo;équipe n&rsquo;avait jamais explicitement nommées :</p>
<p><strong>La config statique</strong> vit dans le code. Règles métier, logique de routing, fichiers de paramètres Symfony — tout ce qui ne varie pas entre les déploiements. Un changement exige un rebuild. Les blocs JSON de numéros de série se sont révélés ne pas être de la config statique du tout : ils étaient interrogés depuis un service Config dédié à l&rsquo;exécution. Ils n&rsquo;avaient rien à faire dans un fichier.</p>
<p><strong>La config environnementale</strong> varie entre les déploiements : hostnames, chaînes de connexion, credentials de services tiers. C&rsquo;est ce que le <a href="https://12factor.net/config" target="_blank" rel="noopener noreferrer">Facteur III</a>
 désigne par &ldquo;config dans les variables d&rsquo;environnement&rdquo; — de vraies variables au niveau OS, injectées à l&rsquo;exécution, jamais des fichiers qui voyagent avec le code. Dans Kubernetes, c&rsquo;est un ConfigMap pour les valeurs non sensibles et un Kubernetes Secret pour les credentials. Le choix retenu pour les secrets a été SOPS — les credentials sont chiffrés et committés dans git, plutôt que stockés dans un coffre-fort externe comme Azure Key Vault ou HashiCorp Vault. Un coffre-fort échange la simplicité contre l&rsquo;auditabilité : rotation automatique, logs d&rsquo;audit centralisés, accès via workload identity sans clé à protéger. SOPS échange ces capacités contre un modèle opérationnel plus simple — pas de service externe à interroger au déploiement, les secrets transitent par le processus de review normal du code, l&rsquo;historique git fait office de piste d&rsquo;audit. Les contreparties acceptées sont la rotation manuelle et la responsabilité de protéger la clé de déchiffrement elle-même. Pour la taille de l&rsquo;équipe, le compromis était délibéré.</p>
<p><strong>La config dynamique</strong> change sans déploiement : paramètres éditoriaux, seuils par marque, configuration de modération de contenu. Elle appartient à une base de données, gérée via le service Config de l&rsquo;application. Une partie de ce qui s&rsquo;était accumulé dans les <code>.env</code> de services était cette catégorie depuis le début, passant pour des valeurs par défaut statiques parce qu&rsquo;elle changeait assez rarement pour que personne ne le remarque.</p>
<p>Une fois les catégories nommées, les variables se sont triées. Le <code>.env</code> racine est arrivé à quatre lignes :</p>
<pre tabindex="0"><code class="language-dotenv" data-lang="dotenv">DOMAIN=platform.127.0.0.1.sslip.io
XDEBUG_MODE=off
SERVER_NAME=:80
APP_ENV=dev
</code></pre><p>Des valeurs par défaut sûres. Rien de sensible. <code>dump-env prod</code> compile maintenant des chaînes vides ; les vraies valeurs arrivent à l&rsquo;exécution depuis Kubernetes.</p>
<h2 id="limage-postgresql">L&rsquo;image PostgreSQL</h2>
<p>L&rsquo;image PostgreSQL utilisée en CI a un mot de passe codé en dur :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-dockerfile" data-lang="dockerfile"><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> <span style="color:#e6db74">postgres:15</span><span style="color:#960050;background-color:#1e0010">
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">ENV</span> POSTGRES_PASSWORD<span style="color:#f92672">=</span>admin123
</span></span></code></pre></div><p>Ça ressemble au même problème. Ce n&rsquo;en est pas un, parce que le modèle de menace est différent. La base CI est éphémère — elle existe le temps d&rsquo;un run de pipeline, ne contient pas de vraies données, tourne dans un réseau isolé. Un mot de passe codé en dur sur une base de test jetable est un risque acceptable, pas une entorse à la règle.</p>
<p>En production, la question ne se pose pas : la plateforme utilise Azure Flexible Server, un service PostgreSQL managé. Il n&rsquo;y a pas d&rsquo;image Docker. Les credentials arrivent via injection dans les charts Helm, sans jamais toucher un layer.</p>
<h2 id="ce-qui-survit-au-build-maintenant">Ce qui survit au build maintenant</h2>
<p>L&rsquo;image qui part en production contient maintenant une garantie : <code>var_dump(require '.env.local.php')</code> ne retourne que des chaînes vides et des valeurs par défaut sûres. Les credentials ne sont pas là parce qu&rsquo;ils n&rsquo;y ont jamais été mis — ils arrivent à l&rsquo;exécution, depuis l&rsquo;extérieur.</p>
<p>C&rsquo;est la frontière de responsabilité que <code>dump-env</code> avait silencieusement effacée : l&rsquo;image est l&rsquo;application, le runtime est l&rsquo;environnement. Ils ne devraient pas connaître les secrets de l&rsquo;autre.</p>
]]></content:encoded></item><item><title>Construire un homelab self-hosted avec Docker Compose et Traefik</title><link>https://guillaumedelre.github.io/fr/2026/02/17/construire-un-homelab-self-hosted-avec-docker-compose-et-traefik/</link><pubDate>Tue, 17 Feb 2026 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/fr/2026/02/17/construire-un-homelab-self-hosted-avec-docker-compose-et-traefik/</guid><description>Tuto complet pour monter un homelab Docker avec Traefik et sslip.io : stacks indépendants, dashboard auto-configuré, pièges documentés.</description><content:encoded><![CDATA[<p>Ça fait des années que j&rsquo;avais envie d&rsquo;un homelab à la maison. Un endroit à moi pour héberger mes outils de développement, surveiller mes machines, faire tourner de la domotique, tester des trucs sans risquer de casser quoi que ce soit d&rsquo;important. L&rsquo;idée est simple. La mise en place un peu moins.</p>
<p>À l&rsquo;époque, Kubernetes n&rsquo;existait pas encore. Les options pour faire tourner plusieurs services sur une machine se résumaient à du scripting bash, des configurations Nginx écrites à la main, et beaucoup de café. Les tutoriels &ldquo;homelab pour les humains&rdquo; brillaient par leur absence.</p>
<p>Ce tuto, c&rsquo;est ce que j&rsquo;aurais voulu trouver à l&rsquo;époque. Ça tourne depuis plusieurs années maintenant. Pas sans évoluer : des services ajoutés, d&rsquo;autres abandonnés, des choix revisités. Mais la base est là, stable, et c&rsquo;est bien ça le succès en self-hosting.</p>
<p>Le setup : dix services web auto-hébergés sur une machine locale, accessibles depuis un navigateur via des URLs lisibles, sans toucher à la configuration DNS, sans louer un VPS, sans certificat TLS à gérer. L&rsquo;ingrédient qui rend ça possible : <a href="https://sslip.io" target="_blank" rel="noopener noreferrer">sslip.io</a>
, un service DNS public qui encode l&rsquo;IP directement dans le nom de domaine. <code>service.192.168.1.10.sslip.io</code> résout vers <code>192.168.1.10</code>, sans rien configurer, depuis n&rsquo;importe quelle machine du réseau local.</p>
<p>Ce tutoriel s&rsquo;adresse à quelqu&rsquo;un qui connaît Docker mais qui part de zéro sur l&rsquo;orchestration de services self-hosted.</p>
<hr>
<h2 id="table-des-matières">Table des matières</h2>
<ol>
<li><a href="#1-philosophie-et-choix-darchitecture">Philosophie et choix d&rsquo;architecture</a>
</li>
<li><a href="#2-les-briques-fondamentales">Les briques fondamentales</a>
</li>
<li><a href="#3-mise-en-place-pas-%c3%a0-pas">Mise en place pas à pas</a>
</li>
<li><a href="#4-ajouter-un-nouveau-service">Ajouter un nouveau service</a>
</li>
<li><a href="#5-patterns-et-conventions">Patterns et conventions</a>
</li>
<li><a href="#6-pi%c3%a8ges-courants">Pièges courants</a>
</li>
<li><a href="#conclusion">Conclusion</a>
</li>
<li><a href="#r%c3%a9f%c3%a9rences">Références</a>
</li>
</ol>
<hr>
<h2 id="1-philosophie-et-choix-darchitecture">1. Philosophie et choix d&rsquo;architecture</h2>
<h3 id="objectif">Objectif</h3>
<p>Faire tourner plusieurs services web sur une machine locale, accessibles depuis un navigateur via des URLs lisibles, sans toucher à la configuration DNS, sans louer un VPS, sans certificat TLS à gérer.</p>
<h3 id="pourquoi-docker-compose-et-pas-autre-chose-">Pourquoi Docker Compose et pas autre chose ?</h3>
<p>Docker Compose est le bon niveau de complexité pour un homelab personnel. Kubernetes est trop lourd pour une seule machine. Docker Swarm est en déclin. Compose est simple, lisible, versionnable, et suffisant pour des dizaines de services.</p>
<h3 id="pourquoi-traefik-et-pas-nginx-proxy-manager-">Pourquoi Traefik et pas Nginx Proxy Manager ?</h3>
<p><strong>Nginx Proxy Manager (NPM)</strong> est une interface graphique pour configurer Nginx comme reverse proxy. Les routes sont stockées dans une base de données et configurées via une UI.</p>
<p><strong><a href="https://github.com/traefik/traefik" target="_blank" rel="noopener noreferrer">Traefik</a>
</strong> lit automatiquement les labels Docker des containers et génère sa configuration à la volée. Quand on démarre un container avec les bons labels, Traefik le découvre et crée la route immédiatement, sans redémarrage, sans UI à ouvrir.</p>
<p>Ce comportement &ldquo;configuration as code&rdquo; a deux avantages majeurs :</p>
<ul>
<li>La configuration d&rsquo;un service est dans son <code>compose.yaml</code>, au même endroit que tout le reste.</li>
<li>Ajouter un service ne nécessite pas de toucher à Traefik.</li>
</ul>
<h3 id="pourquoi-dockge-et-pas-portainer-">Pourquoi Dockge et pas Portainer ?</h3>
<p><strong>Portainer</strong> est un outil de gestion Docker complet : images, volumes, réseaux, containers individuels&hellip; puissant mais complexe.</p>
<p><strong><a href="https://github.com/louislam/dockge" target="_blank" rel="noopener noreferrer">Dockge</a>
</strong> est focalisé sur une seule chose : gérer des stacks Docker Compose. Son UI est minimaliste et intuitive. Pour un homelab où tout est géré en Compose, c&rsquo;est suffisant et bien plus agréable à utiliser.</p>
<h3 id="pourquoi-sslipio-">Pourquoi sslip.io ?</h3>
<p>Les services web ont besoin d&rsquo;un nom d&rsquo;hôte (ex: <code>dozzle.monserveur.local</code>) pour que Traefik puisse les router correctement. Les options habituelles :</p>
<ul>
<li>Modifier <code>/etc/hosts</code> sur chaque machine : fastidieux, non partageable.</li>
<li>Configurer un vrai DNS local (Pi-hole, AdGuard) : nécessite une infrastructure supplémentaire.</li>
<li>Acheter un domaine et configurer les DNS : coûte de l&rsquo;argent et du temps.</li>
</ul>
<p><strong>sslip.io</strong> est un service DNS public qui résout automatiquement <code>&lt;anything&gt;.&lt;IP&gt;.sslip.io</code> vers <code>&lt;IP&gt;</code>. Exemple : <code>dozzle.192.168.1.10.sslip.io</code> résout vers <code>192.168.1.10</code>. Il n&rsquo;y a rien à configurer, le DNS fonctionne partout sans toucher à quoi que ce soit.</p>
<hr>
<h2 id="2-les-briques-fondamentales">2. Les briques fondamentales</h2>
<h3 id="le-réseau-docker-partagé">Le réseau Docker partagé</h3>
<p>Tous les services et Traefik doivent partager le même réseau Docker pour que Traefik puisse communiquer avec eux. Ce réseau s&rsquo;appelle <code>traefik</code> et est créé une seule fois :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker network create traefik
</span></span></code></pre></div><p>C&rsquo;est un réseau <strong>externe</strong> (créé hors de tout Compose). Chaque <code>compose.yaml</code> le déclare comme externe :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">networks</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">traefik</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">external</span>: <span style="color:#66d9ef">true</span>
</span></span></code></pre></div><p>Pourquoi externe plutôt qu&rsquo;interne à un Compose ? Parce que plusieurs stacks indépendants doivent tous y être connectés. Un réseau interne à un Compose n&rsquo;est accessible qu&rsquo;aux services de ce Compose.</p>
<h3 id="traefik--le-reverse-proxy">Traefik : le reverse proxy</h3>
<p>Traefik écoute sur le port 80 et route les requêtes HTTP vers le bon container selon le <code>Host</code> header.</p>
<p>Sa configuration principale est dans <code>stacks/traefik/docker/traefik/traefik.yaml</code> :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">api</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">dashboard</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">insecure</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">entryPoints</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">web</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">address</span>: :<span style="color:#ae81ff">80</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">ping</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">address</span>: :<span style="color:#ae81ff">8082</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">providers</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">docker</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">endpoint</span>: <span style="color:#ae81ff">unix:///var/run/docker.sock</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">exposedByDefault</span>: <span style="color:#66d9ef">false</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">log</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">level</span>: <span style="color:#ae81ff">INFO</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">global</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">sendAnonymousUsage</span>: <span style="color:#66d9ef">false</span>
</span></span></code></pre></div><p><code>exposedByDefault: false</code> est important : Traefik ignore tous les containers par défaut. Un container doit explicitement s&rsquo;exposer avec le label <code>traefik.enable: true</code>. Cela évite d&rsquo;exposer accidentellement des services.</p>
<p>L&rsquo;entrypoint <code>ping</code> sur le port 8082 est dédié aux health checks. Le séparer de l&rsquo;entrypoint <code>web</code> évite que les checks apparaissent dans les logs d&rsquo;accès.</p>
<p>Pour accéder au daemon Docker, Traefik monte le socket :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">volumes</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#ae81ff">/var/run/docker.sock:/var/run/docker.sock</span>
</span></span></code></pre></div><h3 id="dockge--le-gestionnaire-de-stacks">Dockge : le gestionnaire de stacks</h3>
<p>Dockge tourne lui-même dans un container (le <code>compose.yaml</code> à la racine du repo). Il a besoin de deux choses :</p>
<ol>
<li>Accès au socket Docker pour piloter les autres containers.</li>
<li>Accès aux dossiers des stacks pour lire et modifier les <code>compose.yaml</code>.</li>
</ol>
<p>Le point critique est le montage des stacks. Dockge lance les stacks en passant des chemins absolus au daemon Docker. Ces chemins doivent être identiques dans le container Dockge et sur le host. La solution :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">volumes</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#ae81ff">${PWD}/stacks:${PWD}/stacks</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">environment</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">DOCKGE_STACKS_DIR</span>: <span style="color:#ae81ff">${PWD}/stacks</span>
</span></span></code></pre></div><p><code>${PWD}</code> est une variable shell résolue au moment du <code>docker compose up</code>. Elle vaut le répertoire courant. Si on lance Dockge depuis <code>/home/user/homelab</code>, le dossier stacks sera monté à <code>/home/user/homelab/stacks</code> des deux côtés. C&rsquo;est la seule façon d&rsquo;éviter que Docker crée des répertoires fantômes au mauvais endroit.</p>
<p><strong>Conséquence pratique</strong> : toujours lancer <code>docker compose up -d</code> depuis la racine du repo.</p>
<p>La donnée persistante de Dockge (configuration, historique) est dans un volume nommé créé à l&rsquo;avance :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker volume create homelab_dockge_data
</span></span></code></pre></div><p>Un volume nommé survit à un <code>docker compose down -v</code>. Un volume anonyme serait détruit avec la stack.</p>
<hr>
<h2 id="3-mise-en-place-pas-à-pas">3. Mise en place pas à pas</h2>
<h3 id="étape-1--cloner-et-configurer">Étape 1 : cloner et configurer</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>git clone &lt;repo&gt; homelab
</span></span><span style="display:flex;"><span>cd homelab
</span></span></code></pre></div><p>Trouver l&rsquo;IP locale de la machine :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>hostname -I | awk <span style="color:#e6db74">&#39;{print $1}&#39;</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># ex: 192.168.1.10</span>
</span></span></code></pre></div><p>Créer et éditer le <code>.env</code> racine :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>cp .env.example .env
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Éditer .env :</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># IP=192.168.1.10</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># DOMAIN=sslip.io</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># COMPOSE_PROJECT_NAME=dockge  ← important, voir section conventions</span>
</span></span></code></pre></div><h3 id="étape-2--prérequis-docker">Étape 2 : prérequis Docker</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker network create traefik
</span></span><span style="display:flex;"><span>docker volume create homelab_dockge_data
</span></span></code></pre></div><h3 id="étape-3--démarrer-dockge">Étape 3 : démarrer Dockge</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>echo <span style="color:#e6db74">&#34;STACKS_DIR=</span><span style="color:#66d9ef">$(</span>pwd<span style="color:#66d9ef">)</span><span style="color:#e6db74">/stacks&#34;</span> &gt;&gt; .env
</span></span><span style="display:flex;"><span>docker compose up -d
</span></span></code></pre></div><p>Dockge est accessible sur <code>http://&lt;IP&gt;:5001</code>. Il est exposé directement sur le port 5001, pas via Traefik (Traefik n&rsquo;est pas encore démarré à ce stade). Créer un compte admin à la première ouverture.</p>
<h3 id="étape-4--configurer-les-stacks">Étape 4 : configurer les stacks</h3>
<p>Pour chaque dossier dans <code>stacks/</code>, copier le <code>.env.example</code> :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#66d9ef">for</span> stack in stacks/*/; <span style="color:#66d9ef">do</span>
</span></span><span style="display:flex;"><span>    cp <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>stack<span style="color:#e6db74">}</span><span style="color:#e6db74">.env.example&#34;</span> <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>stack<span style="color:#e6db74">}</span><span style="color:#e6db74">.env&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">done</span>
</span></span></code></pre></div><p>Puis éditer chaque <code>.env</code> pour renseigner <code>IP</code> et <code>DOMAIN</code> avec les mêmes valeurs qu&rsquo;à l&rsquo;étape 1. La valeur <code>COMPOSE_PROJECT_NAME</code> est pré-remplie avec le nom du dossier, ne pas la changer (voir section conventions).</p>
<p>Pour <code>filebrowser</code>, renseigner aussi <code>FILEBROWSER_ROOT</code> avec le chemin local à exposer.</p>
<h3 id="étape-5--lancer-les-stacks-depuis-dockge">Étape 5 : lancer les stacks depuis Dockge</h3>
<p>Depuis l&rsquo;interface Dockge (<code>http://&lt;IP&gt;:5001</code>), dans cet ordre :</p>
<p><strong>1. Traefik en premier</strong></p>
<p>Traefik doit être actif avant les autres services. Sans Traefik, les routes n&rsquo;existent pas et les services sont inaccessibles via leur URL.</p>
<p>Après démarrage, vérifier que Traefik est healthy :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker ps --filter name<span style="color:#f92672">=</span>traefik
</span></span></code></pre></div><p><strong>2. Les autres stacks dans n&rsquo;importe quel ordre</strong></p>
<p>Chaque stack se déclare automatiquement auprès de Traefik via ses labels Docker. Traefik découvre les nouveaux containers en temps réel.</p>
<p><strong>3. Homepage en dernier</strong></p>
<p>Homepage lit les labels Docker de tous les containers au démarrage pour construire le dashboard. Le démarrer en dernier garantit qu&rsquo;il découvre tous les services actifs dès le premier lancement.</p>
<hr>
<h2 id="4-ajouter-un-nouveau-service">4. Ajouter un nouveau service</h2>
<p>Voici le template de <code>compose.yaml</code> pour tout nouveau service :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">services</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">monservice</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">image</span>: <span style="color:#ae81ff">editeur/monservice:latest</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">restart</span>: <span style="color:#ae81ff">unless-stopped</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">healthcheck</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">test</span>: [<span style="color:#e6db74">&#34;CMD-SHELL&#34;</span>, <span style="color:#e6db74">&#34;wget -qO- http://127.0.0.1:&lt;PORT&gt;/ || exit 1&#34;</span>]
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">interval</span>: <span style="color:#ae81ff">30s</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">timeout</span>: <span style="color:#ae81ff">10s</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">retries</span>: <span style="color:#ae81ff">3</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">start_period</span>: <span style="color:#ae81ff">10s</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">labels</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#75715e"># Homepage - apparition automatique dans le dashboard</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">homepage.group</span>: <span style="color:#ae81ff">outils</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">homepage.name</span>: <span style="color:#ae81ff">Mon Service</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">homepage.icon</span>: <span style="color:#ae81ff">https://cdn.jsdelivr.net/gh/selfhst/icons/webp/monservice.webp</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">homepage.href</span>: <span style="color:#ae81ff">http://${COMPOSE_PROJECT_NAME}.${IP}.${DOMAIN}</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#75715e"># Traefik - routage HTTP</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">traefik.enable</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">traefik.http.routers.monservice.entrypoints</span>: <span style="color:#ae81ff">web</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">traefik.http.routers.monservice.rule</span>: <span style="color:#ae81ff">Host(`${COMPOSE_PROJECT_NAME}.${IP}.${DOMAIN}`)</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">traefik.http.services.monservice.loadbalancer.server.port</span>: <span style="color:#ae81ff">&lt;PORT&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">networks</span>:
</span></span><span style="display:flex;"><span>            - <span style="color:#ae81ff">traefik</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">networks</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">traefik</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">external</span>: <span style="color:#66d9ef">true</span>
</span></span></code></pre></div><p>Et le <code>.env.example</code> associé :</p>
<pre tabindex="0"><code>COMPOSE_PROJECT_NAME=monservice
IP=127.0.0.1
DOMAIN=sslip.io
</code></pre><p><strong>Le nom du dossier détermine le sous-domaine.</strong> Si le dossier s&rsquo;appelle <code>monservice</code>, le service sera accessible sur <code>monservice.&lt;IP&gt;.&lt;DOMAIN&gt;</code>. C&rsquo;est tout.</p>
<p>Pour trouver des services à ajouter, <a href="https://selfh.st" target="_blank" rel="noopener noreferrer">selfh.st</a>
 est une excellente ressource : c&rsquo;est un catalogue de logiciels self-hosted organisé par catégorie (media, sécurité, productivité, monitoring&hellip;), avec pour chacun une description, une capture d&rsquo;écran et le lien GitHub. Le site publie aussi une newsletter hebdomadaire sur les nouvelles releases.</p>
<h3 id="checklist-pour-un-nouveau-service">Checklist pour un nouveau service</h3>
<ul>
<li><input disabled="" type="checkbox"> Créer <code>stacks/&lt;nom-du-sous-domaine&gt;/compose.yaml</code></li>
<li><input disabled="" type="checkbox"> Créer <code>stacks/&lt;nom-du-sous-domaine&gt;/.env.example</code> avec <code>COMPOSE_PROJECT_NAME=&lt;nom&gt;</code></li>
<li><input disabled="" type="checkbox"> Copier <code>.env.example</code> en <code>.env</code> et renseigner IP/DOMAIN</li>
<li><input disabled="" type="checkbox"> Vérifier le port dans les labels Traefik</li>
<li><input disabled="" type="checkbox"> Choisir le groupe Homepage : <code>infra</code>, <code>observabilité</code>, ou <code>outils</code></li>
<li><input disabled="" type="checkbox"> Trouver l&rsquo;icône sur <a href="https://github.com/selfhst/icons" target="_blank" rel="noopener noreferrer">selfhst/icons</a>
</li>
<li><input disabled="" type="checkbox"> Ajouter les données persistantes dans un volume si nécessaire</li>
<li><input disabled="" type="checkbox"> Lancer depuis Dockge et vérifier que le container est <code>healthy</code></li>
</ul>
<hr>
<h2 id="5-patterns-et-conventions">5. Patterns et conventions</h2>
<h3 id="la-variable-compose_project_name">La variable <code>${COMPOSE_PROJECT_NAME}</code></h3>
<p>Docker Compose valorise automatiquement <code>COMPOSE_PROJECT_NAME</code> avec le nom du dossier du stack. On l&rsquo;utilise pour construire dynamiquement les URLs :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">traefik.http.routers.dozzle.rule</span>: <span style="color:#ae81ff">Host(`${COMPOSE_PROJECT_NAME}.${IP}.${DOMAIN}`)</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">homepage.href</span>: <span style="color:#ae81ff">http://${COMPOSE_PROJECT_NAME}.${IP}.${DOMAIN}</span>
</span></span></code></pre></div><p>Avantage : pas de variable <code>*_HOST</code> à maintenir dans chaque <code>.env</code>. Renommer le dossier change automatiquement le sous-domaine.</p>
<p><strong>Attention</strong> : dans le <code>.env</code>, il faut définir <code>COMPOSE_PROJECT_NAME</code> explicitement avec le nom du dossier du stack. Si on ne le définit pas, Docker Compose utilise le nom du répertoire courant au moment du lancement, ce qui peut donner des valeurs inattendues selon d&rsquo;où on lance la commande.</p>
<h3 id="les-groupes-homepage">Les groupes Homepage</h3>
<p>Les services sont organisés en trois groupes dans le dashboard :</p>
<table>
  <thead>
      <tr>
          <th>Groupe</th>
          <th>Services</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>infra</code></td>
          <td><a href="https://github.com/traefik/traefik" target="_blank" rel="noopener noreferrer">Traefik</a>
, <a href="https://github.com/louislam/dockge" target="_blank" rel="noopener noreferrer">Dockge</a>
, <a href="https://github.com/containrrr/watchtower" target="_blank" rel="noopener noreferrer">Watchtower</a>
, <a href="https://github.com/gethomepage/homepage" target="_blank" rel="noopener noreferrer">Homepage</a>
</td>
      </tr>
      <tr>
          <td><code>observabilité</code></td>
          <td><a href="https://github.com/amir20/dozzle" target="_blank" rel="noopener noreferrer">Dozzle</a>
, <a href="https://github.com/nicolargo/glances" target="_blank" rel="noopener noreferrer">Glances</a>
, <a href="https://github.com/louislam/uptime-kuma" target="_blank" rel="noopener noreferrer">Uptime Kuma</a>
</td>
      </tr>
      <tr>
          <td><code>outils</code></td>
          <td><a href="https://github.com/gtsteffaniak/filebrowser" target="_blank" rel="noopener noreferrer">FileBrowser</a>
, <a href="https://github.com/CorentinTh/it-tools" target="_blank" rel="noopener noreferrer">IT-Tools</a>
, <a href="https://github.com/Stirling-Tools/Stirling-PDF" target="_blank" rel="noopener noreferrer">Stirling PDF</a>
</td>
      </tr>
  </tbody>
</table>
<p>Ce découpage est celui de ce homelab, pas une convention imposée. Homepage accepte n&rsquo;importe quelle valeur dans <code>homepage.group</code> : on peut créer autant de groupes que nécessaire et les nommer comme on veut (<code>media</code>, <code>domotique</code>, <code>dev</code>&hellip;). Le dashboard se réorganise automatiquement.</p>
<h3 id="health-checks">Health checks</h3>
<p>Tous les services ont un health check. C&rsquo;est crucial car <strong>Traefik ignore silencieusement les containers <code>unhealthy</code></strong> : un service avec un health check défaillant n&rsquo;apparaît pas dans le routage, même avec <code>traefik.enable: true</code>.</p>
<p>Trois cas particuliers rencontrés en pratique :</p>
<p><strong>1. <code>localhost</code> ne résout pas toujours en <code>127.0.0.1</code></strong></p>
<p>Dans certaines images minimalistes, <code>localhost</code> n&rsquo;est pas résolu. Utiliser <code>127.0.0.1</code> explicitement :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">test</span>: [<span style="color:#e6db74">&#34;CMD-SHELL&#34;</span>, <span style="color:#e6db74">&#34;wget -qO- http://127.0.0.1:8080/ || exit 1&#34;</span>]
</span></span></code></pre></div><p><strong>2. Images sans shell (<code>scratch</code>-based)</strong></p>
<p>Les images basées sur <code>scratch</code> (ex: Dozzle) ne contiennent pas <code>/bin/sh</code>. <code>CMD-SHELL</code> échoue. Utiliser le binaire embarqué :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">test</span>: [<span style="color:#e6db74">&#34;CMD&#34;</span>, <span style="color:#e6db74">&#34;/dozzle&#34;</span>, <span style="color:#e6db74">&#34;healthcheck&#34;</span>]
</span></span></code></pre></div><p><strong>3. Images sans <code>wget</code> ni <code>curl</code></strong></p>
<p>Certaines images Node.js ou JVM n&rsquo;ont ni wget ni curl. Solutions possibles :</p>
<ul>
<li>Si Node.js est disponible : <code>node -e &quot;require('http').get('http://localhost:PORT', r =&gt; process.exit(r.statusCode &lt; 400 ? 0 : 1)).on('error', () =&gt; process.exit(1))&quot;</code></li>
<li>Si curl est disponible : <code>curl -fs http://127.0.0.1:PORT/</code></li>
<li>Si le binaire de l&rsquo;app expose une sous-commande healthcheck : l&rsquo;utiliser directement.</li>
</ul>
<h3 id="persistance-des-données">Persistance des données</h3>
<p>Pour les services qui ont des données (configuration, base utilisateurs, base de données) :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">volumes</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#ae81ff">./docker/data:/chemin/dans/container</span>
</span></span></code></pre></div><p>Le dossier <code>./docker/</code> est dans le dossier du stack et peut être versionné, à l&rsquo;exception des données runtime qui vont dans <code>.gitignore</code>.</p>
<p><strong>Règle</strong> : ajouter <code>stacks/&lt;service&gt;/docker/</code> dans <code>.gitignore</code> si le dossier contient des données qui ne doivent pas être committées (base SQLite, uploads&hellip;).</p>
<h3 id="organisation-des-labels-traefik">Organisation des labels Traefik</h3>
<p>Par convention, le nom utilisé dans les labels Traefik (<code>traefik.http.routers.&lt;nom&gt;</code>) correspond au nom du service Docker dans le <code>compose.yaml</code>. En pratique on les aligne avec le nom du dossier :</p>
<pre tabindex="0"><code>stacks/it-tools/    →    service: ittools    →    traefik.http.routers.ittools.*
</code></pre><p>Ce n&rsquo;est pas une contrainte technique de Traefik, juste une convention de lisibilité.</p>
<hr>
<h2 id="6-pièges-courants">6. Pièges courants</h2>
<h3 id="dockge--stop-puis-start-pas-restart">Dockge : Stop puis Start, pas Restart</h3>
<p>Quand on modifie un <code>compose.yaml</code> depuis l&rsquo;IDE et qu&rsquo;on veut appliquer les changements, il faut faire <strong>Stop + Start</strong> depuis Dockge, pas &ldquo;Restart&rdquo;. Le Restart redémarre le container existant sans relire le <code>compose.yaml</code>. Le Stop + Start recrée le container avec la nouvelle configuration.</p>
<h3 id="labels-modifiés--redémarrer-homepage">Labels modifiés : redémarrer Homepage</h3>
<p>Homepage lit les labels Docker <strong>au démarrage</strong>. Si on change le <code>homepage.group</code> ou <code>homepage.name</code> d&rsquo;un service, Homepage ne le voit pas tant qu&rsquo;il n&rsquo;est pas redémarré.</p>
<h3 id="le-container-démarre-mais-nest-pas-routable">Le container démarre mais n&rsquo;est pas routable</h3>
<p>Vérifier dans l&rsquo;ordre :</p>
<ol>
<li><code>docker ps</code> : le container est-il <code>healthy</code> ? Traefik ignore les containers <code>unhealthy</code>.</li>
<li>Le container est-il sur le réseau <code>traefik</code> ?</li>
</ol>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker inspect &lt;container&gt; --format <span style="color:#e6db74">&#39;{{json .NetworkSettings.Networks}}&#39;</span>
</span></span></code></pre></div><ol start="3">
<li>Le label <code>traefik.enable: true</code> est-il présent ?</li>
<li>La règle <code>Host(...)</code> correspond-elle à l&rsquo;URL testée ?</li>
</ol>
<h3 id="montage-de-fichiers-inexistants-sous-docker-desktop--wsl">Montage de fichiers inexistants sous Docker Desktop / WSL</h3>
<p>Quand Docker Desktop (WSL) monte un <strong>fichier</strong> qui n&rsquo;existe pas encore sur le host, il crée un <strong>répertoire</strong> à la place. Ce répertoire fantôme bloque ensuite le montage du vrai fichier. Symptôme : le container refuse de démarrer avec une erreur de montage.</p>
<p>Solution : s&rsquo;assurer que le fichier existe sur le host avant de démarrer le container, ou utiliser un montage de répertoire plutôt que de fichier.</p>
<h3 id="watchtower--api-docker-trop-ancienne">Watchtower : API Docker trop ancienne</h3>
<p>Sur certaines configurations, Watchtower tente de communiquer avec le daemon en commençant la négociation à l&rsquo;API v1.25 (son minimum historique). Les versions récentes de Docker refusent cette version. Symptôme : le container redémarre en boucle avec <code>client version 1.25 is too old. Minimum supported API version is 1.40</code>.</p>
<p>Fix dans le <code>compose.yaml</code> de Watchtower :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">environment</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">DOCKER_API_VERSION</span>: <span style="color:#e6db74">&#34;1.40&#34;</span>
</span></span></code></pre></div><p><code>1.40</code> est la valeur à mettre, quelle que soit ta version de Docker. Ce n&rsquo;est pas ta version exacte, c&rsquo;est le minimum que le daemon accepte, indiqué dans le message d&rsquo;erreur. Pour vérifier la version d&rsquo;API réelle de ton daemon :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker version --format <span style="color:#e6db74">&#39;{{.Server.APIVersion}}&#39;</span>
</span></span></code></pre></div><h3 id="pwd-dans-le-compose-de-dockge"><code>${PWD}</code> dans le compose de Dockge</h3>
<p><code>${PWD}</code> n&rsquo;est pas une variable <code>.env</code>, c&rsquo;est une variable shell résolue au moment du <code>docker compose up</code>. Elle vaut le répertoire courant du terminal. Lancer <code>docker compose up -d</code> depuis n&rsquo;importe quel autre répertoire donnera une mauvaise valeur et cassera les montages de volumes des stacks.</p>
<hr>
<p><em>Ce homelab est conçu pour tourner sur une machine Linux ou WSL. Toutes les commandes sont testées sur Ubuntu/WSL2 avec Docker Desktop.</em></p>
<hr>
<h2 id="conclusion">Conclusion</h2>
<p>J&rsquo;ai bien conscience que ce tuto ne couvre pas tout. On aurait pu ajouter de l&rsquo;authentification devant chaque service, faire tourner l&rsquo;ensemble en HTTPS, mettre en place un socket proxy pour limiter l&rsquo;exposition du daemon Docker, ou épingler précisément chaque version d&rsquo;image. Mais chacun de ces points aurait considérablement allongé l&rsquo;article et la complexité de mise en place. L&rsquo;objectif était de démarrer avec quelque chose de fonctionnel et maintenable, pas de construire une forteresse dès le premier jour.</p>
<p>Le homelab parfait n&rsquo;existe pas. Celui qui tourne, si.</p>
<div style="border: 1px solid #e8e8e8; padding: 16px; margin-top: 2em; border-radius: 3px;">
  <img src="https://cdn.simpleicons.org/github" width="20" style="vertical-align: middle; margin-right: 8px;" />
  <strong><a href="https://github.com/guillaumedelre/homelab" target="_blank" rel="noopener noreferrer">guillaumedelre/homelab</a></strong>
  <p style="margin: 8px 0 0; color: #828282; font-size: 14px;">Homelab Docker Compose avec Traefik — stacks indépendants, dashboard auto-configuré, et zéro configuration DNS grâce à sslip.io.</p>
</div>
<h2 id="références">Références</h2>
<table>
  <thead>
      <tr>
          <th>Projet</th>
          <th>GitHub</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>sslip.io</td>
          <td><a href="https://sslip.io" target="_blank" rel="noopener noreferrer">sslip.io</a>
</td>
      </tr>
      <tr>
          <td>selfh.st</td>
          <td><a href="https://selfh.st" target="_blank" rel="noopener noreferrer">selfh.st</a>
</td>
      </tr>
      <tr>
          <td>Traefik</td>
          <td><a href="https://github.com/traefik/traefik" target="_blank" rel="noopener noreferrer">github.com/traefik/traefik</a>
</td>
      </tr>
      <tr>
          <td>Dockge</td>
          <td><a href="https://github.com/louislam/dockge" target="_blank" rel="noopener noreferrer">github.com/louislam/dockge</a>
</td>
      </tr>
      <tr>
          <td>Homepage</td>
          <td><a href="https://github.com/gethomepage/homepage" target="_blank" rel="noopener noreferrer">github.com/gethomepage/homepage</a>
</td>
      </tr>
      <tr>
          <td>Dozzle</td>
          <td><a href="https://github.com/amir20/dozzle" target="_blank" rel="noopener noreferrer">github.com/amir20/dozzle</a>
</td>
      </tr>
      <tr>
          <td>Glances</td>
          <td><a href="https://github.com/nicolargo/glances" target="_blank" rel="noopener noreferrer">github.com/nicolargo/glances</a>
</td>
      </tr>
      <tr>
          <td>FileBrowser</td>
          <td><a href="https://github.com/gtsteffaniak/filebrowser" target="_blank" rel="noopener noreferrer">github.com/gtsteffaniak/filebrowser</a>
</td>
      </tr>
      <tr>
          <td>IT-Tools</td>
          <td><a href="https://github.com/CorentinTh/it-tools" target="_blank" rel="noopener noreferrer">github.com/CorentinTh/it-tools</a>
</td>
      </tr>
      <tr>
          <td>Stirling PDF</td>
          <td><a href="https://github.com/Stirling-Tools/Stirling-PDF" target="_blank" rel="noopener noreferrer">github.com/Stirling-Tools/Stirling-PDF</a>
</td>
      </tr>
      <tr>
          <td>Uptime Kuma</td>
          <td><a href="https://github.com/louislam/uptime-kuma" target="_blank" rel="noopener noreferrer">github.com/louislam/uptime-kuma</a>
</td>
      </tr>
      <tr>
          <td>Watchtower</td>
          <td><a href="https://github.com/containrrr/watchtower" target="_blank" rel="noopener noreferrer">github.com/containrrr/watchtower</a>
</td>
      </tr>
      <tr>
          <td>selfhst/icons</td>
          <td><a href="https://github.com/selfhst/icons" target="_blank" rel="noopener noreferrer">github.com/selfhst/icons</a>
</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>HTTPS local avec Traefik: traefik.me est mort, vive sslip.io</title><link>https://guillaumedelre.github.io/fr/2025/04/17/https-local-avec-traefik-traefik.me-est-mort-vive-sslip.io/</link><pubDate>Thu, 17 Apr 2025 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/fr/2025/04/17/https-local-avec-traefik-traefik.me-est-mort-vive-sslip.io/</guid><description>Le certificat wildcard de traefik.me a été révoqué en 2025. Voici comment le remplacer avec sslip.io, mkcert et une configuration Traefik locale.</description><content:encoded><![CDATA[<p>La configuration semblait parfaite. Pointer <code>*.traefik.me</code> sur 127.0.0.1, télécharger un certificat wildcard depuis le même domaine, le déposer dans Traefik, et chaque service local obtient une URL HTTPS propre sans IP dans la barre d&rsquo;adresse. Pas de limites de débit Let&rsquo;s Encrypt, pas de <code>mkcert</code> à expliquer aux collègues, pas d&rsquo;avertissements de certificat auto-signé à contourner. Juste <code>https://myapp.traefik.me</code> et un cadenas vert.</p>
<p>Puis en mars 2025, Let&rsquo;s Encrypt a révoqué le certificat. Le wildcard cert pour traefik.me est parti et il ne reviendra pas.</p>
<h2 id="ce-que-traefikme-vendait-vraiment">Ce que traefik.me vendait vraiment</h2>
<p>traefik.me est un résolveur DNS wildcard. Tapez <code>anything.traefik.me</code> et ça résout vers 127.0.0.1. Tapez <code>anything.10.0.0.1.traefik.me</code> et ça résout vers 10.0.0.1. Aucun compte, aucune configuration, aucune infrastructure à maintenir. La partie DNS fonctionne toujours bien, soit dit en passant.</p>
<p>Le certificat était le bonus: un wildcard cert pour <code>*.traefik.me</code> que pyrou, le mainteneur, avait généré avec Let&rsquo;s Encrypt et distribué sur <code>https://traefik.me/cert.pem</code> et <code>https://traefik.me/privkey.pem</code>. C&rsquo;était pratique précisément parce que c&rsquo;était partagé: télécharger, déposer dans Traefik, terminé.</p>
<p>Partager une clé privée, c&rsquo;est ce qui l&rsquo;a tué.</p>
<p>Les Baseline Requirements du CA/Browser Forum, section 9.6.3, exigent que les souscripteurs &ldquo;maintiennent le contrôle exclusif&rdquo; de leur clé privée. La distribuer à quiconque visite une URL, c&rsquo;est exactement le contraire du contrôle exclusif. Let&rsquo;s Encrypt a envoyé une notification, bloqué toute future émission pour le domaine, et révoqué le certificat existant. Pyrou a confirmé la situation et recommandé mkcert comme alternative. Le projet survivra uniquement en tant que résolveur DNS.</p>
<p>Le cert avait déjà été révoqué deux fois avant 2025. La troisième était la dernière.</p>
<h2 id="sslipio-fait-la-même-chose-différemment">sslip.io fait la même chose, différemment</h2>
<p>sslip.io est aussi un résolveur DNS wildcard, avec une différence: l&rsquo;IP est encodée dans le hostname plutôt que résolue depuis un fallback. <code>10-0-0-1.sslip.io</code> résout vers <code>10.0.0.1</code>. <code>myapp.192-168-1-10.sslip.io</code> résout vers <code>192.168.1.10</code>. IPv6 fonctionne aussi.</p>
<p>L&rsquo;infrastructure derrière sslip.io est aussi plus visible: trois serveurs de noms à Singapour, aux États-Unis et en Pologne, traitant plus de 10 000 requêtes par seconde, avec un monitoring public. Environ 1 000 étoiles GitHub et une maintenance active sous licence Apache 2.0.</p>
<p>En mettant de côté l&rsquo;histoire des certificats, la comparaison est assez directe:</p>
<table>
  <thead>
      <tr>
          <th></th>
          <th>traefik.me</th>
          <th>sslip.io</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>DNS wildcard</td>
          <td>oui</td>
          <td>oui</td>
      </tr>
      <tr>
          <td>Fallback vers 127.0.0.1</td>
          <td>oui</td>
          <td>non</td>
      </tr>
      <tr>
          <td>IPv6</td>
          <td>non</td>
          <td>oui</td>
      </tr>
      <tr>
          <td>Certificat wildcard</td>
          <td><del>oui</del> révoqué</td>
          <td>non</td>
      </tr>
      <tr>
          <td>Infrastructure</td>
          <td>opaque</td>
          <td>documentée</td>
      </tr>
      <tr>
          <td>Activité du projet</td>
          <td>au point mort</td>
          <td>active</td>
      </tr>
  </tbody>
</table>
<p>Le seul avantage restant de traefik.me est le fallback vers 127.0.0.1: des URLs sans segment IP. Ça compte si on tient vraiment à <code>myapp.traefik.me</code> plutôt que <code>myapp.127-0-0-1.sslip.io</code>. La question est de savoir si cette différence vaut l&rsquo;incertitude sur l&rsquo;infrastructure.</p>
<h2 id="mkcert-comble-le-vide">mkcert comble le vide</h2>
<p>mkcert crée une autorité de certification locale, l&rsquo;installe dans le trust store système et dans les navigateurs qu&rsquo;il trouve, puis émet des certificats signés par cette CA. Les navigateurs voient une chaîne de confiance valide. Aucun avertissement, aucun clic, aucun &ldquo;continuer quand même&rdquo;.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkcert -install
</span></span></code></pre></div><p>C&rsquo;est la configuration unique. Ensuite, générer un certificat se fait en une commande:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkcert <span style="color:#e6db74">&#34;*.127-0-0-1.sslip.io&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># produit _wildcard.127-0-0-1.sslip.io.pem</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#         _wildcard.127-0-0-1.sslip.io-key.pem</span>
</span></span></code></pre></div><p>La limitation: la CA de mkcert est locale. Les autres machines du réseau ne lui feront pas confiance par défaut. Pour un setup solo, c&rsquo;est très bien. Pour un environnement d&rsquo;équipe partagé, il faudrait distribuer la CA root, ce qui est essentiellement le même problème opérationnel que traefik.me tentait d&rsquo;éviter, juste à plus petite échelle.</p>
<h2 id="la-configuration-traefik">La configuration Traefik</h2>
<p>Le setup est le même quelle que soit la solution DNS choisie. Traefik a besoin du certificat monté en volume et d&rsquo;un file provider statique pointant vers un fichier de configuration TLS.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># traefik/config/tls.yml</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">tls</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">certificates</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">certFile</span>: <span style="color:#ae81ff">/certs/cert.pem</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">keyFile</span>: <span style="color:#ae81ff">/certs/key.pem</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">stores</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">default</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">defaultCertificate</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">certFile</span>: <span style="color:#ae81ff">/certs/cert.pem</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">keyFile</span>: <span style="color:#ae81ff">/certs/key.pem</span>
</span></span></code></pre></div><p>La bonne pratique: faire tourner Traefik dans son propre projet Compose, séparé des services qu&rsquo;il route. Chaque projet de service se connecte à Traefik via un réseau externe partagé. On démarre et arrête les services indépendamment sans toucher au reverse proxy.</p>
<p>On commence par créer le réseau externe une seule fois:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker network create traefik-public
</span></span></code></pre></div><p><strong><code>traefik/compose.yml</code></strong> - Traefik seul, propriétaire du réseau:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">services</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">traefik</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">image</span>: <span style="color:#ae81ff">traefik:v3</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">ports</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#e6db74">&#34;80:80&#34;</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#e6db74">&#34;443:443&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">volumes</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">/var/run/docker.sock:/var/run/docker.sock</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">./config:/etc/traefik/config</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">./certs:/certs</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">command</span>:
</span></span><span style="display:flex;"><span>      - --<span style="color:#ae81ff">entrypoints.web.address=:80</span>
</span></span><span style="display:flex;"><span>      - --<span style="color:#ae81ff">entrypoints.websecure.address=:443</span>
</span></span><span style="display:flex;"><span>      - --<span style="color:#ae81ff">providers.docker=true</span>
</span></span><span style="display:flex;"><span>      - --<span style="color:#ae81ff">providers.docker.network=traefik-public</span>
</span></span><span style="display:flex;"><span>      - --<span style="color:#ae81ff">providers.file.directory=/etc/traefik/config</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">networks</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">traefik-public</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">networks</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">traefik-public</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">external</span>: <span style="color:#66d9ef">true</span>
</span></span></code></pre></div><p>On copie la sortie de mkcert dans <code>./certs/</code>, on renomme en <code>cert.pem</code> et <code>key.pem</code>, puis:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker compose -f traefik/compose.yml up -d
</span></span></code></pre></div><p>Traefik est lancé, il écoute sur le port 80 et 443, et surveille Docker pour les nouveaux containers. Aucune route n&rsquo;est encore configurée.</p>
<p><strong><code>whoami/compose.yml</code></strong> - un service qui rejoint le même réseau:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">services</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">whoami</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">image</span>: <span style="color:#ae81ff">traefik/whoami</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">labels</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#e6db74">&#34;traefik.enable=true&#34;</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#e6db74">&#34;traefik.http.routers.whoami.rule=Host(`whoami.127-0-0-1.sslip.io`)&#34;</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#e6db74">&#34;traefik.http.routers.whoami.tls=true&#34;</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#e6db74">&#34;traefik.http.routers.whoami.entrypoints=websecure&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">networks</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">traefik-public</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">networks</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">traefik-public</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">external</span>: <span style="color:#66d9ef">true</span>
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker compose -f whoami/compose.yml up -d
</span></span></code></pre></div><p>Traefik détecte le nouveau container via le Docker provider, lit ses labels, et ajoute la route. <code>https://whoami.127-0-0-1.sslip.io</code> répond immédiatement. Arrêter <code>whoami</code> et la route disparaît. Traefik continue de tourner sans s&rsquo;en apercevoir.</p>
<p>La déclaration <code>external: true</code> est la ligne qui porte tout le poids. Sans elle, Compose crée un réseau limité au périmètre du projet: Traefik et <code>whoami</code> se retrouvent sur des réseaux différents et ne peuvent pas communiquer, même s&rsquo;ils tournent tous les deux. Le réseau externe est le bus partagé auquel chaque projet de service doit explicitement adhérer.</p>
<p>Si on préfère les URLs traefik.me, il suffit de remplacer la commande mkcert et le label de host:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkcert <span style="color:#e6db74">&#34;*.traefik.me&#34;</span>
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span>- <span style="color:#e6db74">&#34;traefik.http.routers.whoami.rule=Host(`whoami.traefik.me`)&#34;</span>
</span></span></code></pre></div><p>Le fallback DNS vers 127.0.0.1 gère le reste.</p>
<h2 id="ce-que-lhistoire-traefikme-enseigne-vraiment">Ce que l&rsquo;histoire traefik.me enseigne vraiment</h2>
<p>Le modèle de distribution de certificats a toujours été fragile. Une &ldquo;paire clé publique-clé privée&rdquo; est une contradiction dans les termes. Chaque révocation était un avertissement que la suivante pourrait être définitive. Finalement, ça l&rsquo;a été.</p>
<p>La leçon ne se limite pas à traefik.me. Tout service qui apporte de la commodité en supprimant discrètement une frontière de sécurité finira par se heurter à cette frontière. mkcert est le bon outil pour ce problème parce qu&rsquo;il opère entièrement dans votre propre domaine de confiance: on génère la CA, on l&rsquo;installe, on émet les certificats. Rien ne dépend de la volonté continue d&rsquo;un tiers de contourner les règles d&rsquo;émission de certificats.</p>
<p>sslip.io résout proprement la partie DNS. mkcert résout proprement la partie TLS. Ils se combinent bien. Le setup traefik.me était plus simple, pendant un temps. Jusqu&rsquo;à ce que ce ne soit plus le cas.</p>
]]></content:encoded></item><item><title>De Vagrant à Docker Compose : une rétrospective</title><link>https://guillaumedelre.github.io/fr/2022/04/18/de-vagrant-%C3%A0-docker-compose-une-r%C3%A9trospective/</link><pubDate>Mon, 18 Apr 2022 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/fr/2022/04/18/de-vagrant-%C3%A0-docker-compose-une-r%C3%A9trospective/</guid><description>Pourquoi on a remplacé Vagrant par Docker Compose : les vrais points de friction, le chemin de migration, et ce qu&amp;#39;on ferait différemment.</description><content:encoded><![CDATA[<p>J&rsquo;ai utilisé Vagrant pendant des années. Un Vagrantfile par projet, une box de base partagée, un script de provision qui marchait le mardi mais pas le jeudi. La promesse était simple : des environnements reproductibles pour tout le monde dans l&rsquo;équipe. La réalité était plus compliquée.</p>
<h2 id="les-années-vagrant">Les années Vagrant</h2>
<p>Le setup avait du sens à l&rsquo;époque. Une VM par projet, provisionnée avec des scripts shell ou Ansible, partagée via un Vagrantfile versionné. L&rsquo;onboarding était théoriquement <code>vagrant up</code> et c&rsquo;est terminé.</p>
<p>En pratique, c&rsquo;était <code>vagrant up</code>, attendre quatre minutes, regarder la provision échouer sur un package qui avait changé son URL de téléchargement, corriger, reprovisionner, attendre à nouveau. Les Vagrantfiles accumulaient de la configuration au fil du temps : des contournements pour des machines spécifiques, du pinning de version d&rsquo;OS, des ajustements mémoire pour le membre de l&rsquo;équipe dont le laptop n&rsquo;avait que 8 Go. Les fichiers devenaient des documents historiques que personne ne voulait toucher.</p>
<p>La VM elle-même était l&rsquo;autre problème. Le boot prenait du temps. Faire tourner la VM consommait de la mémoire et du CPU qui auraient pu aller à l&rsquo;application. La synchronisation des fichiers entre host et guest ajoutait une latence qui faisait paraître les applications PHP plus lentes qu&rsquo;elles n&rsquo;avaient le droit d&rsquo;être. L&rsquo;overhead était significatif pour ce qui était finalement juste &ldquo;faire tourner un serveur web&rdquo;.</p>
<p>On vivait avec parce que tout le monde le faisait. Vagrant était le standard pour le développement PHP local, et l&rsquo;alternative (chaque développeur gérant son propre stack LAMP) était clairement pire.</p>
<h2 id="le-projet-qui-a-changé-le-modèle">Le projet qui a changé le modèle</h2>
<p>Le changement n&rsquo;était pas une décision qu&rsquo;on a prise. C&rsquo;était un projet qui est arrivé déjà conteneurisé.</p>
<p>Un nouveau projet client avait un <code>docker-compose.yml</code> à la racine, un <code>Dockerfile</code>, et un README qui disait <code>docker compose up</code>. On l&rsquo;a lancé. Les conteneurs ont démarré en secondes. PHP-FPM, nginx, PostgreSQL, Redis : tout tournait, tout était en réseau, pas d&rsquo;étape de provision. Arrêter les conteneurs, les redémarrer, même état.</p>
<p>Le contraste avec notre setup Vagrant était immédiat. Pas plus rapide d&rsquo;un pourcentage : plus rapide d&rsquo;un ordre de grandeur différent. Et le fichier Compose était réellement lisible : chaque service, son image, ses volumes, ses variables d&rsquo;environnement, ses dépendances. Comparé à un script de provision qui SSH-ait dans une VM et lançait apt-get, c&rsquo;était lisible.</p>
<p>On a tout migré. Pas progressivement, tout à la fois, sur un sprint. Chaque projet a reçu un <code>docker-compose.yml</code>. Chaque Vagrantfile a été supprimé. La transition a été les trois semaines de travail d&rsquo;infrastructure les plus douloureuses dont je me souvienne, et aussi les plus clairement valables.</p>
<h2 id="ce-que-docker-compose-a-vraiment-changé">Ce que docker-compose a vraiment changé</h2>
<p>Au-delà de la vitesse, Compose a changé le modèle mental. Vagrant abstrayait une machine. Compose abstrayait un ensemble de processus. La distinction compte : avec Compose, on peut arrêter la base de données sans arrêter le serveur d&rsquo;application, scaler un service worker indépendamment, échanger l&rsquo;image PostgreSQL contre une version plus récente sans toucher à quoi que ce soit d&rsquo;autre.</p>
<p>La déclaration <code>services</code> a aussi entièrement remplacé le problème de provision des VMs. Si un nouveau développeur rejoint l&rsquo;équipe, il ne lance pas un script de provision qui peut ou ne pas fonctionner sur sa version d&rsquo;OS. Il lance <code>docker compose up</code> et obtient exactement les mêmes images que tout le monde.</p>
<p>Le CI/CD est devenu plus simple aussi. Le même <code>docker-compose.yml</code> qui tournait en local pouvait tourner dans le pipeline. La parité d&rsquo;environnement que Vagrant promettait mais livrait rarement était réellement réelle avec Compose.</p>
<h2 id="la-dépréciation-silencieuse">La dépréciation silencieuse</h2>
<p>Pendant des années, la commande était <code>docker-compose</code> : un binaire séparé, installé indépendamment de Docker lui-même, écrit en Python, versionné indépendamment. On l&rsquo;utilisait, ça marchait, personne n&rsquo;y pensait vraiment.</p>
<p>À un moment, un collègue a mentionné que Docker avait intégré Compose directement dans le CLI <code>docker</code>. La nouvelle commande était <code>docker compose</code>, sans tiret, réécriture en Go, intégré avec Docker Desktop. L&rsquo;ancien binaire <code>docker-compose</code> était déprécié.</p>
<p>On avait utilisé v1 pendant deux ans après que v2 était sortie. Nos scripts CI, nos Makefiles, notre documentation disaient tous <code>docker-compose</code>. Rien n&rsquo;avait cassé parce que Docker avait maintenu l&rsquo;ancien binaire longtemps. Mais l&rsquo;écosystème avait évolué silencieusement, et on l&rsquo;avait raté.</p>
<p>La migration était triviale : un tiret retiré de chaque script, quelques alias mis à jour. La leçon était moins triviale. Les outils d&rsquo;infrastructure évoluent sans cérémonie. L&rsquo;annonce avait eu lieu, les articles de blog avaient été écrits, les notices de dépréciation étaient là. On ne faisait juste pas attention.</p>
<h2 id="la-vraie-rétrospective">La vraie rétrospective</h2>
<p>En regardant en arrière à travers Vagrant → <code>docker-compose</code> → <code>docker compose</code>, le pattern concerne moins les outils que les defaults.</p>
<p>Vagrant defaultait à &ldquo;ça marche sur ma VM&rdquo;. L&rsquo;overhead de partager cette VM était permanent.</p>
<p>Compose defaultait à &ldquo;ça marche dans ces conteneurs&rdquo;. Les images sont les artefacts ; la machine host est hors sujet.</p>
<p>Le tiret entre <code>docker</code> et <code>compose</code> a toujours été cosmétique. Ce qui comptait, c&rsquo;était le passage des machines provisionnées aux services déclaratifs. Ce passage a eu lieu le jour où on a lancé un projet que quelqu&rsquo;un d&rsquo;autre avait conteneurisé et où on a réalisé qu&rsquo;on ne voulait jamais revenir en arrière.</p>
]]></content:encoded></item><item><title>Contrôler un lance-missiles USB en HTTP avec FastAPI et Docker</title><link>https://guillaumedelre.github.io/fr/2017/02/21/contr%C3%B4ler-un-lance-missiles-usb-en-http-avec-fastapi-et-docker/</link><pubDate>Tue, 21 Feb 2017 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/fr/2017/02/21/contr%C3%B4ler-un-lance-missiles-usb-en-http-avec-fastapi-et-docker/</guid><description>Comment on a branché un lance-missiles USB en mousse sur le pipeline CI — et ce que Docker, udev et WSL2 avaient à dire là-dessus.</description><content:encoded><![CDATA[<p>La règle était simple : celui qui casse le build CI offre le café à l&rsquo;équipe. Ça a marché un moment. Puis quelqu&rsquo;un a proposé qu&rsquo;on ait un retour plus immédiat. Quelque chose de physique. Quelque chose qui tire.</p>
<p>Un <a href="http://www.dreamcheeky.com/thunder-missile-launcher" target="_blank" rel="noopener noreferrer">Dream Cheeky Thunder</a> a atterri sur un bureau peu après. Quatre missiles en mousse, un câble USB, et un consensus d&rsquo;équipe très clair : le brancher au cluster, le câbler au pipeline de build, et laisser le CI décider qui mérite une volée.</p>
<p>Le lanceur devait répondre à des appels HTTP depuis n&rsquo;importe où sur le réseau. Sans driver, sans GUI, sans visée manuelle. Juste un endpoint qui le fait tirer dans la direction du bureau du coupable.</p>
<p>Voilà l&rsquo;histoire de <a href="https://github.com/guillaumedelre/dream-cheeky-thunder" target="_blank" rel="noopener noreferrer">dream-cheeky-thunder</a>.</p>
<p><img alt="Dream Cheeky Thunder" loading="lazy" src="https://raw.githubusercontent.com/guillaumedelre/dream-cheeky-thunder/develop/docs/Dream-Cheeky-Thunder.jpg"></p>
<h2 id="pas-de-sdk-pas-de-docs-pas-de-problème">Pas de SDK, pas de docs, pas de problème</h2>
<p>Dream Cheeky n&rsquo;a jamais publié de spec de protocole. Le lanceur parle USB HID brut, et le seul point de départ était un script Python vendorisé de 2012 qui traînait dans des fils de forum. Vendor ID <code>0x2123</code>, product ID <code>0x1010</code>, et une poignée d&rsquo;octets de contrôle que quelqu&rsquo;un avait rétro-ingénié des années auparavant.</p>
<p>C&rsquo;était suffisant. Le protocole est simple : envoyer une séquence d&rsquo;octets pour bouger les moteurs, en envoyer une autre pour tirer. La partie délicate : le lanceur n&rsquo;a aucun retour de position. Pas d&rsquo;encodeurs, pas de fins de course en dehors des butées physiques aux extrémités. On le pilote à l&rsquo;aveugle.</p>
<h2 id="du-usb-au-http">Du USB au HTTP</h2>
<p>Le pipeline CI devait déclencher le lanceur par le réseau. Un script local ne suffisait pas — le lanceur devait être accessible depuis n&rsquo;importe quelle machine du cluster, y compris le serveur de build. Donc : une API REST.</p>
<p>FastAPI était le choix évident. Le flux de ciblage côté CI se résume à trois appels HTTP :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>curl -X POST http://localhost:8000/park      <span style="color:#75715e"># reset vers une position connue</span>
</span></span><span style="display:flex;"><span>curl -X POST http://localhost:8000/yaw/20    <span style="color:#75715e"># rotation vers le bureau du coupable</span>
</span></span><span style="display:flex;"><span>curl -X POST <span style="color:#e6db74">&#34;http://localhost:8000/fire?shots=2&#34;</span>
</span></span></code></pre></div><p>L&rsquo;appel <code>/park</code> est plus important qu&rsquo;il n&rsquo;y paraît. Puisque le lanceur n&rsquo;a pas de retour de position, le serveur estime l&rsquo;angle courant en suivant le temps de rotation des moteurs. Cette estimation dérive. Un choc sur le hardware, une commande interrompue, ou simplement l&rsquo;imprécision du tracking temporel — tout s&rsquo;accumule. Le parking pousse les deux moteurs contre les butées physiques en balayage complet, ce qui garantit l&rsquo;alignement quelle que soit la représentation interne du serveur. Sans ça, la visée est une approximation.</p>
<p>La référence complète de l&rsquo;API est <a href="https://github.com/guillaumedelre/dream-cheeky-thunder/blob/develop/docs/api.md" target="_blank" rel="noopener noreferrer">dans le repo</a>. Il y a aussi une UI web si vous préférez cliquer plutôt que <code>curl</code>.</p>
<h2 id="docker-ne-connaît-pas-lusb">Docker ne connaît pas l&rsquo;USB</h2>
<p>Faire tourner ça dans un conteneur Docker sur le cluster, c&rsquo;est là que les choses ont commencé à devenir intéressantes : les conteneurs ne voient pas les périphériques USB par défaut.</p>
<p>Le mount <code>devices</code> dans <code>compose.yaml</code> expose le bus USB au conteneur :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">devices</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">/dev/bus/usb:/dev/bus/usb</span>
</span></span></code></pre></div><p>Pas suffisant. Première exécution : <code>USBError: [Errno 13] Access denied</code>. Le nœud de device est bien là dans le conteneur, mais il hérite des permissions du host, et sur le host seul root peut l&rsquo;ouvrir par défaut.</p>
<p>La solution : une règle udev. Déposer un fichier dans <code>/etc/udev/rules.d/</code>, et le kernel applique le bon groupe et les bonnes permissions quand le device se branche. Après ça, l&rsquo;utilisateur du conteneur peut l&rsquo;ouvrir sans privilèges élevés. La règle est fournie avec le projet, les instructions d&rsquo;installation sont <a href="https://github.com/guillaumedelre/dream-cheeky-thunder/blob/develop/docs/setup-linux.md" target="_blank" rel="noopener noreferrer">dans la doc</a>.</p>
<h2 id="wsl2-a-rendu-ça-intéressant">WSL2 a rendu ça intéressant</h2>
<p>La moitié de l&rsquo;équipe tourne sous Windows avec Docker Desktop sur WSL2. C&rsquo;est là que ça devient créatif.</p>
<p>WSL2 n&rsquo;a pas accès aux périphériques USB par défaut : le kernel Windows les détient, et le mount <code>devices</code> seul ne fait rien parce que WSL2 ne voit simplement pas le hardware. La solution est <a href="https://github.com/dorssel/usbipd-win" target="_blank" rel="noopener noreferrer">usbipd-win</a>, qui transfère le périphérique USB de Windows vers le kernel WSL2 par IP. Une fois ça fait, le chemin Linux fonctionne à l&rsquo;identique : règle udev, mount <code>devices</code>, terminé.</p>
<p>L&rsquo;attachement ne survit pas aux redémarrages, cependant. usbipd v4+ a ajouté un mécanisme de policy qui automatise la reconnexion, ce qui a mis fin au mystère du &ldquo;ça marchait hier&rdquo; qui nous agaçait depuis des jours.</p>
<h2 id="ce-qui-nous-a-vraiment-surpris">Ce qui nous a vraiment surpris</h2>
<p><strong>Le positionnement temporel fonctionne suffisamment bien.</strong> Sans encodeurs, on s&rsquo;attendait à ce que le tracking d&rsquo;angle soit quasi-inutilisable. En pratique, le parking avant chaque séquence le maintenait assez précis pour viser un bureau spécifique de manière fiable. Pas au millimètre, mais la précision missile en mousse, ça convient.</p>
<p><strong>Le mount <code>devices</code> est nécessaire mais pas suffisant.</strong> L&rsquo;erreur de permission était déroutante précisément parce que le device était clairement visible dans le conteneur. La règle udev est la partie que la plupart des tutoriels passent discrètement sous silence.</p>
<p><strong>La règle café n&rsquo;a plus jamais été la même après ça.</strong> Une fois le lanceur câblé au pipeline, les builds cassés sont devenus beaucoup plus motivants à corriger.</p>
<div style="border: 1px solid #e8e8e8; padding: 16px; margin-top: 2em; border-radius: 3px;">
  <img src="https://cdn.simpleicons.org/github" width="20" style="vertical-align: middle; margin-right: 8px;" />
  <strong><a href="https://github.com/guillaumedelre/dream-cheeky-thunder" target="_blank" rel="noopener noreferrer">guillaumedelre/dream-cheeky-thunder</a></strong>
  <p style="margin: 8px 0 0; color: #828282; font-size: 14px;">FastAPI + Docker + PyUSB — contrôle HTTP pour le lance-missiles USB Dream Cheeky Thunder. Pull requests bienvenus, surtout si vous avez une meilleure approche de calibration d'angle.</p>
</div>
]]></content:encoded></item></channel></rss>