<?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>Devops on Guillaume Delré</title><link>https://guillaumedelre.github.io/fr/categories/devops/</link><description>Recent content in Devops on Guillaume Delré</description><generator>Hugo</generator><language>fr-FR</language><lastBuildDate>Tue, 17 Feb 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://guillaumedelre.github.io/fr/categories/devops/index.xml" rel="self" type="application/rss+xml"/><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>Observabilité sur des conteneurs FrankenPHP avant que la migration cloud soit finie</title><link>https://guillaumedelre.github.io/fr/2025/06/07/observabilit%C3%A9-sur-des-conteneurs-frankenphp-avant-que-la-migration-cloud-soit-finie/</link><pubDate>Sat, 07 Jun 2025 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/fr/2025/06/07/observabilit%C3%A9-sur-des-conteneurs-frankenphp-avant-que-la-migration-cloud-soit-finie/</guid><description>Déplacer 14 microservices PHP vers le cloud impliquait d&amp;#39;avoir de l&amp;#39;observabilité avant que la migration soit finie, pas après. La couche Caddy de FrankenPHP l&amp;#39;a rendu possible avec deux lignes de config.</description><content:encoded><![CDATA[<p>Quand on fait tourner des workloads on-premise, on peut s&rsquo;en sortir avec presque aucune observabilité. On a SSH. On a <code>top</code>. On a quelqu&rsquo;un qui sait que le service d&rsquo;authentification monte toujours le lundi matin. La connaissance institutionnelle se substitue à l&rsquo;instrumentation, et personne ne budgète le temps pour la remplacer.</p>
<p>Puis on migre vers le cloud. La connaissance institutionnelle ne suit pas. L&rsquo;accès SSH est parti ou peu pratique. Et pour la première fois, on se retrouve à fixer quatorze conteneurs FrankenPHP sans la moindre idée de ce qu&rsquo;ils font réellement.</p>
<p>C&rsquo;est le moment où on a besoin de métriques. Pas éventuellement. Avant que la migration soit terminée.</p>
<h2 id="le-problème-à-le-faire-correctement">Le problème à le faire correctement</h2>
<p>La bonne façon d&rsquo;instrumenter un service PHP pour Prometheus : ajouter une bibliothèque client, écrire des compteurs et histogrammes autour de ce qui importe, exposer une route <code>/metrics</code>, mettre à jour la config de scrape. Pour un seul service, c&rsquo;est un après-midi raisonnable. Pour quatorze services en pleine migration, c&rsquo;est un projet de plusieurs sprints qui entre en compétition avec tout le reste qui doit bouger.</p>
<p>Le calcul est gênant. On a besoin de métriques pour avoir confiance que la migration se passe bien. Mais ajouter des métriques à tout avant la migration signifie que la migration prend plus longtemps. Et plus elle prend longtemps, plus on a besoin de métriques pour savoir où on en est.</p>
<p>Il fallait bien que quelque chose cède.</p>
<h2 id="ce-que-frankenphp-embarque-sans-lannoncer">Ce que FrankenPHP embarque sans l&rsquo;annoncer</h2>
<p>FrankenPHP n&rsquo;est pas un runtime PHP qui utilise <a href="https://caddyserver.com" target="_blank" rel="noopener noreferrer">Caddy</a> comme serveur web. La relation est inversée : Caddy est le serveur, et PHP est un module Caddy. Chaque requête HTTP passe par Caddy avant d&rsquo;atteindre le code applicatif.</p>
<p>Caddy embarque un endpoint compatible Prometheus intégré. Pas de plugin, pas de binaire supplémentaire. Activer l&rsquo;admin API et il est là.</p>
<p><code>CADDY_GLOBAL_OPTIONS</code> est une variable d&rsquo;environnement FrankenPHP qui injecte des directives directement dans le bloc de configuration global de Caddy. Deux lignes suffisent :</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">CADDY_GLOBAL_OPTIONS</span>: |<span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        admin 0.0.0.0:2019
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        metrics</span>
</span></span></code></pre></div><p><code>admin 0.0.0.0:2019</code> lie l&rsquo;admin API à toutes les interfaces réseau — le défaut est localhost uniquement, inaccessible depuis un conteneur Prometheus sur le même réseau. <code>metrics</code> active l&rsquo;endpoint.</p>
<p>Après ça, chaque conteneur répond à <code>GET :2019/metrics</code> avec un payload Prometheus complet. Comptes de requêtes labellisés par code de statut, histogrammes de latence, connexions actives. Aucune route ajoutée à l&rsquo;application. Aucun <code>composer require</code>. Aucun changement au Dockerfile.</p>
<p>Une variable d&rsquo;environnement, ajoutée à chaque définition de service dans un seul commit. Quatorze cibles de scrape, toutes produisant des données.</p>
<h2 id="une-image-utilisable-dans-grafana">Une image utilisable dans Grafana</h2>
<p>La config de scrape Prometheus liste chaque service par son nom de 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">scrape_configs</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">job_name</span>: <span style="color:#ae81ff">caddy</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">metrics_path</span>: <span style="color:#ae81ff">/metrics</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">static_configs</span>:
</span></span><span style="display:flex;"><span>          - <span style="color:#f92672">targets</span>:
</span></span><span style="display:flex;"><span>              - <span style="color:#ae81ff">authentication:2019</span>
</span></span><span style="display:flex;"><span>              - <span style="color:#ae81ff">content:2019</span>
</span></span><span style="display:flex;"><span>              - <span style="color:#ae81ff">media:2019</span>
</span></span><span style="display:flex;"><span>              <span style="color:#75715e"># les 14 services</span>
</span></span></code></pre></div><p>Grafana se pose au-dessus de Prometheus. Le dashboard communautaire Caddy donne les taux de requêtes, les taux d&rsquo;erreur et les percentiles de latence par service, par endpoint, par code de statut. En moins d&rsquo;une journée après que la migration a atterri dans le nouvel environnement, il y avait quelque chose de significatif à regarder.</p>
<p>La couche données suit la même logique : des exporters pour PostgreSQL, Redis et RabbitMQ scrappent au niveau infrastructure sans toucher le code applicatif. Des dashboards communautaires existent pour tous.</p>
<h2 id="ce-que-cette-baseline-couvre-réellement">Ce que cette baseline couvre réellement</h2>
<p>Les métriques HTTP de Caddy sont des métriques de serveur web, pas des métriques applicatives. Elles répondent à : est-ce que ce service reçoit du trafic, est-ce qu&rsquo;il retourne des erreurs, à quelle vitesse répond-il. Le genre de questions qu&rsquo;on pose quand quelque chose est cassé et qu&rsquo;on doit trier dans le noir.</p>
<p>Elles ne répondent pas à : combien d&rsquo;éléments ont été traités aujourd&rsquo;hui, quel job en arrière-plan est bloqué, quel est l&rsquo;impact business de ce pic de latence. Pour ça, il faut de l&rsquo;instrumentation applicative, et ce travail existe encore quand on a des choses spécifiques à mesurer.</p>
<p>Mais dans un contexte de migration, cette distinction compte moins qu&rsquo;elle n&rsquo;en a l&rsquo;air. Les choses qui cassent pendant une migration cloud sont principalement des problèmes d&rsquo;infrastructure : un service qui ne peut pas atteindre sa base de données, une limite mémoire définie trop bas, un consumer de queue qui a arrêté de traiter les messages. Ce sont exactement les choses que la baseline couvre.</p>
<p>Avoir l&rsquo;instrumentation parfaite pour les événements au niveau business peut attendre que la plateforme soit stable. Avoir assez de visibilité pour savoir si la migration a réussi ne peut pas.</p>
]]></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></channel></rss>