<?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/tags/docker/</link><description>Recent content in Docker on Guillaume Delré</description><generator>Hugo</generator><language>en</language><lastBuildDate>Sun, 17 May 2026 15:00:00 +0000</lastBuildDate><atom:link href="https://guillaumedelre.github.io/tags/docker/index.xml" rel="self" type="application/rss+xml"/><item><title>Eleven Out of Twelve</title><link>https://guillaumedelre.github.io/2026/05/17/eleven-out-of-twelve/</link><pubDate>Sun, 17 May 2026 15:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2026/05/17/eleven-out-of-twelve/</guid><description>Part 8 of 8 in &amp;quot;Symfony to the Cloud: Twelve Factors, Thirteen Services&amp;quot;: Eleven factors resolved cleanly. The twelfth: Doctrine migrations in the entrypoint, waiting on a governance question that code alone can&amp;#39;t answer.</description><category>symfony-to-the-cloud</category><content:encoded><![CDATA[<p>The <code>composer.json</code> in each service had this in its <code>post-install-cmd</code> section:</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> runs during <code>composer install</code>, which in the production Dockerfile runs during the image build. There is no database available during a Docker build. The migration command either failed silently, or connected to nothing, or was skipped by Doctrine when it couldn&rsquo;t find a schema to compare against. In any case, it didn&rsquo;t migrate anything.</p>
<p>This is a clean violation of <a href="https://12factor.net/admin-processes" target="_blank" rel="noopener noreferrer">Factor XII</a>
: admin processes — migrations, one-off scripts, console tasks — should run in the same environment as the application, against the actual production data. Running them at build time inverts the relationship. The image shouldn&rsquo;t know about the database. The database should be there when the image needs it.</p>
<h2 id="the-move-to-the-entrypoint">The move to the entrypoint</h2>
<p>The migration command moved from <code>composer.json</code> to <code>docker-entrypoint.sh</code>. The shift looks small on a diff. The implications are not.</p>
<p>The entrypoint runs when the container starts, not when the image is built. The database is reachable. The entrypoint waits for it — up to 60 seconds, one attempt per second — before doing anything:</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>If the database doesn&rsquo;t respond within 60 seconds, the container exits with an error and Kubernetes restarts it. Once the database is ready, the migration runs:</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>Two changes from the original command: <code>--all-or-nothing</code> ensures that if any migration in a batch fails, the entire batch rolls back. And the <code>find</code> guard skips the command entirely if there are no migration files — useful for services that don&rsquo;t use Doctrine migrations at all.</p>
<p>This is genuinely better. The database is present. The migration runs in the real environment. The <code>--all-or-nothing</code> flag adds atomicity that the build-time version never had.</p>
<h2 id="what-it-doesnt-solve">What it doesn&rsquo;t solve</h2>
<p>Two pods redeploying simultaneously both run the entrypoint. Both reach the database. Both find pending migrations. Both call <code>doctrine:migrations:migrate</code>.</p>
<p>Doctrine has a locking mechanism: a <code>doctrine_migration_versions</code> table that records which migrations have run, and the command checks it before applying. Under normal conditions this is fine: the second pod finds the table up to date and exits cleanly. The real failure modes are more specific: a migration long enough that the database lock times out before it completes, letting a second runner start the same migration before the first has finished; or a pod that crashes mid-migration before recording the version in the table, leaving the schema in an applied-but-unregistered state that the next pod will try to apply again.</p>
<p>The team&rsquo;s position is explicit: a brief deployment downtime is acceptable. Application versions aren&rsquo;t necessarily forward-compatible with older schema versions, so running N and N+1 simultaneously against the same database isn&rsquo;t safe anyway. The deployment strategy is Recreate: all old pods are terminated before any new pods start. The migration runs on first startup, no overlap between versions. It works.</p>
<p>But &ldquo;it works&rdquo; and &ldquo;it&rsquo;s the right architecture&rdquo; are different answers.</p>
<h2 id="what-would-be-different">What would be different</h2>
<p><a href="https://12factor.net/admin-processes" target="_blank" rel="noopener noreferrer">Factor XII</a>
 says admin processes should run in &ldquo;one-off processes.&rdquo; A process that runs once, for a specific purpose, against the production environment. The entrypoint is not one-off — it runs every time a container starts, including restarts, scaling events, and Kubernetes node movements.</p>
