<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Devops on Guillaume Delré</title><link>https://guillaumedelre.github.io/tags/devops/</link><description>Recent content in Devops on Guillaume Delré</description><generator>Hugo</generator><language>en</language><lastBuildDate>Tue, 17 Feb 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://guillaumedelre.github.io/tags/devops/index.xml" rel="self" type="application/rss+xml"/><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>Observability on FrankenPHP containers before the cloud migration was done</title><link>https://guillaumedelre.github.io/2025/06/07/observability-on-frankenphp-containers-before-the-cloud-migration-was-done/</link><pubDate>Sat, 07 Jun 2025 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2025/06/07/observability-on-frankenphp-containers-before-the-cloud-migration-was-done/</guid><description>Moving 14 PHP microservices to the cloud meant needing observability before the migration was done, not after. FrankenPHP&amp;#39;s Caddy layer made that possible with two lines of config.</description><content:encoded><![CDATA[<p>When you run workloads on-premise, you can get away with almost no observability. You have SSH. You have <code>top</code>. You have someone who knows that the authentication service always spikes on Monday mornings. Institutional knowledge substitutes for instrumentation, and nobody budgets the time to replace it.</p>
<p>Then you migrate to the cloud. The institutional knowledge doesn&rsquo;t follow. The SSH access is gone or inconvenient. And for the first time, you&rsquo;re staring at fourteen FrankenPHP containers with no idea what they&rsquo;re actually doing.</p>
<p>That&rsquo;s the moment you need metrics. Not eventually. Before the migration is done.</p>
<h2 id="the-problem-with-doing-it-properly">The problem with doing it properly</h2>
<p>The correct way to instrument a PHP service for Prometheus: add a client library, write counters and histograms around what you care about, expose a <code>/metrics</code> route, update the scrape config. For one service, that&rsquo;s a reasonable afternoon. For fourteen services mid-migration, it&rsquo;s a multi-sprint project that competes with everything else that needs to move.</p>
<p>The calculation is awkward. You need metrics to trust that the migration is going well. But adding metrics to everything before the migration means the migration takes longer. And the longer it takes, the more you need metrics to know where you stand.</p>
<p>Something had to give.</p>
<h2 id="what-frankenphp-carries-without-announcing-it">What FrankenPHP carries without announcing it</h2>
<p>FrankenPHP is not a PHP runtime that happens to use <a href="https://caddyserver.com" target="_blank" rel="noopener noreferrer">Caddy</a> as its web server. The relationship is inverted: Caddy is the server, and PHP is a Caddy module. Every HTTP request flows through Caddy before it reaches application code.</p>
<p>Caddy ships with a Prometheus-compatible metrics endpoint built in. No plugin, no extra binary. Enable the admin API and it&rsquo;s there.</p>
<p><code>CADDY_GLOBAL_OPTIONS</code> is a FrankenPHP environment variable that injects directives directly into Caddy&rsquo;s global configuration block. Two lines are enough:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">environment</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">CADDY_GLOBAL_OPTIONS</span>: |<span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        admin 0.0.0.0:2019
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        metrics</span>
</span></span></code></pre></div><p><code>admin 0.0.0.0:2019</code> binds the admin API to all network interfaces - the default is localhost-only, which is unreachable from a Prometheus container on the same network. <code>metrics</code> enables the endpoint.</p>
<p>After that, every container responds to <code>GET :2019/metrics</code> with a full Prometheus payload. Request counts labeled by status code, latency histograms, active connections. No route added to the application. No <code>composer require</code>. No Dockerfile change.</p>
<p>One environment variable, added to each service definition in a single commit. Fourteen scrape targets, all producing data.</p>
<h2 id="a-usable-picture-in-grafana">A usable picture in Grafana</h2>
<p>The Prometheus scrape config lists every service by its container name:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">scrape_configs</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">job_name</span>: <span style="color:#ae81ff">caddy</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">metrics_path</span>: <span style="color:#ae81ff">/metrics</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">static_configs</span>:
</span></span><span style="display:flex;"><span>          - <span style="color:#f92672">targets</span>:
</span></span><span style="display:flex;"><span>              - <span style="color:#ae81ff">authentication:2019</span>
</span></span><span style="display:flex;"><span>              - <span style="color:#ae81ff">content:2019</span>
</span></span><span style="display:flex;"><span>              - <span style="color:#ae81ff">media:2019</span>
</span></span><span style="display:flex;"><span>              <span style="color:#75715e"># all 14 services</span>
</span></span></code></pre></div><p>Grafana sits on top of Prometheus. The Caddy community dashboard gives you request rates, error rates, and latency percentiles per service, per endpoint, per status code. Within a day of the migration landing in the new environment, there was something meaningful to look at.</p>
<p>The data tier follows the same logic: exporters for PostgreSQL, Redis, and RabbitMQ scrape at the infrastructure level without touching application code. Community dashboards exist for all of them.</p>
<h2 id="what-this-baseline-actually-covers">What this baseline actually covers</h2>
<p>The HTTP metrics from Caddy are web server metrics, not application metrics. They answer: is this service receiving traffic, is it returning errors, how fast is it responding. The kind of questions you ask when something is broken and you need to triage in the dark.</p>
<p>They don&rsquo;t answer: how many items were processed today, which background job is stuck, what is the business impact of this latency spike. For those you need application instrumentation, and that work still exists when you have specific things to measure.</p>
<p>But in a migration context, that distinction matters less than it sounds. The things that break during a cloud migration are mostly infrastructure problems: a service that can&rsquo;t reach its database, a memory limit that was set too low, a queue consumer that stopped picking up messages. Those are exactly the things the baseline covers.</p>
<p>Getting instrumentation right for business-level events can wait until the platform is stable. Getting enough visibility to know whether the migration succeeded cannot.</p>
]]></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></channel></rss>