<?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>Self-Hosted on Guillaume Delré</title><link>https://guillaumedelre.github.io/tags/self-hosted/</link><description>Recent content in Self-Hosted 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/self-hosted/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></channel></rss>