<p>Three alternatives exist, each with a different answer to the question of ownership:</p>
<p><strong>A Kubernetes init container</strong> runs before the main container starts, in the same pod. It could run the migration, exit, and let the main container start only after it succeeds. The migration is isolated from the application runtime. The downside: the init container is another image to build and maintain, and it runs on every pod start — so a 14-service platform starting simultaneously still has a potential race.</p>
<p><strong>A Kubernetes Job</strong> runs once, on demand or triggered by a deployment pipeline. It can be made to run before any pods are updated — serial, isolated, with a clear success or failure signal. The race condition goes away. The complexity moves to the deployment process: the Job must complete before the Deployment rollout begins, and the CI pipeline must coordinate both.</p>
<p><strong>A Helm hook</strong> is the same concept expressed declaratively in the Helm chart. A <code>pre-upgrade</code> hook runs the migration before the application pods are updated. It&rsquo;s the most idiomatic Kubernetes answer. It also means the Helm chart is now responsible for running migrations — a decision that belongs to whoever owns the chart.</p>
<p>That last sentence is why the entrypoint hasn&rsquo;t changed. Moving migrations out of the application means deciding that the deployment infrastructure — not the application itself — is responsible for the schema. It&rsquo;s a governance question as much as a technical one, and governance questions take longer to resolve than code changes.</p>
<h2 id="the-honest-end">The honest end</h2>
<p>The migration block in the entrypoint is two lines. Literally: the <code>if [ &quot;$( find ./migrations... )&quot; ]</code> guard, and the <code>php bin/console doctrine:migrations:migrate</code> that follows. Eleven other factors have clean resolutions. The cache moved to Redis. The logs go to stdout. The filesystem is an S3 bucket. The CI assembles production images from the same commit it tests. The secrets don&rsquo;t travel in image layers.</p>
<p>Factor XII has an answer. It&rsquo;s just not the final one.</p>
<p>The migrations run at startup, with a real database, with atomicity, with a bounded retry window. That&rsquo;s better than running at build time against nothing. Whether they eventually move to a Job or a Helm hook is a conversation about who owns the schema — a question that a <code>kubectl apply</code> can&rsquo;t answer.</p>
]]></content:encoded></item><item><title>Ready Is Not the Same as Started</title><link>https://guillaumedelre.github.io/2026/05/17/ready-is-not-the-same-as-started/</link><pubDate>Sun, 17 May 2026 10:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2026/05/17/ready-is-not-the-same-as-started/</guid><description>Part 7 of 8 in &amp;quot;Symfony to the Cloud: Twelve Factors, Thirteen Services&amp;quot;: The entrypoint script that works perfectly in Docker Compose has five jobs. In Kubernetes, each of those jobs belongs somewhere else.</description><category>symfony-to-the-cloud</category><content:encoded><![CDATA[<p>The rolling deploy looked clean. A new pod started. Kubernetes saw the healthcheck pass — <code>php -v</code> returned zero — and began routing traffic to the new container.</p>
<p>For the next forty seconds — out of a possible sixty — that container was polling for the database.</p>
<p>Requests that landed on it during that window got errors. Not many — the window was short — but enough to show up as noise in the monitoring. The kind of noise that gets dismissed as a transient network issue and filed nowhere. The deploy succeeded. The pod eventually became ready. The mechanism that caused it was still there, waiting for the next deploy.</p>
<p>The entrypoint script does five things before FrankenPHP starts: copy a version file, verify the vendor directory, wait up to sixty seconds for the database, run pending migrations, install assets and set filesystem permissions. In Docker Compose, this is invisible. In Kubernetes, the gap becomes traffic.</p>
<h2 id="the-gap-between-started-and-ready">The gap between started and ready</h2>
<p>Kubernetes decides whether to send traffic to a pod by watching its readiness probe. A pod whose readiness probe passes receives requests. A pod whose readiness probe fails is removed from the load balancer rotation until it recovers. This is the mechanism that makes rolling deploys safe: Kubernetes doesn&rsquo;t cut over to a new pod until that pod says it&rsquo;s ready.</p>
<p>The compose.yaml defines a healthcheck on every 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> succeeds the moment the PHP binary is present — which is true from the first millisecond of container life. The <code>start_period: 10s</code> gives ten seconds before checks begin. But the entrypoint polling loop runs for up to sixty seconds before FrankenPHP even starts. At second ten, the healthcheck passes. The application is still waiting for the database.</p>
<p>The Dockerfile has a better 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>Port 2019 is Caddy&rsquo;s built-in metrics server, embedded directly in FrankenPHP. The endpoint is Prometheus-compatible and only responds once Caddy&rsquo;s HTTP stack is fully initialized and PHP workers are accepting connections. <code>php -v</code> exits in fifty milliseconds regardless of what the application is doing — it checks the binary, not the server. <code>:2019/metrics</code> only answers when the server is actually serving. It is also not an endpoint added just for the probe: every service in the platform already has it scraped by Prometheus, so the signal is live regardless of any healthcheck configuration.</p>
<p>That&rsquo;s closer. But in Kubernetes, the <code>HEALTHCHECK</code> instruction is ignored entirely. Kubernetes uses its own probe configuration. Without explicit probe definitions in the Kubernetes manifests, there are no readiness checks — and a pod is considered ready the moment its container starts.</p>
<p>Which means: pod starts, entrypoint begins polling, Kubernetes routes traffic, application is not yet serving. Requests arrive at a container that isn&rsquo;t ready to handle them.</p>
<h2 id="three-signals-three-questions">Three signals, three questions</h2>
<p>Kubernetes separates container lifecycle into three distinct questions, each with its own probe type:</p>
<p><strong>startupProbe</strong> — &ldquo;Has the application finished starting?&rdquo; Fires repeatedly until it passes, then hands off to liveness. Prevents the liveness probe from killing a container that&rsquo;s legitimately slow to initialize. For a container whose entrypoint can take sixty seconds, this is the right tool.</p>
<p><strong>readinessProbe</strong> — &ldquo;Is the application ready to handle requests?&rdquo; Fails and passes throughout the container&rsquo;s life. When it fails, the pod is removed from the load balancer. This is what makes a rolling deploy safe.</p>
<p><strong>livenessProbe</strong> — &ldquo;Is the application still alive?&rdquo; If it fails, Kubernetes restarts the container. Meant to catch hung processes, not slow startups.</p>
<p>The sixty-second polling loop belongs in the startupProbe&rsquo;s patience, not in application 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">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 attempts × 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>Once the startupProbe passes, a readinessProbe on the same endpoint takes over — telling Kubernetes when the pod is safe to receive traffic — and a livenessProbe watches for hung processes. But the startupProbe is the one that absorbs the slow start. The entrypoint polling loop becomes redundant: its job was to keep the container alive while the database caught up. Without it, the application attempts to connect, fails, and the container exits — Kubernetes restarts the pod, and the startupProbe maintains its retry cycle until the database responds and the application starts cleanly. The retry responsibility moves from inside the entrypoint to the orchestrator, which is exactly where it belongs.</p>
<h2 id="the-migration-problem">The migration problem</h2>
<p>The polling loop is the most visible issue, but the migrations create a subtler one.</p>
<p>With a rolling deploy and two replicas, Kubernetes starts a new pod while the old one still serves traffic. Both pods run the same entrypoint. Both reach <code>doctrine:migrations:migrate</code>.</p>
<p>Doctrine&rsquo;s migration table tracks which migrations have already executed, so a completed migration won&rsquo;t run twice. But if two pods start simultaneously and both see a pending migration, both attempt to run it at the same time. Whether that&rsquo;s safe depends on the migration: additive schema changes are usually fine; destructive ones less so. And you don&rsquo;t get to choose which ones run on a deploy that didn&rsquo;t expect to coordinate. <code>--all-or-nothing</code> wraps migrations in a transaction and rolls back everything if one fails — it&rsquo;s about atomicity within a single run, not coordination across processes.</p>
<p>The cleaner approach separates the two concerns into two init containers: one that waits for the database, one that runs migrations. The main container starts only after both complete:</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>Both init containers reuse the application image. That&rsquo;s not waste: they need the same PHP binary and the same environment wiring to reach the database and resolve the migration classes. A lighter purpose-built image would reduce startup overhead, but would require maintaining a separate PHP installation in sync with the main image.</p>
<p>Even with init containers, multiple pods starting simultaneously — initial deploy, after a node failure, or under autoscaling pressure — will each attempt to run migrations. Solving that properly — through a Helm pre-upgrade hook, a <code>maxSurge: 0</code> strategy, or a separate migration Job — is a topic in itself. What matters here is that the entrypoint is the wrong place to host that decision: it can&rsquo;t coordinate across pods, and it ties migration execution to application startup in a way that&rsquo;s hard to untangle later. The question of which approach fits this codebase — and why the entrypoint hasn&rsquo;t been replaced — gets its own treatment in <a href="/2026/05/17/eleven-out-of-twelve/">the next article in this series</a>
.</p>
<p>Factor XII of the <a href="https://12factor.net/admin-processes" target="_blank" rel="noopener noreferrer">twelve-factor methodology</a> — admin processes run in the same environment as the application — is satisfied either way. The question is whether &ldquo;same environment&rdquo; means &ldquo;same entrypoint script&rdquo; or &ldquo;same image, separate process&rdquo;. In Kubernetes, the latter is safer.</p>
<h2 id="what-the-entrypoints-real-job-is">What the entrypoint&rsquo;s real job is</h2>
<p>Strip out the database wait (now a startupProbe or init container), the migrations (now an init container or Job), and the assets install (a build-time operation that belongs in the Dockerfile), and the entrypoint has one remaining job: start the 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>Factor IX of the twelve-factor app asks for fast startup and graceful shutdown. A container whose startup takes sixty seconds because it&rsquo;s waiting for external dependencies is not fast. It means rolling deploys are slow, recovery after a crash is slow, and horizontal scale-out creates a sixty-second gap before each new pod contributes.</p>
<p>Fast startup is not just a nice-to-have. It&rsquo;s what makes the rest of the cloud model work. When a pod can start in seconds, the orchestrator can scale aggressively and recover quickly. When it takes a minute, you add headroom everywhere — longer probe timeouts, larger deployment windows, more conservative scaling policies — and the system becomes rigid.</p>
<h2 id="the-docker-compose-tax">The Docker Compose tax</h2>
<p>The entrypoint accumulates these responsibilities for a reason. In Docker Compose, there is no init container concept. There is no startupProbe. Services declare <code>depends_on</code>, but without health conditions, that&rsquo;s just startup ordering — not readiness. The entrypoint fills the gap.</p>
<p>This is not a design flaw. It&rsquo;s a reasonable adaptation to the constraints of Docker Compose. The script works. It handles edge cases (the database timeout, unrecoverable errors, missing migrations directory). Someone tested it.</p>
<p>The issue is the assumption that the same script works equally well in Kubernetes. It runs. The application eventually starts. But it bypasses the probe system that makes Kubernetes deployments reliable, and it puts migration responsibility in a place where coordination across pods is difficult to reason about.</p>
<p>Several of the changes in this series — <a href="/2026/05/14/the-ghost-of-the-ci-runner/">media storage</a>
, <a href="/2026/05/14/what-survives-the-build/">secrets in image layers</a>
, <a href="/2026/05/15/no-witnesses/">log handlers</a>
, <a href="/2026/05/15/the-host-that-hid-the-graph/">service dependencies</a>
, <a href="/2026/05/16/fifteen-minutes-before-the-first-test/">CI environment parity</a>
, <a href="/2026/05/16/the-cache-that-was-lying-to-us/">cache adapters</a>
 — were changes to application code or configuration. This one is different. It requires the infrastructure to gain awareness of what &ldquo;ready&rdquo; means for this application, and it requires the entrypoint to give up responsibilities it currently owns.</p>
<p>That&rsquo;s a harder conversation. But the startupProbe is waiting for it.</p>
]]></content:encoded></item><item><title>Fifteen Minutes Before the First Test</title><link>https://guillaumedelre.github.io/2026/05/16/fifteen-minutes-before-the-first-test/</link><pubDate>Sat, 16 May 2026 10:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2026/05/16/fifteen-minutes-before-the-first-test/</guid><description>Part 5 of 8 in &amp;quot;Symfony to the Cloud: Twelve Factors, Thirteen Services&amp;quot;: How a CI pipeline that provisioned an Azure VM per run — missing RabbitMQ, MinIO, and Varnish — became one that assembles the production environment from the same images it ships.</description><category>symfony-to-the-cloud</category><content:encoded><![CDATA[<p>The pipeline had two stages that had nothing to do with code: <code>provision</code> and <code>deprovision</code>. Between them, in sequence, came <code>phpunit</code>, <code>phpmetrics</code>, and <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>Before the first assertion ran, fifteen minutes had passed. Terraform had cloned an infrastructure repository, authenticated to Azure, and applied a VM configuration. Ansible had connected to the new VM, installed PHP, configured the application, wired up a database and a Redis instance. Then the tests ran. Then Terraform destroyed what Ansible had built.</p>
<p>For every pipeline. From every branch. For every pull request, from open to merge.</p>
<h2 id="what-those-fifteen-minutes-were-missing">What those fifteen minutes were missing</h2>
<p>The <code>provision</code> stage set up two services: PostgreSQL and Redis. Three services that the application depended on in production were absent: RabbitMQ, MinIO, and Varnish.</p>
<p>RabbitMQ processed all asynchronous work — 56 consumers across 14 microservices. MinIO handled media storage. Varnish fronted the HTTP cache. In CI, none of them existed. Tests that exercised message queuing or file storage had two options: skip these paths, or leave them untested until staging. Varnish is a different case: tests hit the application directly and intentionally bypass the cache layer, so its absence in CI is a deliberate choice rather than a gap.</p>
<p>This is the problem <a href="https://12factor.net/dev-prod-parity" target="_blank" rel="noopener noreferrer">Factor X</a>
 describes as the environment gap. The gap here wasn&rsquo;t a matter of configuration — it was structural. The VM was built by Ansible from a script in a separate repository. It wasn&rsquo;t a container image. It wasn&rsquo;t versioned alongside the application. If a branch modified the RabbitMQ message topology, there was no way to test that modification in CI. The topology change and the code that relied on it would only meet in staging.</p>
<p>The Ansible provisioning script itself is part of the problem:</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>The <code>sleep 45</code> is there because Ansible needs the VM to finish booting before it can connect. It&rsquo;s not an oversight — it&rsquo;s the minimum time a freshly provisioned VM needs before SSH works. It&rsquo;s baked into the process.</p>
<h2 id="what-replaced-it">What replaced it</h2>
<p>The new pipeline has no <code>provision</code> stage. It has no <code>deprovision</code> stage. The environment is the images, and the images exist before the tests begin.</p>
<p>Each test job declares its dependencies as Docker services:</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>The services start in parallel when the job begins. Before the test script runs, a <code>before_script</code> waits for all of them to be ready:</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>From pipeline start to first assertion: ninety seconds — assuming images are already cached on the runner; a cold pull adds time, but becomes negligible once the pipeline has run once on a given branch.</p>
<h2 id="what-ci_commit_ref_slug-means">What <code>$CI_COMMIT_REF_SLUG</code> means</h2>
<p>The timing is the visible result. What produces it is more interesting: the image names.</p>
<p><code>$REGISTRY_URL/platform/rabbitmq:$CI_COMMIT_REF_SLUG</code> is not the official RabbitMQ image from Docker Hub. It&rsquo;s an image built by the same pipeline, from the same branch, at the same commit as the code being tested. The RabbitMQ image carries the topology: a <code>definitions.json</code> with every exchange, every queue, every binding, every dead-letter configuration — versioned in git alongside the application that depends on them.</p>
<p>If a branch modifies the messaging topology, the CI pipeline builds a new RabbitMQ image that includes those modifications, then runs the tests against it. The topology change and the code that relies on it are tested together, at the same commit, before anything reaches staging.</p>
<p>The same logic applies to MinIO, as described in the <a href="/2026/05/14/the-ghost-of-the-ci-runner/">first article in this series</a>
: the MinIO image carries preloaded test fixtures. The CI environment doesn&rsquo;t need a setup step to populate storage. The state is built in.</p>
<p>The test runner itself follows the same pattern. Each job uses a debug variant of the application image — built from the same branch, same commit — with the test dependencies included:</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>The whole environment assembles from artifacts built at the same point in the git history.</p>
<h2 id="what-this-required-dropping">What this required dropping</h2>
<p>Behat and the provisioned VM were coupled. The Behat test suite ran against an HTTP server on the VM; removing the VM meant removing Behat.</p>
<p>That turned out not to be the obstacle it looked like. The Behat suite lived in a separate repository, required the VM to run, and had accumulated significant maintenance overhead. PHPUnit, running inside the application container with Docker services, covered the same scenarios through a more direct path: functional tests exercising the HTTP layer, unit tests for individual components, suites organized per feature area and generated dynamically into parallel CI jobs.</p>
<p>The BDD layer went away. The test coverage stayed — and could now run against the actual services.</p>
<h2 id="factor-x-applied">Factor X, applied</h2>
<p><a href="https://12factor.net/dev-prod-parity" target="_blank" rel="noopener noreferrer">Factor X</a>
 is often read as &ldquo;use the same database locally as in production.&rdquo; That&rsquo;s the simplest version. The deeper version is about the gap between what you test and what you ship.</p>
<p>The gap in the old pipeline was wide: a manually configured VM, missing key services, rebuilt from scratch on every run. The gap in the new pipeline is narrow: the CI assembles the environment from the same images as production, built from the same commit as the code under test.</p>
<p>The fifteen minutes of Terraform and Ansible were not just slow. They were building something that wasn&rsquo;t what production ran, every time, before any test could begin. The ninety seconds of <code>docker pull</code> build exactly what production runs — and the tests that follow are testing that, not an approximation of it.</p>
]]></content:encoded></item><item><title>What Survives the Build</title><link>https://guillaumedelre.github.io/2026/05/14/what-survives-the-build/</link><pubDate>Thu, 14 May 2026 15:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2026/05/14/what-survives-the-build/</guid><description>Part 2 of 8 in &amp;quot;Symfony to the Cloud: Twelve Factors, Thirteen Services&amp;quot;: How committed .env files feed a build step that bakes credentials into Docker layers — and what it takes to empty the file down to four lines.</description><category>symfony-to-the-cloud</category><content:encoded><![CDATA[<p>At some point during a cloud migration audit, someone ran this:</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>The output showed everything that <code>composer dump-env prod</code> had compiled into the image at build time. Which meant it showed everything that had been in the <code>.env</code> file when the image was built. Which meant it showed these, among others:</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>Twenty-five variables in total. Every credential that had accumulated in the root <code>.env</code> over three years, now permanent in an image layer.</p>
<h2 id="how-dump-env-works">How <code>dump-env</code> works</h2>
<p><code>composer dump-env prod</code> is a legitimate Symfony optimization. Instead of parsing <code>.env</code> files on every request, the runtime loads a pre-compiled PHP array from <code>.env.local.php</code>. Faster and simpler.</p>
<p>The problem is what it reads. The Dockerfile copies the repository into the image with <code>COPY . ./</code>, <code>.env</code> included. Then <code>dump-env prod</code> reads that file and compiles every variable into <code>.env.local.php</code>. The image ships with a frozen snapshot of the credentials that were in <code>.env</code> at build time.</p>
<p>Docker layers are immutable archives. Even if a subsequent step removed <code>.env</code> from the container filesystem, the layer containing it would still exist inside the image. <code>docker save &lt;image&gt;</code> produces a tarball of every layer; extracting any file from any point in the build history is straightforward. The credentials are invisible at runtime. They are not gone.</p>
<p><a href="https://12factor.net/build-release-run" target="_blank" rel="noopener noreferrer">Factor V</a>
 calls this out directly: a build artifact should be environment-agnostic, with config arriving at the release step from outside. Once credentials are compiled in, the image is no longer portable. You can&rsquo;t promote it across environments. You build twice and hope the second build behaves like the first.</p>
<h2 id="how-twenty-five-variables-accumulate">How twenty-five variables accumulate</h2>
<p>Before tracing how this gets fixed, it&rsquo;s worth understanding how it happened.</p>
<p>The <code>BLACKFIRE_*</code> tokens are the easy case to understand. A team member sets up profiling, needs to share the configuration, and the repository is already open to everyone. One line in <code>.env</code> is the path of least resistance. The InfluxDB and Grafana credentials follow the same logic — shared tooling, shared repo, one commit.</p>
<p>Then there are the variables that reveal a different kind of drift. In some of the service-level <code>.env</code> files:</p>
<pre tabindex="0"><code class="language-dotenv" data-lang="dotenv">APP__RATINGS__SERIALS=&#39;{&#34;brand1&#34;:{&#34;fr&#34;:&#34;12345&#34;},...}&#39;  # ~40 lines of JSON
APP__YOUTUBE__CREDENTIALS=&#39;{&#34;brand1&#34;:{&#34;client_id&#34;:&#34;xxx&#34;,&#34;refresh_token&#34;:&#34;yyy&#34;},...}&#39;
</code></pre><p>Audience measurement serial numbers. YouTube API refresh tokens per brand. These aren&rsquo;t secrets in the Blackfire sense. They&rsquo;re business data — the kind of values that vary between brands and environments, that someone decided to version in <code>.env</code> because they behaved like configuration and <code>.env</code> was where configuration lived.</p>
<p>Twenty-five variables is the sum of incremental decisions, none of which felt wrong in isolation. The problem is structural: when <code>.env</code> is the only answer available, everything starts looking like it belongs there.</p>
<h2 id="where-things-actually-belong">Where things actually belong</h2>
<p>Emptying the file required answering one question for each variable: <em>where does this actually belong?</em></p>
<p>The answers revealed three categories that the team had never explicitly named:</p>
<p><strong>Static config</strong> lives in code. Business rules, routing logic, Symfony parameter files — anything that doesn&rsquo;t vary between deployments. A change requires a rebuild. The JSON blobs for audience measurement serials turned out not to be static config at all: they were queried from a dedicated Config service at runtime. They had no business being in a file.</p>
<p><strong>Environment config</strong> varies between deployments: hostnames, connection strings, third-party credentials. This is what <a href="https://12factor.net/config" target="_blank" rel="noopener noreferrer">Factor III</a>
 means by &ldquo;config in environment variables&rdquo; — real OS-level variables injected by the runtime, never files that travel with the code. In Kubernetes, this becomes a ConfigMap for non-sensitive values and a Kubernetes Secret for credentials. The choice for secrets management was SOPS — credentials are encrypted and committed to git, rather than stored in an external vault like Azure Key Vault or HashiCorp Vault. A vault trades simplicity for auditability: automatic rotation, centralized audit logs, workload identity-based access with no key to protect. SOPS trades those capabilities for a simpler operational model — no external service to query at deploy time, secrets travel through the normal code review process, git history serves as the audit trail. The accepted downsides are manual rotation and the responsibility of protecting the decryption key itself. For the team&rsquo;s scale, the tradeoff was deliberate.</p>
<p><strong>Dynamic config</strong> changes without a deployment: editorial parameters, per-brand thresholds, content moderation settings. It belongs in a database, managed through the application&rsquo;s Config service. Some of what had accumulated in <code>.env</code> files was this category all along, passing as static defaults because it changed rarely enough that nobody noticed.</p>
<p>Once the categories had names, the variables sorted themselves. The root <code>.env</code> ended at four lines:</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>Safe defaults. Nothing sensitive. <code>dump-env prod</code> now compiles empty strings; real values arrive at runtime from Kubernetes.</p>
<h2 id="the-postgresql-image">The PostgreSQL image</h2>
<p>The PostgreSQL image used in CI has a hardcoded password:</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>This looks like the same problem. It isn&rsquo;t, because the threat model is different. The CI database is ephemeral — it exists for the duration of a pipeline run, contains no real data, and runs in an isolated network. A hardcoded password on a throwaway test database is an acceptable risk, not a policy exception.</p>
<p>In production, the question doesn&rsquo;t arise: the platform uses Azure Flexible Server, a managed PostgreSQL service. There is no Docker image. Credentials arrive via Helm chart injection, never touching a layer.</p>
<h2 id="what-survives-the-build-now">What survives the build now</h2>
<p>The image that ships to production now contains a guarantee: <code>var_dump(require '.env.local.php')</code> returns only empty strings and safe defaults. The credentials aren&rsquo;t there because they were never put there — they arrive at runtime, from outside.</p>
<p>That&rsquo;s the responsibility boundary <code>dump-env</code> had been quietly erasing: the image is the application, the runtime is the environment. They should not know each other&rsquo;s secrets.</p>
]]></content:encoded></item><item><title>Building a self-hosted homelab with Docker Compose and Traefik</title><link>https://guillaumedelre.github.io/2026/02/17/building-a-self-hosted-homelab-with-docker-compose-and-traefik/</link><pubDate>Tue, 17 Feb 2026 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2026/02/17/building-a-self-hosted-homelab-with-docker-compose-and-traefik/</guid><description>Complete guide to setting up a Docker homelab with Traefik and sslip.io: independent stacks, auto-configured dashboard, common pitfalls documented.</description><content:encoded><![CDATA[<p>For years I wanted a homelab at home. A place of my own to host development tools, monitor my machines, run home automation, and experiment without risking breaking anything important. The idea is simple. Getting it running, a bit less so.</p>
<p>Back then, Kubernetes didn&rsquo;t exist yet. Options for running multiple services on a single machine came down to bash scripting, hand-written Nginx configs, and a lot of coffee. Tutorials on &ldquo;homelab for humans&rdquo; were nowhere to be found.</p>
<p>This tutorial is what I wish I had found back then. It&rsquo;s been running for several years now. Not without evolving: services added, others dropped, choices revisited. But the foundation is there, stable — and that&rsquo;s what success looks like in self-hosting.</p>
<p>The setup: ten self-hosted web services on a local machine, accessible from a browser via readable URLs, without touching DNS configuration, without renting a VPS, without managing TLS certificates. The ingredient that makes it possible: <a href="https://sslip.io" target="_blank" rel="noopener noreferrer">sslip.io</a>
, a public DNS service that encodes the IP directly in the domain name. <code>service.192.168.1.10.sslip.io</code> resolves to <code>192.168.1.10</code>, with zero configuration, from any machine on the local network.</p>
<p>This tutorial is aimed at someone who knows Docker but is starting from scratch on self-hosted service orchestration.</p>
<hr>
<h2 id="table-of-contents">Table of contents</h2>
<ol>
<li><a href="#1-philosophy-and-architecture-choices">Philosophy and architecture choices</a>
</li>
<li><a href="#2-the-building-blocks">The building blocks</a>
</li>
<li><a href="#3-step-by-step-setup">Step-by-step setup</a>
</li>
<li><a href="#4-adding-a-new-service">Adding a new service</a>
</li>
<li><a href="#5-patterns-and-conventions">Patterns and conventions</a>
</li>
<li><a href="#6-common-pitfalls">Common pitfalls</a>
</li>
<li><a href="#conclusion">Conclusion</a>
</li>
<li><a href="#references">References</a>
</li>
</ol>
<hr>
<h2 id="1-philosophy-and-architecture-choices">1. Philosophy and architecture choices</h2>
<h3 id="goal">Goal</h3>
<p>Run multiple web services on a local machine, accessible from a browser via readable URLs, without touching DNS configuration, without renting a VPS, without managing TLS certificates.</p>
<h3 id="why-docker-compose-and-not-something-else">Why Docker Compose and not something else?</h3>
<p>Docker Compose is the right level of complexity for a personal homelab. Kubernetes is too heavy for a single machine. Docker Swarm is in decline. Compose is simple, readable, versionable, and sufficient for dozens of services.</p>
<h3 id="why-traefik-and-not-nginx-proxy-manager">Why Traefik and not Nginx Proxy Manager?</h3>
<p><strong>Nginx Proxy Manager (NPM)</strong> is a graphical interface for configuring Nginx as a reverse proxy. Routes are stored in a database and configured through a UI.</p>
<p><strong><a href="https://github.com/traefik/traefik" target="_blank" rel="noopener noreferrer">Traefik</a>
</strong> automatically reads Docker container labels and generates its configuration on the fly. When a container starts with the right labels, Traefik discovers it and creates the route immediately, without restarting, without opening any UI.</p>
<p>This &ldquo;configuration as code&rdquo; approach has two major advantages:</p>
<ul>
<li>A service&rsquo;s configuration lives in its <code>compose.yaml</code>, in the same place as everything else.</li>
<li>Adding a service requires no changes to Traefik.</li>
</ul>
<h3 id="why-dockge-and-not-portainer">Why Dockge and not Portainer?</h3>
<p><strong>Portainer</strong> is a full Docker management tool: images, volumes, networks, individual containers&hellip; powerful but complex.</p>
<p><strong><a href="https://github.com/louislam/dockge" target="_blank" rel="noopener noreferrer">Dockge</a>
</strong> is focused on a single thing: managing Docker Compose stacks. Its UI is minimal and intuitive. For a homelab where everything is managed through Compose, it&rsquo;s sufficient and much more pleasant to use.</p>
<h3 id="why-sslipio">Why sslip.io?</h3>
<p>Web services need a hostname (e.g. <code>dozzle.myserver.local</code>) for Traefik to route correctly. The usual options:</p>
<ul>
<li>Edit <code>/etc/hosts</code> on every machine: tedious, not shareable.</li>
<li>Set up a local DNS server (Pi-hole, AdGuard): requires additional infrastructure.</li>
<li>Buy a domain and configure DNS: costs money and time.</li>
</ul>
<p><strong>sslip.io</strong> is a public DNS service that automatically resolves <code>&lt;anything&gt;.&lt;IP&gt;.sslip.io</code> to <code>&lt;IP&gt;</code>. Example: <code>dozzle.192.168.1.10.sslip.io</code> resolves to <code>192.168.1.10</code>. Nothing to configure — the DNS works everywhere without touching anything.</p>
<hr>
<h2 id="2-the-building-blocks">2. The building blocks</h2>
<h3 id="the-shared-docker-network">The shared Docker network</h3>
<p>All services and Traefik must share the same Docker network so Traefik can communicate with them. This network is called <code>traefik</code> and is created once:</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>It is an <strong>external</strong> network (created outside any Compose file). Each <code>compose.yaml</code> declares it as external:</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>Why external rather than internal to a Compose file? Because multiple independent stacks all need to connect to it. A network internal to a Compose file is only accessible to services within that file.</p>
<h3 id="traefik-the-reverse-proxy">Traefik: the reverse proxy</h3>
<p>Traefik listens on port 80 and routes HTTP requests to the right container based on the <code>Host</code> header.</p>
<p>Its main configuration lives in <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> is important: Traefik ignores all containers by default. A container must explicitly opt in with the label <code>traefik.enable: true</code>. This prevents accidentally exposing services.</p>
<p>The <code>ping</code> entrypoint on port 8082 is dedicated to health checks. Separating it from the <code>web</code> entrypoint prevents health check requests from appearing in access logs.</p>
<p>To access the Docker daemon, Traefik mounts the 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-the-stack-manager">Dockge: the stack manager</h3>
<p>Dockge runs inside a container itself (the <code>compose.yaml</code> at the root of the repo). It needs two things:</p>
<ol>
<li>Access to the Docker socket to manage the other containers.</li>
<li>Access to the stack directories to read and edit <code>compose.yaml</code> files.</li>
</ol>
<p>The critical point is the stack mount. Dockge launches stacks by passing absolute paths to the Docker daemon. These paths must be identical inside the Dockge container and on the host. The 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> is a shell variable resolved at <code>docker compose up</code> time. It equals the current directory. If Dockge is launched from <code>/home/user/homelab</code>, the stacks folder will be mounted at <code>/home/user/homelab/stacks</code> on both sides. This is the only way to prevent Docker from creating ghost directories in the wrong place.</p>
<p><strong>Practical consequence</strong>: always run <code>docker compose up -d</code> from the root of the repo.</p>
<p>Dockge&rsquo;s persistent data (configuration, history) lives in a named volume created in advance:</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>A named volume survives <code>docker compose down -v</code>. An anonymous volume would be destroyed with the stack.</p>
<hr>
<h2 id="3-step-by-step-setup">3. Step-by-step setup</h2>
<h3 id="step-1-clone-and-configure">Step 1: clone and configure</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>Find the machine&rsquo;s local IP:</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"># e.g.: 192.168.1.10</span>
</span></span></code></pre></div><p>Create and edit the root <code>.env</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>cp .env.example .env
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Edit .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, see conventions section</span>
</span></span></code></pre></div><h3 id="step-2-docker-prerequisites">Step 2: Docker prerequisites</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="step-3-start-dockge">Step 3: start 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 is accessible at <code>http://&lt;IP&gt;:5001</code>. It is exposed directly on port 5001, not through Traefik (Traefik is not running yet at this point). Create an admin account on first launch.</p>
<h3 id="step-4-configure-the-stacks">Step 4: configure the stacks</h3>
<p>For each directory in <code>stacks/</code>, copy the <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>Then edit each <code>.env</code> to set <code>IP</code> and <code>DOMAIN</code> to the same values as in step 1. The <code>COMPOSE_PROJECT_NAME</code> value is pre-filled with the folder name — do not change it (see conventions section).</p>
<p>For <code>filebrowser</code>, also set <code>FILEBROWSER_ROOT</code> to the local path to expose.</p>
<h3 id="step-5-start-the-stacks-from-dockge">Step 5: start the stacks from Dockge</h3>
<p>From the Dockge interface (<code>http://&lt;IP&gt;:5001</code>), in this order:</p>
<p><strong>1. Traefik first</strong></p>
<p>Traefik must be running before the other services. Without Traefik, routes don&rsquo;t exist and services are unreachable via their URL.</p>
<p>After starting, verify Traefik is 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. The other stacks in any order</strong></p>
<p>Each stack automatically registers itself with Traefik via its Docker labels. Traefik discovers new containers in real time.</p>
<p><strong>3. Homepage last</strong></p>
<p>Homepage reads Docker labels from all running containers at startup to build the dashboard. Starting it last ensures it discovers all active services from the first launch.</p>
<hr>
<h2 id="4-adding-a-new-service">4. Adding a new service</h2>
<p>Here is the <code>compose.yaml</code> template for any new 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">myservice</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">image</span>: <span style="color:#ae81ff">vendor/myservice: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 - auto-discovery in dashboard</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">homepage.group</span>: <span style="color:#ae81ff">tools</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">homepage.name</span>: <span style="color:#ae81ff">My 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/myservice.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 - HTTP routing</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.myservice.entrypoints</span>: <span style="color:#ae81ff">web</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">traefik.http.routers.myservice.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.myservice.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>And the associated <code>.env.example</code>:</p>
<pre tabindex="0"><code>COMPOSE_PROJECT_NAME=myservice
IP=127.0.0.1
DOMAIN=sslip.io
</code></pre><p><strong>The folder name determines the subdomain.</strong> If the folder is called <code>myservice</code>, the service will be accessible at <code>myservice.&lt;IP&gt;.&lt;DOMAIN&gt;</code>. That&rsquo;s it.</p>
<p>To find services worth adding, <a href="https://selfh.st" target="_blank" rel="noopener noreferrer">selfh.st</a>
 is an excellent resource: it&rsquo;s a catalog of self-hosted software organized by category (media, security, productivity, monitoring&hellip;), with a description, screenshot, and GitHub link for each. The site also publishes a weekly newsletter on new releases.</p>
<h3 id="checklist-for-a-new-service">Checklist for a new service</h3>
<ul>
<li><input disabled="" type="checkbox"> Create <code>stacks/&lt;subdomain-name&gt;/compose.yaml</code></li>
<li><input disabled="" type="checkbox"> Create <code>stacks/&lt;subdomain-name&gt;/.env.example</code> with <code>COMPOSE_PROJECT_NAME=&lt;name&gt;</code></li>
<li><input disabled="" type="checkbox"> Copy <code>.env.example</code> to <code>.env</code> and fill in IP/DOMAIN</li>
<li><input disabled="" type="checkbox"> Check the port in the Traefik labels</li>
<li><input disabled="" type="checkbox"> Choose the Homepage group: <code>infra</code>, <code>monitoring</code>, <code>tools</code></li>
<li><input disabled="" type="checkbox"> Find the icon on <a href="https://github.com/selfhst/icons" target="_blank" rel="noopener noreferrer">selfhst/icons</a>
</li>
<li><input disabled="" type="checkbox"> Add persistent data in a volume if needed</li>
<li><input disabled="" type="checkbox"> Start from Dockge and verify the container is <code>healthy</code></li>
</ul>
<hr>
<h2 id="5-patterns-and-conventions">5. Patterns and conventions</h2>
<h3 id="the-compose_project_name-variable">The <code>${COMPOSE_PROJECT_NAME}</code> variable</h3>
<p>Docker Compose automatically sets <code>COMPOSE_PROJECT_NAME</code> to the stack folder name. We use it to build URLs dynamically:</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>Advantage: no <code>*_HOST</code> variable to maintain in each <code>.env</code>. Renaming the folder automatically changes the subdomain.</p>
<p><strong>Warning</strong>: in the <code>.env</code>, <code>COMPOSE_PROJECT_NAME</code> must be defined explicitly with the stack folder name. Without it, Docker Compose uses the current directory name at launch time, which can produce unexpected values depending on where the command is run from.</p>
<h3 id="homepage-groups">Homepage groups</h3>
<p>Services are organized into three groups in the dashboard:</p>
<table>
  <thead>
      <tr>
          <th>Group</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>monitoring</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>tools</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>This grouping is specific to this homelab, not an enforced convention. Homepage accepts any value for <code>homepage.group</code>: you can create as many groups as needed and name them however you like (<code>media</code>, <code>home-automation</code>, <code>dev</code>&hellip;). The dashboard reorganizes automatically.</p>
<h3 id="health-checks">Health checks</h3>
<p>All services have a health check. This is crucial because <strong>Traefik silently ignores <code>unhealthy</code> containers</strong>: a service with a failing health check will not appear in routing, even with <code>traefik.enable: true</code>.</p>
<p>Three edge cases encountered in practice:</p>
<p><strong>1. <code>localhost</code> does not always resolve to <code>127.0.0.1</code></strong></p>
<p>In some minimal images, <code>localhost</code> is not resolved. Use <code>127.0.0.1</code> explicitly:</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 without a shell (<code>scratch</code>-based)</strong></p>
<p>Images based on <code>scratch</code> (e.g. Dozzle) do not contain <code>/bin/sh</code>. <code>CMD-SHELL</code> fails. Use the embedded binary:</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 without <code>wget</code> or <code>curl</code></strong></p>
<p>Some Node.js or JVM images have neither wget nor curl. Possible solutions:</p>
<ul>
<li>If Node.js is available: <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>If curl is available: <code>curl -fs http://127.0.0.1:PORT/</code></li>
<li>If the app binary exposes a healthcheck subcommand: use it directly.</li>
</ul>
<h3 id="data-persistence">Data persistence</h3>
<p>For services that have data (configuration, user accounts, database):</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:/path/in/container</span>
</span></span></code></pre></div><p>The <code>./docker/</code> folder lives inside the stack directory and can be versioned, except for runtime data which goes in <code>.gitignore</code>.</p>
<p><strong>Rule</strong>: add <code>stacks/&lt;service&gt;/docker/</code> to <code>.gitignore</code> if the folder contains data that should not be committed (SQLite databases, uploads&hellip;).</p>
<h3 id="traefik-label-conventions">Traefik label conventions</h3>
<p>By convention, the name used in Traefik labels (<code>traefik.http.routers.&lt;name&gt;</code>) matches the Docker service name in <code>compose.yaml</code>. In practice, align it with the folder name:</p>
<pre tabindex="0"><code>stacks/it-tools/    →    service: ittools    →    traefik.http.routers.ittools.*
</code></pre><p>This is not a technical constraint from Traefik, just a readability convention.</p>
<hr>
<h2 id="6-common-pitfalls">6. Common pitfalls</h2>
<h3 id="dockge-stop-then-start-not-restart">Dockge: Stop then Start, not Restart</h3>
<p>When a <code>compose.yaml</code> is modified from an IDE and the changes need to be applied, use <strong>Stop + Start</strong> from Dockge, not &ldquo;Restart&rdquo;. Restart restarts the existing container without re-reading the <code>compose.yaml</code>. Stop + Start recreates the container with the new configuration.</p>
<h3 id="modified-labels-restart-homepage">Modified labels: restart Homepage</h3>
<p>Homepage reads Docker labels <strong>at startup</strong>. If <code>homepage.group</code> or <code>homepage.name</code> is changed for a service, Homepage won&rsquo;t see it until it is restarted.</p>
<h3 id="container-starts-but-is-not-routable">Container starts but is not routable</h3>
<p>Check in order:</p>
<ol>
<li><code>docker ps</code>: is the container <code>healthy</code>? Traefik ignores <code>unhealthy</code> containers.</li>
<li>Is the container on the <code>traefik</code> network?</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>Is the label <code>traefik.enable: true</code> present?</li>
<li>Does the <code>Host(...)</code> rule match the URL being tested?</li>
</ol>
<h3 id="mounting-non-existent-files-under-docker-desktop--wsl">Mounting non-existent files under Docker Desktop / WSL</h3>
<p>When Docker Desktop (WSL) mounts a <strong>file</strong> that does not yet exist on the host, it creates a <strong>directory</strong> instead. This ghost directory then blocks the mount of the actual file. Symptom: the container fails to start with a mount error.</p>
<p>Solution: ensure the file exists on the host before starting the container, or use a directory mount instead of a file mount.</p>
<h3 id="watchtower-docker-api-too-old">Watchtower: Docker API too old</h3>
<p>On some configurations, Watchtower tries to communicate with the daemon starting the negotiation at API v1.25 (its historical minimum). Recent versions of Docker reject this version. Symptom: the container restarts in a loop with <code>client version 1.25 is too old. Minimum supported API version is 1.40</code>.</p>
<p>Fix in the Watchtower <code>compose.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">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> is the value to use, regardless of your Docker version. It is not your exact version — it is the minimum the daemon accepts, as stated in the error message. To check the actual API version of your 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-in-dockges-compose-file"><code>${PWD}</code> in Dockge&rsquo;s compose file</h3>
<p><code>${PWD}</code> is not a <code>.env</code> variable — it is a shell variable resolved at <code>docker compose up</code> time. It equals the current terminal directory. Running <code>docker compose up -d</code> from any other directory will produce a wrong value and break stack volume mounts.</p>
<hr>
<p><em>This homelab is designed to run on a Linux machine or WSL. All commands have been tested on Ubuntu/WSL2 with Docker Desktop.</em></p>
<hr>
<h2 id="conclusion">Conclusion</h2>
<p>I&rsquo;m well aware this tutorial doesn&rsquo;t cover everything. We could have added authentication in front of each service, run the whole thing over HTTPS, set up a socket proxy to limit the Docker daemon&rsquo;s exposure, or pinned precise image versions. But each of those points would have considerably lengthened the article and the complexity of the setup. The goal was to start with something functional and maintainable, not to build a fortress on day one.</p>
<p>The perfect homelab doesn&rsquo;t exist. The one that runs, does.</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;">Docker Compose homelab with Traefik — independent stacks, auto-configured dashboard, and zero DNS configuration using sslip.io.</p>
</div>
<h2 id="references">References</h2>
<table>
  <thead>
      <tr>
          <th>Project</th>
          <th>Link</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>Local HTTPS with Traefik: traefik.me is dead, long live sslip.io</title><link>https://guillaumedelre.github.io/2025/04/17/local-https-with-traefik-traefik.me-is-dead-long-live-sslip.io/</link><pubDate>Thu, 17 Apr 2025 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2025/04/17/local-https-with-traefik-traefik.me-is-dead-long-live-sslip.io/</guid><description>traefik.me&amp;#39;s wildcard cert was revoked in 2025. Here&amp;#39;s how to replace it with sslip.io, mkcert, and a local Traefik setup.</description><content:encoded><![CDATA[<p>The setup seemed perfect. Point <code>*.traefik.me</code> at 127.0.0.1, download a wildcard certificate from the same domain, drop it into Traefik, and every local service gets a clean HTTPS URL with no IP in the address bar. No Let&rsquo;s Encrypt rate limits, no <code>mkcert</code> to explain to teammates, no self-signed warnings to click through. Just <code>https://myapp.traefik.me</code> and a green padlock.</p>
<p>Then in March 2025, Let&rsquo;s Encrypt revoked the certificate. The wildcard cert for traefik.me is gone and it&rsquo;s not coming back.</p>
<h2 id="what-traefikme-was-actually-selling">What traefik.me was actually selling</h2>
<p>traefik.me is a wildcard DNS resolver. Type <code>anything.traefik.me</code> and it resolves to 127.0.0.1. Type <code>anything.10.0.0.1.traefik.me</code> and it resolves to 10.0.0.1. No account, no configuration, no infrastructure to maintain. The DNS part still works fine, by the way.</p>
<p>The certificate was the bonus: a wildcard cert for <code>*.traefik.me</code> that pyrou, the maintainer, generated with Let&rsquo;s Encrypt and distributed at <code>https://traefik.me/cert.pem</code> and <code>https://traefik.me/privkey.pem</code>. It was convenient precisely because it was shared: download, drop into Traefik, done.</p>
<p>Sharing a private key is why it died.</p>
<p>The CA/Browser Forum Baseline Requirements, section 9.6.3, require subscribers to &ldquo;maintain sole control&rdquo; over their private key. Distributing it to anyone who visits a URL is the exact opposite of sole control. Let&rsquo;s Encrypt sent a notice, blocked future issuance for the domain, and revoked the existing certificate. Pyrou confirmed the situation and recommended mkcert as an alternative. The project will live on as a DNS resolver only.</p>
<p>The cert had already been revoked twice before 2025. Third time was the last.</p>
<h2 id="sslipio-does-the-same-thing-differently">sslip.io does the same thing, differently</h2>
<p>sslip.io is also a wildcard DNS resolver, with one difference: the IP is encoded in the hostname rather than resolved from a fallback. <code>10-0-0-1.sslip.io</code> resolves to <code>10.0.0.1</code>. <code>myapp.192-168-1-10.sslip.io</code> resolves to <code>192.168.1.10</code>. IPv6 works too.</p>
<p>The infrastructure behind sslip.io is also more visible: three nameservers in Singapore, the US, and Poland, handling over 10,000 requests per second, with public monitoring. About 1,000 GitHub stars and active maintenance under the Apache 2.0 licence.</p>
<p>Strip away the certificate story and the comparison is pretty straightforward:</p>
<table>
  <thead>
      <tr>
          <th></th>
          <th>traefik.me</th>
          <th>sslip.io</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>DNS wildcard</td>
          <td>yes</td>
          <td>yes</td>
      </tr>
      <tr>
          <td>Fallback to 127.0.0.1</td>
          <td>yes</td>
          <td>no</td>
      </tr>
      <tr>
          <td>IPv6</td>
          <td>no</td>
          <td>yes</td>
      </tr>
      <tr>
          <td>Wildcard certificate</td>
          <td><del>yes</del> revoked</td>
          <td>no</td>
      </tr>
      <tr>
          <td>Infrastructure</td>
          <td>opaque</td>
          <td>documented</td>
      </tr>
      <tr>
          <td>Project activity</td>
          <td>stalled</td>
          <td>active</td>
      </tr>
  </tbody>
</table>
<p>traefik.me&rsquo;s only remaining advantage is the 127.0.0.1 fallback: URLs without an IP segment. That matters if you really want <code>myapp.traefik.me</code> instead of <code>myapp.127-0-0-1.sslip.io</code>. Whether that difference is worth the infrastructure uncertainty is a short conversation.</p>
<h2 id="mkcert-fills-the-gap">mkcert fills the gap</h2>
<p>mkcert creates a local certificate authority, installs it in the system trust store and whatever browsers it finds, then issues certificates signed by that CA. Browsers see a trusted chain. No warning, no click-through, no &ldquo;proceed anyway&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>That&rsquo;s the one-time setup. After that, generating a certificate is one command:</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"># produces _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>The limitation is that mkcert&rsquo;s CA is local. Other machines on the network won&rsquo;t trust it by default. For a solo dev setup that&rsquo;s fine. For a shared team environment, you&rsquo;d need to distribute the CA root, which is essentially the same operational problem traefik.me was trying to avoid, just smaller in scope.</p>
<h2 id="the-traefik-configuration">The Traefik configuration</h2>
<p>The setup is the same regardless of which DNS service you pick. Traefik needs the certificate mounted as a volume and a static file provider pointing at a TLS configuration file.</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>The key practice: run Traefik in its own Compose project, separate from the services it routes to. Each service project connects to Traefik through a shared external network. Start and stop services independently without touching the reverse proxy.</p>
<p>Start by creating the external network once:</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 alone, owning the network:</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>Copy the mkcert output into <code>./certs/</code>, rename to <code>cert.pem</code> and <code>key.pem</code>, then:</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 is up, listening on 80 and 443, watching Docker for new containers. Nothing is routed yet.</p>
<p><strong><code>whoami/compose.yml</code></strong> - a service that joins the same network:</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 detects the new container via the Docker provider, reads its labels, and adds the route. <code>https://whoami.127-0-0-1.sslip.io</code> responds immediately. Bring <code>whoami</code> down and the route disappears. Traefik keeps running without noticing.</p>
<p>The <code>external: true</code> declaration is the load-bearing line. Without it, Compose creates a project-scoped network: Traefik and <code>whoami</code> end up on different networks and can&rsquo;t reach each other, even though both are running. The external network is the shared bus every service project must explicitly opt into.</p>
<p>If you prefer traefik.me URLs, replace the mkcert command and the host label:</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>The DNS fallback to 127.0.0.1 handles the rest.</p>
<h2 id="what-the-traefikme-story-actually-teaches">What the traefik.me story actually teaches</h2>
<p>The certificate distribution model was always fragile. A &ldquo;public-private key pair&rdquo; is a contradiction in terms. Every revocation was a warning that the next one could be permanent. Eventually it was.</p>
<p>The lesson isn&rsquo;t specific to traefik.me. Any service that provides convenience by quietly removing a security boundary will eventually hit that boundary. mkcert is the right tool for this problem because it operates entirely within your own trust domain: you generate the CA, you install it, you issue the certificates. Nothing depends on a third party&rsquo;s continued willingness to bend certificate issuance rules.</p>
<p>sslip.io solves the DNS part cleanly. mkcert solves the TLS part cleanly. They compose well. The traefik.me setup was simpler, for a while. Until it wasn&rsquo;t.</p>
]]></content:encoded></item><item><title>From Vagrant to Docker Compose: a retrospective</title><link>https://guillaumedelre.github.io/2022/04/18/from-vagrant-to-docker-compose-a-retrospective/</link><pubDate>Mon, 18 Apr 2022 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2022/04/18/from-vagrant-to-docker-compose-a-retrospective/</guid><description>Why we replaced Vagrant with Docker Compose: the real friction points, the migration path, and what we&amp;#39;d do differently.</description><content:encoded><![CDATA[<p>I ran Vagrant for years. A Vagrantfile per project, a shared base box, a provision script that worked on Tuesday but not on Thursday. The promise was simple: reproducible environments for everyone on the team. The reality was more complicated.</p>
<h2 id="the-vagrant-years">The Vagrant years</h2>
<p>The setup made sense at the time. One VM per project, provisioned with shell scripts or Ansible, shared via a versioned Vagrantfile. Onboarding was theoretically <code>vagrant up</code> and you&rsquo;re done.</p>
<p>In practice, it was <code>vagrant up</code>, wait four minutes, watch the provision fail on a package that changed its download URL, fix it, reprovision, wait again. Vagrantfiles accumulated configuration over time: workarounds for specific machines, OS version pinning, memory tweaks for the team member whose laptop had 8GB. The files became historical documents nobody wanted to touch.</p>
<p>The VM itself was the other problem. Booting took time. Running took memory and CPU that could have gone to the application. File syncing between host and guest added latency that made PHP apps feel slower than they had any right to be. The overhead was significant for what was ultimately just &ldquo;run a web server.&rdquo;</p>
<p>We lived with it because everyone did. Vagrant was the standard for local PHP development, and the alternative (each developer managing their own LAMP stack) was clearly worse.</p>
<h2 id="the-project-that-changed-the-model">The project that changed the model</h2>
<p>The shift wasn&rsquo;t a decision we made. It was a project that arrived already containerized.</p>
<p>A new client project had a <code>docker-compose.yml</code> at the root, a <code>Dockerfile</code>, and a README that said <code>docker compose up</code>. We ran it. The containers started in seconds. PHP-FPM, nginx, PostgreSQL, Redis: all running, all networked, no provisioning step. Stop the containers, start them again, same state.</p>
<p>The contrast with our Vagrant setup was immediate. Not faster by a percentage: faster by a different order. And the Compose file was actually readable: each service, its image, its volumes, its environment variables, its dependencies. Compared to a provision script that SSHed into a VM and ran apt-get, this was legible.</p>
<p>We migrated everything. Not gradually, all at once, over a sprint. Every project got a <code>docker-compose.yml</code>. Every Vagrantfile was deleted. The transition was the most painful three weeks of infrastructure work I remember, and also the most clearly worth it.</p>
<h2 id="what-docker-compose-actually-changed">What docker-compose actually changed</h2>
<p>Beyond the speed, Compose changed the mental model. Vagrant abstracted a machine. Compose abstracted a set of processes. The distinction matters: with Compose, you can stop the database without stopping the application server, scale a worker service independently, swap the PostgreSQL image for a newer version without touching anything else.</p>
<p>The <code>services</code> declaration also replaced the VM provisioning problem entirely. If a new developer joins, they don&rsquo;t run a provision script that may or may not work on their OS version. They run <code>docker compose up</code> and get the exact same images everyone else runs.</p>
<p>CI/CD got simpler too. The same <code>docker-compose.yml</code> that ran locally could run in the pipeline. The environment parity that Vagrant promised but rarely delivered was actually real with Compose.</p>
<h2 id="the-quiet-deprecation">The quiet deprecation</h2>
<p>For years, the command was <code>docker-compose</code>: a separate binary, installed independently from Docker itself, written in Python, versioned independently. We used it, it worked, nobody thought much about it.</p>
<p>At some point a colleague mentioned that Docker had integrated Compose directly into the <code>docker</code> CLI. The new command was <code>docker compose</code>, no hyphen, Go rewrite, bundled with Docker Desktop. The old <code>docker-compose</code> binary was deprecated.</p>
<p>We had been using v1 for two years after v2 shipped. Our CI scripts, our Makefiles, our documentation all said <code>docker-compose</code>. Nothing had broken because Docker maintained the old binary for a long time. But the ecosystem had moved on quietly, and we&rsquo;d missed it.</p>
<p>The migration was trivial: a hyphen removed from every script, a few aliases updated. The lesson was less trivial. Infrastructure tooling evolves without ceremony. The announcement happened, the blog posts were written, the deprecation notices were there. We just weren&rsquo;t paying attention.</p>
<h2 id="the-actual-retrospective">The actual retrospective</h2>
<p>Looking back across Vagrant → <code>docker-compose</code> → <code>docker compose</code>, the pattern is less about the tools and more about the defaults.</p>
<p>Vagrant defaulted to &ldquo;it works on my VM.&rdquo; The overhead of sharing that VM was permanent.</p>
<p>Compose defaulted to &ldquo;it works in these containers.&rdquo; The images are the artifacts; the host machine is irrelevant.</p>
<p>The hyphen between <code>docker</code> and <code>compose</code> was always cosmetic. What mattered was the shift from provisioned machines to declarative services. That shift happened the day we ran a project someone else containerized and realized we never wanted to go back.</p>
]]></content:encoded></item><item><title>Controlling a USB missile launcher over HTTP with FastAPI and Docker</title><link>https://guillaumedelre.github.io/2017/02/21/controlling-a-usb-missile-launcher-over-http-with-fastapi-and-docker/</link><pubDate>Tue, 21 Feb 2017 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2017/02/21/controlling-a-usb-missile-launcher-over-http-with-fastapi-and-docker/</guid><description>How we wired a USB foam missile launcher to the CI pipeline — and what Docker, udev, and WSL2 had to say about it.</description><content:encoded><![CDATA[<p>The rule was simple: whoever breaks the CI build owes the team a coffee. It worked fine for a while. Then someone suggested we needed something with more immediate feedback. Something physical. Something that fires.</p>
<p>A <a href="http://www.dreamcheeky.com/thunder-missile-launcher" target="_blank" rel="noopener noreferrer">Dream Cheeky Thunder</a> appeared on a desk shortly after. Four foam missiles, a USB cable, and a very clear team consensus: hook it to the cluster, wire it to the build pipeline, and let the CI decide who deserves a volley.</p>
<p>The launcher needed to respond to HTTP calls from anywhere on the network. No driver, no GUI, no manual aiming. Just an endpoint that makes it shoot in the direction of the guilty party&rsquo;s desk.</p>
<p>This is the story of <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="no-sdk-no-docs-no-problem">No SDK, no docs, no problem</h2>
<p>Dream Cheeky never published a protocol spec. The launcher speaks raw USB HID, and the only starting point was a vendored Python script from 2012 floating around in forum threads. Vendor ID <code>0x2123</code>, product ID <code>0x1010</code>, and a handful of control bytes that someone had reverse engineered years before.</p>
<p>That was enough. The protocol is simple: send a byte sequence to move the motors, send another to fire. The tricky part is that the launcher has no position feedback. No encoders, no limit switches beyond the physical hard stops at the extremes. You drive it blind.</p>
<h2 id="from-usb-to-http">From USB to HTTP</h2>
<p>The CI pipeline needed to trigger the launcher over the network. A local script wasn&rsquo;t going to cut it — the launcher had to be reachable from any machine on the cluster, including the build server. So: a REST API.</p>
<p>FastAPI was the obvious choice. The targeting flow from the CI side ends up being three HTTP calls:</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 to known position</span>
</span></span><span style="display:flex;"><span>curl -X POST http://localhost:8000/yaw/20    <span style="color:#75715e"># rotate toward guilty desk</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>The <code>/park</code> call matters more than it looks. Since the launcher has no position feedback, the server estimates the current angle by tracking how long the motors have been running. That estimate drifts. Bumping the hardware, interrupting a command, or just the imprecision of time-based tracking — they all accumulate. Parking drives both motors against the physical hard stops at full sweep, which guarantees alignment regardless of what the server thinks it knows. Skip it, and your aim is a guess.</p>
<p>The full API reference is <a href="https://github.com/guillaumedelre/dream-cheeky-thunder/blob/develop/docs/api.md" target="_blank" rel="noopener noreferrer">in the repo</a>. There&rsquo;s also a web UI if you prefer clicking over <code>curl</code>.</p>
<h2 id="docker-knows-nothing-about-usb">Docker knows nothing about USB</h2>
<p>Running this in a Docker container on the cluster was where the fun really started: containers don&rsquo;t see USB devices by default.</p>
<p>The <code>devices</code> mount in <code>compose.yaml</code> exposes the USB bus to the container:</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>Not enough. First run came back with <code>USBError: [Errno 13] Access denied</code>. The device node is there inside the container, but it inherits permissions from the host, and on the host only root can open it by default.</p>
<p>The fix is a udev rule. Drop one file into <code>/etc/udev/rules.d/</code>, and the kernel sets the right group and permissions when the device plugs in. After that, the container user can open it without needing elevated privileges. The rule ships with the project, setup instructions are <a href="https://github.com/guillaumedelre/dream-cheeky-thunder/blob/develop/docs/setup-linux.md" target="_blank" rel="noopener noreferrer">in the docs</a>.</p>
<h2 id="wsl2-made-it-interesting">WSL2 made it interesting</h2>
<p>Half the team runs Windows with Docker Desktop on WSL2. That&rsquo;s where things got creative.</p>
<p>WSL2 has no access to USB devices by default: the Windows kernel holds them, and the <code>devices</code> mount alone does nothing because WSL2 simply doesn&rsquo;t see the hardware. The fix is <a href="https://github.com/dorssel/usbipd-win" target="_blank" rel="noopener noreferrer">usbipd-win</a>, which forwards the USB device from Windows into the WSL2 kernel over IP. Once that&rsquo;s done, the Linux path works exactly the same: udev rule, <code>devices</code> mount, done.</p>
<p>The attachment doesn&rsquo;t survive reboots, though. usbipd v4+ added a policy mechanism that automates reconnection, which killed the &ldquo;it worked yesterday&rdquo; mystery that had been annoying us for days.</p>
<h2 id="what-actually-surprised-us">What actually surprised us</h2>
<p><strong>Time-based positioning works well enough.</strong> No encoders meant we went in expecting the angle tracking to be basically useless. Turns out, parking before every sequence kept it accurate enough to reliably aim at a specific desk. Not millimeter precision, but foam missile precision is fine.</p>
<p><strong>The <code>devices</code> mount is necessary but not sufficient.</strong> The permission error was confusing precisely because the device was clearly visible inside the container. The udev rule is the bit most tutorials quietly skip.</p>
<p><strong>The coffee rule was never the same after this.</strong> Once the launcher was wired to the pipeline, broken builds suddenly became a lot more motivating to fix.</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 — HTTP control for the Dream Cheeky Thunder USB missile launcher. Pull requests welcome, especially if you have a better angle calibration approach.</p>
</div>
]]></content:encoded></item></channel></rss>