<?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>Containers – Serverküche</title><link>https://serverkueche.de/en/categories/containers/</link><description>Containers – Neueste Beiträge von Serverküche</description><generator>Hugo</generator><language>en-US</language><managingEditor>feedback@serverkueche.de (Serverküche)</managingEditor><webMaster>feedback@serverkueche.de (Serverküche)</webMaster><copyright>2026 Serverküche</copyright><lastBuildDate>Tue, 21 Jul 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://serverkueche.de/en/categories/containers/index.xml" rel="self" type="application/rss+xml"/><item><title>Monitoring with Grafana &amp; Prometheus: your server in live dashboards</title><link>https://serverkueche.de/en/tutorials/monitoring-grafana-prometheus/</link><pubDate>Tue, 21 Jul 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/monitoring-grafana-prometheus/</guid><description>A complete monitoring stack of Prometheus, node-exporter, cAdvisor and Grafana behind Traefik – with live metrics for host and containers.</description><content:encoded><![CDATA[<p><a href="/en/tutorials/uptime-kuma-monitoring/">Uptime Kuma</a> tells you <em>whether</em> a service is
running. This stack tells you <em>how</em> it&rsquo;s doing: CPU, RAM, disk, network and the load of each
individual container – as live dashboards with history. We build the combination that has
become the de facto standard in self-hosting: Prometheus collects, Grafana shows.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>By the end, a complete <strong>monitoring stack</strong> runs behind your Traefik proxy, reachable at
<code>https://grafana.YOUR_DOMAIN</code> with valid HTTPS. Four building blocks interlock:</p>
<ul>
<li><strong>Prometheus 3.13</strong> – the time-series database. It <em>pulls</em> (scrapes) metrics from the other
services at intervals and stores them with a timestamp.</li>
<li><strong>node-exporter 1.12</strong> – delivers the <strong>host metrics</strong>: CPU, memory, load, disks, network
interfaces.</li>
<li><strong>cAdvisor 0.55</strong> – delivers the <strong>container metrics</strong>: CPU and RAM per running container,
so you see which service is eating.</li>
<li><strong>Grafana 13.1</strong> – the interface: ready-made dashboards that turn the Prometheus data into
graphs and displays.</li>
</ul>
<p>The decisive difference from Uptime Kuma: instead of &ldquo;green/red&rdquo; you get <strong>trends</strong> – you
recognize that the RAM has been slowly filling up for days, <em>before</em> the server swaps. Only
Grafana is reachable from outside; Prometheus, node-exporter and cAdvisor get no public route.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-herb-400 bg-herb-50 dark:border-herb-700 dark:bg-herb-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🧑‍🍳</span>How big does the server need to be?
  </p>
  <div class="prose-kitchen text-sm">The stack itself is frugal – in the test it occupied only a few hundred MB of RAM. The
storage hunger comes from Prometheus&rsquo;s <strong>retention time</strong>: the longer you keep metrics and
the more services you monitor, the more space and RAM the time-series database needs. Because
monitoring usually runs <em>in addition</em> to your actual apps, we recommend the VPS 2000 with more
reserve – for a pure test setup the smaller one is enough too.</div>
</div>
<div class="not-prose my-6 overflow-hidden rounded-xl border border-paprika-200 bg-paprika-50 dark:border-paprika-800 dark:bg-paprika-900/20">
  <div class="flex items-center justify-between border-b border-paprika-200 bg-paprika-100 px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-paprika-700 dark:border-paprika-800 dark:bg-paprika-900/40 dark:text-paprika-300">
    <span>🍳 Recommendation</span>
    <span title="Links marked with * are affiliate links.">Ad</span>
  </div>
  <div class="flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between">
    <div>
      <p class="text-lg font-bold text-slate-900 dark:text-white">VPS 2000 G12</p>
      <p class="mt-1 text-sm text-slate-600 dark:text-slate-300">8 vCores · 16 GB RAM · 512 GB NVMe</p>
      <p class="mt-1 text-sm font-semibold text-paprika-700 dark:text-paprika-400">from €19.24/month</p>
      <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">Enough RAM and storage to run monitoring alongside your apps.</p>
    </div>
    <a href="https://www.netcup.com/en/server/vps/vps-2000-g12-iv-12m?ref=44083" rel="sponsored noopener" target="_blank"
   class="inline-flex shrink-0 items-center justify-center rounded-lg bg-paprika-600 px-5 py-2.5 font-semibold text-white transition-colors hover:bg-paprika-700">
  Go to netcup →
</a>

  </div><div class="px-4 pb-4"><p class="not-prose my-3 flex flex-wrap items-center gap-x-2 gap-y-1 rounded-lg border border-herb-500/40 bg-herb-50 px-3 py-2 text-sm text-slate-700 dark:bg-herb-900/20 dark:text-slate-200">
  <span>💶 <strong>5 € voucher</strong> for new netcup customers:</span>
  <code class="rounded bg-white px-2 py-0.5 font-mono text-sm font-semibold text-herb-800 dark:bg-slate-800 dark:text-herb-400">36nc17844976032</code>
  <span class="text-xs text-slate-500 dark:text-slate-400">(new customers only, no domains)</span>
</p></div>
</div>

<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>Confidence with <strong>Docker Compose</strong> – services, volumes, networks. If that&rsquo;s still new:
<a href="/en/tutorials/docker-compose-basics/">understanding Docker Compose</a>.</li>
<li>A running <strong>Traefik reverse proxy</strong> with the shared <code>proxy</code> network and the Let&rsquo;s Encrypt
resolver <code>le</code> – set up as in <a href="/en/tutorials/traefik-reverse-proxy/">reverse proxy with Traefik</a>.</li>
<li>A subdomain <code>grafana.YOUR_DOMAIN</code> whose DNS record (A/AAAA) points to your server IP – see
<a href="/en/tutorials/connect-domain-to-server/">connecting a domain to your server</a>.</li>
</ul>
<h2 id="step-by-step">Step by step</h2>
<h3 id="step-1-understand-the-architecture">Step 1: Understand the architecture</h3>
<p>Before we create files, the picture behind it – otherwise you debug blind. Prometheus works
on the <strong>pull principle</strong>: the services don&rsquo;t send their values somewhere, but Prometheus
queries them actively. Every &ldquo;exporter&rdquo; provides its metrics under <code>/metrics</code>, Prometheus
fetches them every 15 seconds and stores them in its time-series database. Grafana in turn
queries Prometheus and draws the graphs from it.</p>
<p>Why pull instead of push at all? Because this way Prometheus always knows whether a target is
<em>alive</em>: if an exporter doesn&rsquo;t respond, that&rsquo;s itself information (<code>up = 0</code>). You need no
agents that actively &ldquo;phone home&rdquo;, and you can simply enter every new target into the
configuration. An exporter is nothing magical – it&rsquo;s a tiny web server that delivers a text
list of current measurements under <code>/metrics</code>.</p>
<p>From this follows the security rule of this setup: node-exporter, cAdvisor and Prometheus have
<strong>no authentication</strong>. So they must <strong>never</strong> stand open on the internet. Prometheus and
cAdvisor we bind only to the internal Docker network <code>monitoring</code>; node-exporter runs in the
<strong>host network</strong> (why is explained in step 4), but stays unreachable from outside thanks to
<a href="/en/tutorials/firewall-ufw-setup/">UFW</a> and the
<a href="/en/tutorials/netcup-firewall-setup/">netcup firewall</a>. Only Grafana is additionally on the
<code>proxy</code> network and gets a Traefik route – the only service with a login and a public address.</p>
<h3 id="step-2-create-the-project-folder-and-configure-prometheus">Step 2: Create the project folder and configure Prometheus</h3>
<p>Create the folder structure:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mkdir -p ~/monitoring/prometheus ~/monitoring/grafana/provisioning/datasources
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> ~/monitoring</span></span></code></pre></div>
</div>
<p>Create the Prometheus configuration. It defines <em>whom</em> Prometheus queries at what interval:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># prometheus/prometheus.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">global</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">scrape_interval</span><span class="p">:</span><span class="w"> </span><span class="l">15s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">scrape_timeout</span><span class="p">:</span><span class="w"> </span><span class="l">10s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">scrape_configs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">job_name</span><span class="p">:</span><span class="w"> </span><span class="l">prometheus</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">static_configs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">targets</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;localhost:9090&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">job_name</span><span class="p">:</span><span class="w"> </span><span class="l">node</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">static_configs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">targets</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;host.docker.internal:9100&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">job_name</span><span class="p">:</span><span class="w"> </span><span class="l">cadvisor</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">static_configs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">targets</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;cadvisor:8080&#34;</span><span class="p">]</span></span></span></code></pre></div>
</div>
<p>Important: <code>cadvisor:8080</code> appears as a <strong>service name</strong> – Docker resolves it automatically in
the shared <code>monitoring</code> network. The node-exporter, on the other hand, runs in the <strong>host
network</strong> (step 4 explains why) and is therefore addressed via <code>host.docker.internal:9100</code> –
Prometheus gets this name mapped to the Docker host&rsquo;s IP via <code>extra_hosts</code> shortly.
<code>scrape_interval: 15s</code> is a good compromise – often enough for meaningful curves, seldom
enough to hardly create load.</p>
<h3 id="step-3-provision-the-grafana-data-source-automatically">Step 3: Provision the Grafana data source automatically</h3>
<p>Instead of clicking the Prometheus data source into Grafana by hand later, we set it up via
<strong>provisioning</strong> – as a file. That&rsquo;s reproducible: after a rebuild, everything is immediately
back, without clicking.</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># grafana/provisioning/datasources/datasource.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="m">1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">datasources</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Prometheus</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">prometheus</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">access</span><span class="p">:</span><span class="w"> </span><span class="l">proxy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">url</span><span class="p">:</span><span class="w"> </span><span class="l">http://prometheus:9090</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">isDefault</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">editable</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span></span></span></code></pre></div>
</div>
<p><code>access: proxy</code> means that <strong>Grafana itself</strong> (server-side) queries Prometheus – the user&rsquo;s
browser never talks to Prometheus directly. That&rsquo;s exactly why Prometheus can stay internal.</p>
<h3 id="step-4-the-compose-stack">Step 4: The Compose stack</h3>
<p>Now the heart. Create the <code>compose.yaml</code>. Replace <code>grafana.YOUR_DOMAIN</code> and the Grafana
password:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">prometheus</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">prom/prometheus:v3.13.1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">prometheus</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--config.file=/etc/prometheus/prometheus.yml&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--storage.tsdb.path=/prometheus&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--storage.tsdb.retention.time=30d&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">prom_data:/prometheus</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">extra_hosts</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;host.docker.internal:host-gateway&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">monitoring]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">node-exporter</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">prom/node-exporter:v1.12.1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">node-exporter</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--path.rootfs=/host&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">network_mode</span><span class="p">:</span><span class="w"> </span><span class="l">host</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">pid</span><span class="p">:</span><span class="w"> </span><span class="l">host</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;/:/host:ro,rslave&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cadvisor</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">gcr.io/cadvisor/cadvisor:v0.55.1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">cadvisor</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">privileged</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">devices</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/dev/kmsg</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/:/rootfs:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/var/run:/var/run:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/sys:/sys:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/var/lib/docker/:/var/lib/docker:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/dev/disk/:/dev/disk:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">monitoring]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">grafana</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">grafana/grafana:13.1.0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">grafana</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">prometheus]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">GF_SECURITY_ADMIN_USER</span><span class="p">:</span><span class="w"> </span><span class="l">admin</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">GF_SECURITY_ADMIN_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">YOUR_GRAFANA_PASSWORD</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">GF_SERVER_ROOT_URL</span><span class="p">:</span><span class="w"> </span><span class="l">https://grafana.YOUR_DOMAIN</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">GF_USERS_ALLOW_SIGN_UP</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;false&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">grafana_data:/var/lib/grafana</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./grafana/provisioning:/etc/grafana/provisioning:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">labels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.enable=true&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.grafana.rule=Host(`grafana.YOUR_DOMAIN`)&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.grafana.entrypoints=websecure&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.grafana.tls.certresolver=le&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.services.grafana.loadbalancer.server.port=3000&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">monitoring, proxy]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">prom_data</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">grafana_data</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">monitoring</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">proxy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">external</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span></span></span></code></pre></div>
</div>
<p>What matters here:</p>
<ul>
<li><strong><code>--storage.tsdb.retention.time=30d</code></strong> keeps metrics for 30 days. More = more meaning, but
also more storage. 30 days is a good start.</li>
<li><strong>node-exporter</strong> needs the <strong>host root directory</strong> (<code>/:/host:ro,rslave</code>), <code>pid: host</code>
<strong>and <code>network_mode: host</code></strong> to read real host values instead of container values.
<code>--path.rootfs=/host</code> tells it where the host view is (read-only, <code>:ro</code>). The host network
isn&rsquo;t a nice-to-have: <code>/proc/net</code> is bound to the network namespace – in a normal bridge
network, node-exporter would see <strong>only its own container interface</strong> (effectively just the
scrape traffic), not the real host interfaces. The price: node-exporter thus listens on port
<code>9100</code> of the host – your <a href="/en/tutorials/firewall-ufw-setup/">firewall</a> keeps the port closed
from outside, and Prometheus reaches it via the <code>extra_hosts</code> entry (<code>host-gateway</code>).</li>
<li><strong>cAdvisor</strong> needs <code>privileged: true</code>, the device <code>/dev/kmsg</code> and read mounts on <code>/sys</code> and
<code>/var/lib/docker</code> to detect all containers. That&rsquo;s unavoidable for container monitoring –
that&rsquo;s why cAdvisor stays strictly internal.</li>
<li><strong>Only <code>grafana</code></strong> carries Traefik labels and is on the <code>proxy</code> network. The internal port is
<strong>3000</strong>. <code>GF_SERVER_ROOT_URL</code> must be the public HTTPS address, otherwise logins and
redirects break behind the proxy.</li>
<li><strong><code>GF_USERS_ALLOW_SIGN_UP: &quot;false&quot;</code></strong> prevents strangers from creating their own account. Set
a <strong>strong admin password</strong> – Grafana is publicly accessible.</li>
</ul>
<h3 id="step-5-start-and-check-the-prometheus-targets">Step 5: Start and check the Prometheus targets</h3>
<p>Start the complete stack:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose up -d</span></span></code></pre></div>
</div>
<p>The first time, Docker downloads four images – that takes a moment. Check that all containers
are running:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose ps</span></span></code></pre></div>
</div>
<p>All four services (node-exporter, cadvisor, prometheus, grafana) should be <code>running</code>. Now the
decisive test: <strong>Does Prometheus see its targets?</strong> Since Prometheus stays internal, we query
it from a short-lived container that&rsquo;s on the same network (Compose calls the network
<code>monitoring_monitoring</code> – project folder plus network name):</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker run --rm --network monitoring_monitoring curlimages/curl:8.21.0 <span class="se">\
</span></span></span><span class="line"><span class="cl">  -s http://prometheus:9090/api/v1/query?query<span class="o">=</span>up</span></span></code></pre></div>
</div>
<p>In the JSON response there&rsquo;s a <code>&quot;value&quot;</code> with <code>&quot;1&quot;</code> for each target – that means &ldquo;reachable&rdquo;.
In the test all three jobs (<code>prometheus</code>, <code>node</code>, <code>cadvisor</code>) returned a <code>1</code>. A <code>0</code> means
Prometheus can&rsquo;t scrape the target – then the section &ldquo;When things go wrong&rdquo; helps.</p>
<h3 id="step-6-open-grafana-and-import-dashboards">Step 6: Open Grafana and import dashboards</h3>
<p>Open <code>https://grafana.YOUR_DOMAIN</code>. Traefik fetches the certificate on the first access (a few
seconds). Log in with <code>admin</code> and your password from the <code>compose.yaml</code>. Thanks to
provisioning, the Prometheus data source is already connected – you find it under
<strong>Connections → Data sources</strong>:</p>
<p><figure class="my-6"><img src="/en/tutorials/monitoring-grafana-prometheus/grafana-datasource_hu_399dc2e7f48b1788.webp" srcset="/en/tutorials/monitoring-grafana-prometheus/grafana-datasource_hu_7e0a1a479133fa45.webp 480w, /en/tutorials/monitoring-grafana-prometheus/grafana-datasource_hu_399dc2e7f48b1788.webp 768w, /en/tutorials/monitoring-grafana-prometheus/grafana-datasource_hu_c28443e38bd35aa1.webp 1200w, /en/tutorials/monitoring-grafana-prometheus/grafana-datasource_hu_b685c9265d189cbd.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/monitoring-grafana-prometheus/grafana-datasource_hu_2947ead1a73e98f1.webp"
    alt="The provisioned Prometheus data source in Grafana with the internal URL http://prometheus:9090" title="The data source is provisioned via file – Grafana reports it as preconfigured"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The data source is provisioned via file – Grafana reports it as preconfigured</figcaption></figure></p>
<p>Now the dashboards. Instead of building panels ourselves, we import two proven ones from the
Grafana community. Go to <strong>Dashboards → New → Import</strong>, enter the ID and click <strong>Load</strong>:</p>
<p><figure class="my-6"><img src="/en/tutorials/monitoring-grafana-prometheus/grafana-import_hu_daeb0cdee97f29d5.webp" srcset="/en/tutorials/monitoring-grafana-prometheus/grafana-import_hu_ebf84a9b89d945c1.webp 480w, /en/tutorials/monitoring-grafana-prometheus/grafana-import_hu_daeb0cdee97f29d5.webp 768w, /en/tutorials/monitoring-grafana-prometheus/grafana-import_hu_b8420823a49d711c.webp 1200w, /en/tutorials/monitoring-grafana-prometheus/grafana-import_hu_26166f8d6195fe6c.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/monitoring-grafana-prometheus/grafana-import_hu_c29ed6a2a42cab3a.webp"
    alt="The Grafana import dialog loads the &ldquo;Node Exporter Full&rdquo; dashboard from Grafana.com" title="Import a dashboard by ID – here \&#34;Node Exporter Full\&#34; (ID 1860)"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Import a dashboard by ID – here \&#34;Node Exporter Full\&#34; (ID 1860)</figcaption></figure></p>
<p>Import these two:</p>
<ul>
<li><strong>1860</strong> – &ldquo;Node Exporter Full&rdquo;: the comprehensive host dashboard.</li>
<li><strong>19792</strong> – &ldquo;cAdvisor Dashboard&rdquo;: metrics per container.</li>
</ul>
<p>On import, select the <strong>Prometheus</strong> data source each time. After that, &ldquo;Node Exporter Full&rdquo;
shows your host in real time – CPU usage, used RAM, disk, network, uptime:</p>
<p><figure class="my-6"><img src="/en/tutorials/monitoring-grafana-prometheus/grafana-node-dashboard_hu_caaac511989edd0f.webp" srcset="/en/tutorials/monitoring-grafana-prometheus/grafana-node-dashboard_hu_96720c854316db6f.webp 480w, /en/tutorials/monitoring-grafana-prometheus/grafana-node-dashboard_hu_caaac511989edd0f.webp 768w, /en/tutorials/monitoring-grafana-prometheus/grafana-node-dashboard_hu_b90af51a41705c01.webp 1200w, /en/tutorials/monitoring-grafana-prometheus/grafana-node-dashboard_hu_3e6bb19e3a185f34.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/monitoring-grafana-prometheus/grafana-node-dashboard_hu_d1052dba15ec2b7e.webp"
    alt="The Grafana dashboard &ldquo;Node Exporter Full&rdquo; shows live values of the server: CPU 1 percent, RAM 14.7 percent used, 4 CPU cores, 8 GB RAM, uptime 2 days" title="Node Exporter Full with real live data from the test server"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Node Exporter Full with real live data from the test server</figcaption></figure></p>
<p>The cAdvisor dashboard breaks the same load down to <strong>individual containers</strong> – here you
immediately see which service draws how much CPU and RAM:</p>
<p><figure class="my-6"><img src="/en/tutorials/monitoring-grafana-prometheus/grafana-container-dashboard_hu_69ee0d4d1a780625.webp" srcset="/en/tutorials/monitoring-grafana-prometheus/grafana-container-dashboard_hu_9f644b6b9b88fc4f.webp 480w, /en/tutorials/monitoring-grafana-prometheus/grafana-container-dashboard_hu_69ee0d4d1a780625.webp 768w, /en/tutorials/monitoring-grafana-prometheus/grafana-container-dashboard_hu_db3e2021d7ef215.webp 1200w, /en/tutorials/monitoring-grafana-prometheus/grafana-container-dashboard_hu_41bcb30176acfef2.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/monitoring-grafana-prometheus/grafana-container-dashboard_hu_412631eaba466c56.webp"
    alt="The cAdvisor dashboard shows CPU consumption per container – each color is a container like grafana, prometheus or traefik" title="cAdvisor: CPU and memory consumption per container, broken down by name"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">cAdvisor: CPU and memory consumption per container, broken down by name</figcaption></figure></p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-herb-400 bg-herb-50 dark:border-herb-700 dark:bg-herb-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🧑‍🍳</span>Provision dashboards too
  </p>
  <div class="prose-kitchen text-sm">Like the data source, dashboards can also be provisioned via file (under
<code>grafana/provisioning/dashboards/</code> a <code>.yml</code> plus the dashboard JSONs). For getting started, the
import via ID is faster; once your setup is in place, provisioning is worth it so all dashboards
are automatically back after a rebuild.</div>
</div>
<h3 id="step-7-read-the-most-important-metrics-correctly">Step 7: Read the most important metrics correctly</h3>
<p>A dashboard full of curves is of little use if you don&rsquo;t know what to watch for. These five
values in the &ldquo;Node Exporter Full&rdquo; dashboard tell you almost everything about the health of
your server:</p>
<ul>
<li><strong>CPU Busy.</strong> Short spikes are normal. It gets critical when the usage stays <em>permanently</em>
high – then the server is the bottleneck.</li>
<li><strong>Sys Load.</strong> The load should on average be below the number of your CPU cores (in the test:
4 cores → load permanently above 4 is a warning sign of overload).</li>
<li><strong>RAM Used.</strong> Important: Linux uses free memory as a file cache – so &ldquo;used&rdquo; isn&rsquo;t the same as
&ldquo;tight&rdquo;. Only when little stays free <em>besides</em> the cache and <strong>SWAP Used</strong> rises does the
server get sluggish. It&rsquo;s exactly this creeping rise you want to see early.</li>
<li><strong>Root FS Used.</strong> The classic among server outages is the full disk. The trend shows you the
fill rate – so you recognize days in advance when it&rsquo;s getting tight.</li>
<li><strong>Network Traffic.</strong> Unusual spikes even though nothing is running can indicate a backup, an
update or – in the bad case – unwanted traffic.</li>
</ul>
<p>In the cAdvisor dashboard the most exciting question is: <strong>Which container draws the load?</strong> If
your server suddenly gets slow, you see here at a glance whether it&rsquo;s due to a single app – and
don&rsquo;t have to guess. This turns &ldquo;something is slow&rdquo; into a targeted &ldquo;the photo import in
container X is eating the CPU right now&rdquo;.</p>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong>A target is at <code>up = 0</code> or &ldquo;DOWN&rdquo; in Prometheus.</strong> Prometheus can&rsquo;t reach the exporter. For
<code>cadvisor</code>, check that it&rsquo;s on the same <code>monitoring</code> network and the name and port are exactly
right (<code>cadvisor:8080</code>). For the <code>node</code> job, check the <code>extra_hosts</code> entry on the Prometheus
service and the target <code>host.docker.internal:9100</code> – without both, Prometheus doesn&rsquo;t find the
node-exporter in the host network. After changes to the <code>prometheus.yml</code>, restart the
Prometheus container: <code>docker compose restart prometheus</code>.</p>
<p><strong>The cAdvisor dashboard partly shows &ldquo;No data&rdquo; or cAdvisor won&rsquo;t start.</strong> If the mounts or
<code>privileged: true</code> are missing, cAdvisor doesn&rsquo;t see the containers. Check the <code>cadvisor</code> block
(mounts, <code>devices: /dev/kmsg</code>). Individual &ldquo;No data&rdquo; panels are normal – some metrics (like CPU
throttling) only exist if you&rsquo;ve set CPU limits on the containers.</p>
<p><strong>Grafana doesn&rsquo;t load correctly behind Traefik – login fails or the layout is broken.</strong> Almost
always <code>GF_SERVER_ROOT_URL</code> is wrong. It must be exactly the public address
(<code>https://grafana.YOUR_DOMAIN</code>). Also check that the Traefik label
<code>loadbalancer.server.port=3000</code> is set – Grafana listens on 3000 internally.</p>
<p><strong>Grafana shows &ldquo;No data&rdquo; in the panels, even though the targets are <code>up</code>.</strong> Usually the time
range. A fresh stack has no history yet – set &ldquo;Last 15 minutes&rdquo; at the top right and wait a few
minutes for data to accumulate. Also check that the <strong>Prometheus</strong> data source was selected on
dashboard import.</p>
<p><strong>The disk space on the server grows steadily.</strong> That&rsquo;s the Prometheus TSDB. Reduce
<code>--storage.tsdb.retention.time</code> (e.g. to <code>15d</code>) or monitor fewer targets. The <code>prom_data</code>
volume grows with the number of metrics × retention time.</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<ul>
<li><strong>What you must back up.</strong> Important is the <strong><code>grafana_data</code> volume</strong>: it contains your users,
settings and self-built dashboards. Back it up encrypted and off-site with
<a href="/en/tutorials/restic-backups/">Restic</a>. The Prometheus metrics (<code>prom_data</code>) are pure
measurements – a loss is bearable, a backup optional.</li>
<li><strong>Updates.</strong> The image tags are pinned (<code>prometheus:v3.13.1</code>, <code>grafana:13.1.0</code> …). To update,
raise the tags, then <code>docker compose pull &amp;&amp; docker compose up -d</code>. Read the release notes
first – Grafana in particular occasionally changes defaults between major versions. You get
cAdvisor from <code>gcr.io</code>; there <code>v0.55.1</code> is currently the latest available image version.</li>
<li><strong>Security.</strong> Grafana is publicly accessible – assign a strong admin password and leave
<code>GF_USERS_ALLOW_SIGN_UP</code> at <code>false</code>. Prometheus and cAdvisor stay internal; don&rsquo;t expose them
&ldquo;just quickly&rdquo; for debugging, they have no authentication.</li>
<li><strong>The next step: alerts.</strong> A dashboard only helps if you look at it. Grafana can trigger
alerts itself at thresholds (RAM &gt; 90%, disk almost full) and report e.g. via push. This turns
the pretty dashboard into an early-warning system – a good connection to your existing
<a href="/en/tutorials/uptime-kuma-monitoring/">Uptime Kuma</a>.</li>
<li><strong>Honest about the effort:</strong> the stack runs largely on its own after setup. Occasionally plan
a look at the retention time and storage usage, and bump the versions roughly quarterly.</li>
</ul>
]]></content:encoded></item><item><title>Setting up Traefik: reverse proxy with automatic HTTPS</title><link>https://serverkueche.de/en/tutorials/traefik-reverse-proxy/</link><pubDate>Sat, 18 Jul 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/traefik-reverse-proxy/</guid><description>Traefik as a reverse proxy in front of your containers, with automatic Let's Encrypt certificates: every app gets a domain and HTTPS via a few labels.</description><content:encoded><![CDATA[<p>This is the most important building block of the Serverküche. A <strong>reverse proxy</strong>
takes in all requests on ports 80 and 443 and distributes them to the right
container based on the domain – and <strong>Traefik</strong> fetches the HTTPS certificates fully
automatically from Let&rsquo;s Encrypt. From here on, every further app gets its domain and
its TLS with a few lines of labels, without you ever touching a certificate by hand
again.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>By the end, <strong>Traefik v3</strong> runs as the central entry point on your server. It listens
on ports 80/443, detects new containers automatically via Docker labels, redirects
HTTP to HTTPS automatically, and obtains a valid <strong>Let&rsquo;s Encrypt certificate</strong> for
every domain. As the first app, we hang <code>whoami</code> behind the proxy – a tiny test
service that shows routing and TLS are working. A secured dashboard comes on top.</p>
<p>The pattern from this tutorial – a shared <code>proxy</code> network plus a few labels – repeats
afterwards in <strong>every</strong> app recipe.</p>
<p>A request always passes through the same four stations in Traefik – this vocabulary
helps you debug:</p>
<ol>
<li><strong>Entrypoint</strong> – the port on which the request arrives (<code>web</code> = 80,
<code>websecure</code> = 443).</li>
<li><strong>Router</strong> – decides based on a <strong>rule</strong> (usually <code>Host(...)</code>) whether this request
belongs to an app.</li>
<li><strong>Middleware</strong> <em>(optional)</em> – modifies the request along the way (e.g.
HTTPS redirect, basic auth, security headers).</li>
<li><strong>Service</strong> – the container that ultimately responds.</li>
</ol>
<p>Debugging mnemonic: &ldquo;<strong>Entrypoint → Router → Middleware → Service</strong>&rdquo;. If a request
ends up nowhere, it&rsquo;s almost always the router (wrong domain) or the network (service
not reachable) at fault.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li><a href="/en/tutorials/install-docker/">Docker + Compose installed</a> and the
<a href="/en/tutorials/docker-compose-basics/">Compose basics</a> understood</li>
<li>A <a href="/en/tutorials/connect-domain-to-server/">domain connected to the server</a>:
<code>YOUR_DOMAIN</code> and the subdomains must resolve to the server via A/AAAA</li>
<li><strong>Ports 80 and 443 are reachable from the internet</strong> – Let&rsquo;s Encrypt uses them to
verify that you own the domain. Open firewalls accordingly (see <a href="/en/tutorials/firewall-ufw-setup/">setting up a
firewall with UFW</a>).</li>
</ul>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-amber-400 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">⚠️</span>No resolving domain, no certificate
  </p>
  <div class="prose-kitchen text-sm">Let&rsquo;s Encrypt only issues certificates for domains it can reach. Check <strong>beforehand</strong>
with <code>dig +short YOUR_DOMAIN</code> that your server IP comes back. If the record still
points nowhere, certificate issuance fails – that&rsquo;s the most common Traefik error of
all.</div>
</div>
<h2 id="step-by-step">Step by step</h2>
<h3 id="step-1-create-the-shared-proxy-network">Step 1: Create the shared proxy network</h3>
<p>Traefik and all apps must share a Docker network so Traefik can reach the containers.
We create it <strong>once</strong> and explicitly, so later stacks can simply dock onto it:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker network create proxy</span></span></code></pre></div>
</div>
<p>Check:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker network ls <span class="p">|</span> grep proxy</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">c442da869c47   proxy     bridge    local</span></span></code></pre></div>
</div>
<p>This network is independent of the individual Compose projects – that&rsquo;s why we later
include it as <code>external</code>.</p>
<h3 id="step-2-create-the-traefik-project">Step 2: Create the Traefik project</h3>
<p>Create a dedicated folder for Traefik and, inside it, the file where the certificates
are stored:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mkdir -p ~/traefik <span class="o">&amp;&amp;</span> <span class="nb">cd</span> ~/traefik
</span></span><span class="line"><span class="cl">touch acme.json
</span></span><span class="line"><span class="cl">chmod <span class="m">600</span> acme.json</span></span></code></pre></div>
</div>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-paprika-400 bg-paprika-50 dark:border-paprika-700 dark:bg-paprika-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🔥</span>acme.json needs 600
  </p>
  <div class="prose-kitchen text-sm">Without <code>chmod 600 acme.json</code>, <strong>Traefik skips the Let&rsquo;s Encrypt resolver</strong>: the
container does start, but issues no valid certificate – you land on Traefik&rsquo;s
self-signed emergency certificate. The log then reads <code>permissions 644 for /acme.json are too open, please use 600</code>. The file contains your private keys – only
the owner may read it.</div>
</div>
<h3 id="step-3-the-traefik-composeyaml">Step 3: The Traefik compose.yaml</h3>
<p>Now the central configuration. It&rsquo;s long, but every line has a purpose – the
explanation follows right below:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">traefik</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">traefik:v3.7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># Dashboard (secured in step 7)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--api.dashboard=true&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># Docker as source; only containers with traefik.enable=true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--providers.docker=true&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--providers.docker.exposedbydefault=false&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--providers.docker.network=proxy&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># Entrypoints: 80 (HTTP) and 443 (HTTPS)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--entrypoints.web.address=:80&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--entrypoints.websecure.address=:443&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># Redirect everything from HTTP to HTTPS automatically</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--entrypoints.web.http.redirections.entrypoint.to=websecure&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--entrypoints.web.http.redirections.entrypoint.scheme=https&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># Let&#39;s Encrypt resolver named &#34;le&#34; via HTTP challenge</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--certificatesresolvers.le.acme.email=YOUR_EMAIL&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--certificatesresolvers.le.acme.storage=/acme.json&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--certificatesresolvers.le.acme.httpchallenge=true&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--certificatesresolvers.le.acme.httpchallenge.entrypoint=web&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;80:80&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;443:443&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/var/run/docker.sock:/var/run/docker.sock:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./acme.json:/acme.json</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">proxy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">proxy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">external</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span></span></span></code></pre></div>
</div>
<p>The most important blocks:</p>
<ul>
<li><strong><code>providers.docker</code> + <code>exposedbydefault=false</code></strong>: Traefik watches the Docker
socket, but only containers that explicitly carry <code>traefik.enable=true</code>. No service
is accidentally made public.</li>
<li><strong><code>providers.docker.network=proxy</code></strong>: tells Traefik which network it uses to reach
the containers – important when containers are attached to several networks.</li>
<li><strong><code>entrypoints web/websecure</code></strong>: ports 80 and 443. The two <code>redirections</code> lines send
every HTTP call automatically to HTTPS.</li>
<li><strong><code>certificatesresolvers.le</code></strong>: the Let&rsquo;s Encrypt resolver. Via the <strong>HTTP
challenge</strong>, Traefik proves to Let&rsquo;s Encrypt that the domain points to this server
and stores the certificate in <code>acme.json</code>. For this, <strong>port 80 must stay reachable
from outside</strong> – even if your app only runs over HTTPS, because the challenge comes
over HTTP. Let&rsquo;s Encrypt uses the <code>acme.email</code> solely for warnings about expiring
certificates; enter a real address.</li>
<li>The <strong>Docker socket</strong> is mounted read-only (<code>:ro</code>) – Traefik must read it, but not
write to it.</li>
</ul>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-herb-400 bg-herb-50 dark:border-herb-700 dark:bg-herb-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🧑‍🍳</span>Test with the staging server first
  </p>
  <div class="prose-kitchen text-sm">Let&rsquo;s Encrypt has strict <strong>rate limits</strong> for production certificates. While you&rsquo;re
still building, add
<code>--certificatesresolvers.le.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory</code>
as a test. This delivers test certificates (shown as insecure in the browser) without
a limit. Once everything works, remove the line, <strong>empty <code>acme.json</code></strong> (<code>&gt; acme.json</code>)
and restart Traefik – then the real certificate comes.</div>
</div>
<p>Start Traefik:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose up -d
</span></span><span class="line"><span class="cl">docker compose logs -f traefik</span></span></code></pre></div>
</div>
<p>The logs must show <strong>no</strong> <code>ERR</code> about ACME or the provider. <code>Ctrl+C</code> only ends the
following, not the container.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-herb-400 bg-herb-50 dark:border-herb-700 dark:bg-herb-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🧑‍🍳</span>An empty log is a good sign
  </p>
  <div class="prose-kitchen text-sm">Traefik v3 writes <strong>only errors</strong> at the default log level. So an empty log output
means: everything is running. If you want to see more during setup (every detected
router, every ACME request), add <code>--log.level=INFO</code> to the <code>command</code> block and
restart.</div>
</div>
<h3 id="step-4-the-first-app-behind-traefik-whoami">Step 4: The first app behind Traefik (whoami)</h3>
<p><code>whoami</code> is a tiny service that returns the received request – perfect for testing. Own
folder, own <code>compose.yaml</code>:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mkdir -p ~/whoami <span class="o">&amp;&amp;</span> <span class="nb">cd</span> ~/whoami</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">whoami</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">traefik/whoami:v1.11</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">labels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.enable=true&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.whoami.rule=Host(`whoami.YOUR_DOMAIN`)&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.whoami.entrypoints=websecure&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.whoami.tls.certresolver=le&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">proxy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">proxy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">external</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span></span></span></code></pre></div>
</div>
<p>These are the four labels you&rsquo;ll need again and again from now on:</p>
<ul>
<li><strong><code>traefik.enable=true</code></strong> – only then does Traefik touch the container.</li>
<li><strong><code>...routers.whoami.rule=Host(...)</code></strong> – at which domain this container responds.
<code>whoami</code> is a freely chosen router name (unique per container).</li>
<li><strong><code>...entrypoints=websecure</code></strong> – reachable over HTTPS (443).</li>
<li><strong><code>...tls.certresolver=le</code></strong> – fetch the certificate via the resolver <code>le</code> defined in
step 3.</li>
</ul>
<p>Important: the service has <strong>no <code>ports:</code></strong> – it&rsquo;s only reachable via Traefik, not
directly from outside. And it&rsquo;s on the <strong><code>proxy</code> network</strong>, otherwise Traefik won&rsquo;t
find it.</p>
<p>Create the DNS record <code>whoami.YOUR_DOMAIN</code> beforehand (A/AAAA to the server IP, <a href="/en/tutorials/connect-domain-to-server/">as in
the DNS tutorial</a>), then:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose up -d</span></span></code></pre></div>
</div>
<p>Open <code>https://whoami.YOUR_DOMAIN</code> in the browser. On the first call, certificate
issuance takes a few seconds; after that you see a valid padlock icon and a text
output like:</p>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Hostname: bfa4b8dee3d0
</span></span><span class="line"><span class="cl">IP: 127.0.0.1
</span></span><span class="line"><span class="cl">IP: 172.19.0.3
</span></span><span class="line"><span class="cl">RemoteAddr: 172.19.0.2:49734
</span></span><span class="line"><span class="cl">GET / HTTP/1.1
</span></span><span class="line"><span class="cl">Host: whoami.YOUR_DOMAIN</span></span></code></pre></div>
</div>
<p>The <code>Host:</code> line confirms that Traefik routed correctly to this container based on the
domain. Exactly this behavior – a request for <code>whoami.YOUR_DOMAIN</code> lands at the
whoami container, an unknown domain gets a <strong>404</strong> – is the heart of the reverse proxy.</p>
<p><strong>Check which CA issued the certificate.</strong> This separates &ldquo;HTTPS is running&rdquo; from &ldquo;I
only see Traefik&rsquo;s emergency certificate&rdquo;:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">echo</span> <span class="p">|</span> openssl s_client -connect whoami.YOUR_DOMAIN:443 -servername whoami.YOUR_DOMAIN 2&gt;/dev/null <span class="p">|</span> openssl x509 -noout -issuer</span></span></code></pre></div>
</div>
<p>As long as you use the <strong>staging</strong> server (as recommended in step 3), a test issuer
appears there – the browser still shows the certificate as insecure:</p>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">issuer=C=US, O=Let&#39;s Encrypt, CN=(STAGING) Ersatz Emmer YR2</span></span></code></pre></div>
</div>
<p>If <code>TRAEFIK DEFAULT CERT</code> appears here, the resolver fetched no certificate – then go
to troubleshooting below. If a Let&rsquo;s Encrypt issuer is there, the whole chain works.</p>
<h3 id="step-5-switch-to-the-real-certificate">Step 5: Switch to the real certificate</h3>
<p>Once staging runs cleanly, you fetch the real certificate that&rsquo;s valid in the browser.
Remove the <code>caserver</code> line from the <code>traefik</code> service (step 3), <strong>empty the staging
certificates</strong> and restart Traefik:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">&gt; acme.json                 <span class="c1"># discards the staging certificates (chmod 600 stays)</span>
</span></span><span class="line"><span class="cl">docker compose up -d</span></span></code></pre></div>
</div>
<p>On the next call, Traefik fetches a fresh production certificate. In the <code>issuer</code> line
from above, the <code>(STAGING)</code> then disappears, and the browser shows a valid padlock.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-amber-400 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">⚠️</span>Staging first, then production
  </p>
  <div class="prose-kitchen text-sm">Let&rsquo;s Encrypt has hard <strong>rate limits</strong> on production certificates (a few per domain per
week). Only switch to production once routing and challenge demonstrably work with
staging – otherwise you lock yourself out of the domain for hours.</div>
</div>
<h3 id="step-6-the-http-to-https-redirect">Step 6: The HTTP-to-HTTPS redirect</h3>
<p>You already enabled this globally in step 3 (the two <code>redirections</code> lines). Test:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -sI http://whoami.YOUR_DOMAIN <span class="p">|</span> grep -iE <span class="s1">&#39;HTTP/|location&#39;</span></span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">HTTP/1.1 308 Permanent Redirect
</span></span><span class="line"><span class="cl">location: https://whoami.YOUR_DOMAIN/</span></span></code></pre></div>
</div>
<p>So every unencrypted call is automatically redirected to HTTPS – you no longer have to
think about it in any app.</p>
<h3 id="step-7-secure-the-dashboard">Step 7: Secure the dashboard</h3>
<p>Traefik comes with a dashboard that shows which routers and services are active. Never
put it <strong>unprotected</strong> on the internet. We secure it with basic auth and hang it on a
dedicated subdomain. First create a user:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo apt install -y apache2-utils
</span></span><span class="line"><span class="cl">htpasswd -nbB admin YOUR_PASSWORD</span></span></code></pre></div>
</div>
<p>The output (<code>admin:$2y$05$...</code>) goes into the labels. <strong>In the <code>compose.yaml</code>, double
every <code>$</code></strong> (<code>$$</code>), otherwise Compose interprets it as a variable. Add to the
<code>traefik</code> service:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">    </span><span class="nt">labels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.enable=true&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.dashboard.rule=Host(`traefik.YOUR_DOMAIN`)&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.dashboard.entrypoints=websecure&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.dashboard.tls.certresolver=le&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.dashboard.service=api@internal&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.dashboard.middlewares=dashboard-auth&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.middlewares.dashboard-auth.basicauth.users=admin:$$2y$$05$$...&#34;</span></span></span></code></pre></div>
</div>
<p>After <code>docker compose up -d</code> you reach the dashboard at <code>https://traefik.YOUR_DOMAIN</code>
– after a password prompt.</p>
<p>In the dashboard you see, under <strong>HTTP → Routers</strong>, every detected router (with its
<code>Host(...)</code> rule), under <strong>Services</strong> the containers behind them, and under
<strong>Middlewares</strong> your building blocks like <code>dashboard-auth</code>. A router turns <strong>green</strong>
when rule, service and – for <code>websecure</code> – the certificate are correct; <strong>red</strong> means
something is missing (usually network or host rule). That makes the dashboard your
first look at &ldquo;why isn&rsquo;t my app responding?&rdquo;.</p>
<p><figure class="my-6"><img src="/en/tutorials/traefik-reverse-proxy/traefik-dashboard_hu_5849a1b2dfad3fb8.webp" srcset="/en/tutorials/traefik-reverse-proxy/traefik-dashboard_hu_9cab1a77d2d2e383.webp 480w, /en/tutorials/traefik-reverse-proxy/traefik-dashboard_hu_5849a1b2dfad3fb8.webp 768w, /en/tutorials/traefik-reverse-proxy/traefik-dashboard_hu_477dc2b839a1268b.webp 1200w, /en/tutorials/traefik-reverse-proxy/traefik-dashboard_hu_6912160ac6e2d97f.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/traefik-reverse-proxy/traefik-dashboard_hu_5dfd90f74c454165.webp"
    alt="The Traefik dashboard: entrypoints (web/websecure), detected HTTP routers and services – all green" title="The Traefik dashboard at a glance"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The Traefik dashboard at a glance</figcaption></figure></p>
<p>Under <strong>HTTP Routers</strong> you see each router individually – with its <code>Host(...)</code> rule,
the entrypoint, the TLS status (padlock) and the provider <code>docker</code>. This is how you
check at a glance whether your labels were detected correctly:</p>
<p><figure class="my-6"><img src="/en/tutorials/traefik-reverse-proxy/traefik-routers_hu_2424c2a45482f193.webp" srcset="/en/tutorials/traefik-reverse-proxy/traefik-routers_hu_6a794561852d450.webp 480w, /en/tutorials/traefik-reverse-proxy/traefik-routers_hu_2424c2a45482f193.webp 768w, /en/tutorials/traefik-reverse-proxy/traefik-routers_hu_cbd2f006d49919b9.webp 1200w, /en/tutorials/traefik-reverse-proxy/traefik-routers_hu_1ef1bcad6b9c811.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/traefik-reverse-proxy/traefik-routers_hu_37c445c54306ba89.webp"
    alt="The router list in the Traefik dashboard: per app the host rule, the entrypoint (websecure), TLS and the Docker provider" title="HTTP routers with host rules, entrypoint and provider"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">HTTP routers with host rules, entrypoint and provider</figcaption></figure></p>
<h3 id="step-8-security-headers-as-a-reusable-middleware">Step 8: Security headers as a reusable middleware</h3>
<p>A <strong>middleware</strong> hooks in between router and service and modifies the request or
response. A set of security headers belongs on every public app – defined once,
attached everywhere. Define the middleware on any container (common: on Traefik
itself) via labels:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.middlewares.sec-headers.headers.stsSeconds=31536000&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.middlewares.sec-headers.headers.stsIncludeSubdomains=true&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.middlewares.sec-headers.headers.frameDeny=true&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.middlewares.sec-headers.headers.contentTypeNosniff=true&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.middlewares.sec-headers.headers.browserXssFilter=true&#34;</span></span></span></code></pre></div>
</div>
<p>What the most important ones do:</p>
<ul>
<li><strong><code>stsSeconds</code> (HSTS)</strong> – the browser will address the domain only over HTTPS from
now on. One year (<code>31536000</code>) is the usual value.</li>
<li><strong><code>frameDeny</code></strong> – forbids embedding in foreign <code>&lt;iframe&gt;</code>s (clickjacking protection).</li>
<li><strong><code>contentTypeNosniff</code></strong> – the browser doesn&rsquo;t guess the content type but takes the
one delivered – rules out a whole class of attacks.</li>
</ul>
<p>Attach it to an app via a label (adjust the router name):</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.whoami.middlewares=sec-headers&#34;</span></span></span></code></pre></div>
</div>
<p>Multiple middlewares are given comma-separated (<code>sec-headers,dashboard-auth</code>) and are
run <strong>in that order</strong>. This is how you gradually build a toolbox (auth, rate limiting,
IP whitelist) that every app can reuse.</p>
<p>If a header set should apply <strong>to all</strong> apps, you don&rsquo;t attach the middleware to each
router individually, but globally to the entrypoint – one line in Traefik&rsquo;s <code>command</code>
block:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--entrypoints.websecure.http.middlewares=sec-headers@docker&#34;</span></span></span></code></pre></div>
</div>
<p>The suffix <code>@docker</code> tells Traefik the middleware comes from the Docker provider (where
you defined it via label).</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-herb-400 bg-herb-50 dark:border-herb-700 dark:bg-herb-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🧑‍🍳</span>Tip
  </p>
  <div class="prose-kitchen text-sm">Only arm HSTS with <code>stsSeconds</code> once HTTPS runs <strong>reliably</strong> and permanently. The
browser remembers the setting stubbornly – a broken certificate would then be hard to
work around for the full duration.</div>
</div>
<h3 id="step-9-the-recipe-for-every-further-app">Step 9: The recipe for every further app</h3>
<p>From now on, every app is the same pattern – you never have to touch Traefik again. A
new application gets its own folder with a <code>compose.yaml</code>, is on the <code>proxy</code> network,
and carries exactly these labels (adjust router name and domain; for a port ≠ 80 add
the <code>loadbalancer</code> label as well):</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">myapp</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">YOUR_IMAGE:TAG</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">labels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.enable=true&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.myapp.rule=Host(`app.YOUR_DOMAIN`)&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.myapp.entrypoints=websecure&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.myapp.tls.certresolver=le&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># only needed if the app does NOT listen on port 80:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.services.myapp.loadbalancer.server.port=YOUR_PORT&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">proxy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">proxy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">external</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span></span></span></code></pre></div>
</div>
<p><code>docker compose up -d</code>, set the DNS record to the server IP, done – domain and HTTPS
are created automatically. This is exactly how
<a href="/en/tutorials/uptime-kuma-monitoring/">the first real app (Uptime Kuma)</a> hangs behind
the proxy.</p>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong>&ldquo;Certificate invalid&rdquo; in the browser, or the Traefik log shows ACME errors.</strong> The
three usual reasons: (1) The DNS record doesn&rsquo;t point to the server yet – check <code>dig +short YOUR_DOMAIN</code>. (2) Port 80 isn&rsquo;t reachable from outside (firewall/netcup
firewall) – the HTTP challenge needs it. (3) You hit the <strong>rate limit</strong> of the
production CA – switch to the staging server (tip in step 3), test, then go back.</p>
<p><strong><code>404 page not found</code> when calling the app domain.</strong> Traefik doesn&rsquo;t know the route.
Check: Does the container have <code>traefik.enable=true</code>? Is it on the <strong><code>proxy</code> network</strong>?
Is the domain in the <code>Host(...)</code> rule exactly right (incl. subdomain)? The dashboard
(step 7) shows under &ldquo;HTTP Routers&rdquo; whether the router was registered.</p>
<p><strong>The browser shows Traefik&rsquo;s self-signed emergency certificate; the log reads
<code>permissions 644 for /acme.json are too open, please use 600</code>.</strong> Traefik is running but
skipped the ACME resolver – hence no real certificate. Run <code>chmod 600 acme.json</code> (step
2) and restart the container.</p>
<p><strong>No app is routed; the Traefik log repeats <code>client version 1.24 is too old. Minimum supported API version is 1.40</code>.</strong> Your Traefik version is too old for your Docker
engine – the Docker provider can no longer query the socket. Current Docker (Engine 29,
API level ≥ 1.40) needs <strong>Traefik ≥ v3.5</strong>; that&rsquo;s why this tutorial uses
<code>traefik:v3.7</code>. Older tags like <code>v3.3</code> no longer work with new Docker – bump the image
tag and run <code>docker compose up -d</code> again.</p>
<p><strong>Basic auth on the dashboard is rejected immediately / the router is missing.</strong> In the
<code>compose.yaml</code>, the <code>$</code> characters of the hash must be <strong>doubled</strong> (<code>$$</code>). Check the
hash once more outside with <code>htpasswd -nbB</code>.</p>
<p><strong><code>Gateway Timeout</code> or Traefik doesn&rsquo;t reach the container.</strong> Usually the app is on the
wrong network, or Traefik doesn&rsquo;t know which one is meant.
<code>providers.docker.network=proxy</code> in Traefik <strong>and</strong> <code>networks: [proxy]</code> on the app must
match.</p>
<p><strong><code>502 Bad Gateway</code>, even though the container is running.</strong> Traefik reaches the
container but hits the wrong port. If the app doesn&rsquo;t listen on 80, it needs the label
<code>traefik.http.services.&lt;name&gt;.loadbalancer.server.port=&lt;real-port&gt;</code>. This exact case
meets you with the first app in the next tutorial (Uptime Kuma on 3001).</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<ul>
<li><strong>You must back up <code>acme.json</code> and all <code>compose.yaml</code> files.</strong> With those, Traefik is
restored within minutes after a crash – the certificates don&rsquo;t need to be reissued
(which also spares the rate limit). We build an encrypted off-site backup of these
files in the <a href="/en/tutorials/restic-backups/">Restic tutorial</a>.</li>
<li><strong>Certificates renew automatically.</strong> Let&rsquo;s Encrypt certificates expire after 90
days; Traefik renews them in good time on its own – no cron job needed. You can check
the expiry date any time by appending <code>-dates</code> instead of <code>-issuer</code> to the <code>openssl</code>
command from step 4 (shows <code>notBefore</code>/<code>notAfter</code>).</li>
<li><strong>Maintain the Traefik version.</strong> The fixed tag (<code>traefik:v3.7</code>) means you apply
updates deliberately. Before jumping to a new minor/major version, read the release
notes – Traefik changed the label syntax between v2 and v3, for example.</li>
<li><strong>Keep an eye on the dashboard.</strong> A quick login shows whether all routers are &ldquo;green&rdquo;
– the fastest check of whether everything holds after a deploy.</li>
</ul>
<p>From now on the path is the same for every app: container onto the <code>proxy</code> network,
four labels on it, set the DNS record – and a publicly reachable service with HTTPS is
ready. As the <strong>first real app</strong>, in the next recipe we hang <strong>Uptime Kuma</strong> behind
Traefik and use it to monitor all subsequent services.</p>
]]></content:encoded></item><item><title>Understanding Docker Compose: services, volumes, networks</title><link>https://serverkueche.de/en/tutorials/docker-compose-basics/</link><pubDate>Fri, 17 Jul 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/docker-compose-basics/</guid><description>compose.yaml explained: services, volumes, networks and variables – the Docker vocabulary every app recipe in the Serverküche builds on.</description><content:encoded><![CDATA[<p>In the <a href="/en/tutorials/install-docker/">Docker tutorial</a> you installed the Compose
plugin – but we didn&rsquo;t explain it yet. We&rsquo;re catching up on that now, because almost
every app recipe here describes an application as a <strong><code>compose.yaml</code></strong>. Anyone who
reads this file like a shopping list can adapt every following tutorial instead of
just copying it.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>We build a small stack step by step and, along the way, get to know the five
building blocks that make up practically every <code>compose.yaml</code>: <strong>services</strong> (the
containers), <strong>ports</strong> (reachability from outside), <strong>volumes</strong> (persistent data),
<strong>networks</strong> (containers talking to each other) and <strong>environment variables</strong>
(configuration). By the end you&rsquo;ll understand why your data survives a <code>down</code>
command – and when it doesn&rsquo;t.</p>
<p>Tested with <strong>Docker Compose v5.3</strong> (the <code>docker compose</code> plugin without a hyphen –
not the old <code>docker-compose</code> v1 with a hyphen).</p>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>A <a href="/en/tutorials/harden-ssh/">hardened server</a> with <a href="/en/tutorials/install-docker/">Docker
installed</a> and the Compose plugin</li>
<li>The user is in the <code>docker</code> group (then you don&rsquo;t need <code>sudo</code> before <code>docker</code>)</li>
</ul>
<h2 id="step-by-step">Step by step</h2>
<h3 id="step-1-the-first-composeyaml">Step 1: The first compose.yaml</h3>
<p>A <code>compose.yaml</code> describes <strong>declaratively</strong> which containers should run – you say
<em>what</em> you want, not <em>how</em>. Create a project folder; the folder name later becomes
the prefix of all containers:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mkdir -p ~/compose-demo/site <span class="o">&amp;&amp;</span> <span class="nb">cd</span> ~/compose-demo</span></span></code></pre></div>
</div>
<p>Create a small HTML page that we&rsquo;ll serve in a moment:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s1">&#39;&lt;h1&gt;Hallo aus der Serverküche&lt;/h1&gt;&#39;</span> &gt; site/index.html</span></span></code></pre></div>
</div>
<p>And now the central file <code>compose.yaml</code>:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">web</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">nginx:1.31</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;8080:80&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./site:/usr/share/nginx/html:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span></span></span></code></pre></div>
</div>
<p>Line by line:</p>
<ul>
<li><strong><code>services:</code></strong> – the top level. Every entry below it is a container.</li>
<li><strong><code>web:</code></strong> – a freely chosen <strong>service name</strong>. Remember it, it later also becomes
the hostname on the internal network (step 3).</li>
<li><strong><code>image: nginx:1.31</code></strong> – the container image with a <strong>fixed tag</strong>. Never use
<code>latest</code>: <code>latest</code> changes under you and makes errors unreproducible.</li>
<li><strong><code>ports: - &quot;8080:80&quot;</code></strong> – format <code>HOST:CONTAINER</code>. Port <strong>80 in the container</strong>
is mapped to <strong>8080 on the server</strong>. The server is always on the left.</li>
<li><strong><code>volumes: - ./site:...:ro</code></strong> – the local <code>site</code> folder is mounted into the web
root, <code>:ro</code> = read-only. More on this in step 2.</li>
<li><strong><code>restart: unless-stopped</code></strong> – the container restarts automatically after a
reboot or crash, unless you stopped it yourself. The sensible default for server
services. The alternatives: <code>no</code> (never automatically – the default), <code>always</code>
(restarts even after a manual stop, rarely wanted) and <code>on-failure</code> (only after a
crash with an error code). For the vast majority of services, <code>unless-stopped</code> is
exactly right.</li>
</ul>
<p>Start the stack:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose up -d</span></span></code></pre></div>
</div>
<p><code>-d</code> means <strong>detached</strong> (in the background). The first time, Docker downloads the
image; after that you see at the end:</p>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl"> Network compose-demo_default  Created
</span></span><span class="line"><span class="cl"> Container compose-demo-web-1  Started</span></span></code></pre></div>
</div>
<p>Check the status:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose ps</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">NAME                 IMAGE        COMMAND                  SERVICE   CREATED          STATUS          PORTS
</span></span><span class="line"><span class="cl">compose-demo-web-1   nginx:1.31   &#34;/docker-entrypoint.…&#34;   web       10 seconds ago   Up 9 seconds    0.0.0.0:8080-&gt;80/tcp, [::]:8080-&gt;80/tcp</span></span></code></pre></div>
</div>
<p>The container is called <code>compose-demo-web-1</code> – <strong>project folder + service + number</strong>.
Test:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl localhost:8080</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">&lt;h1&gt;Hallo aus der Serverküche&lt;/h1&gt;</span></span></code></pre></div>
</div>
<p>Works. You see a service&rsquo;s logs with <code>docker compose logs web</code> (or <code>-f</code> to follow).</p>
<h3 id="step-2-volumes--where-your-data-really-lives">Step 2: Volumes – where your data really lives</h3>
<p>Containers are <strong>ephemeral</strong>: if you delete a container, everything written <em>inside</em>
the container is gone. So that data survives this, there are two kinds of volumes:</p>
<ul>
<li><strong>Bind mount</strong> (<code>./site:/usr/share/nginx/html</code>): a <strong>folder from your server</strong> is
mounted into the container. Ideal for config files you edit yourself.</li>
<li><strong>Named volume</strong> (<code>webdata:/var/lib/...</code>): storage <strong>managed by Docker</strong>. Ideal for
database data – performant and cleanly separated from the host.</li>
</ul>
<p>In step 1 we used a bind mount. For database-like services it looks like this –
change <code>compose.yaml</code> as a test:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">web</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">nginx:1.31</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">webdata:/usr/share/nginx/html</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="l">webdata:</span></span></span></code></pre></div>
</div>
<p>Named volumes must <strong>additionally</strong> be declared at the top level under <code>volumes:</code>.
After <code>docker compose up -d</code> the volume appears:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker volume ls</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">DRIVER    VOLUME NAME
</span></span><span class="line"><span class="cl">local     compose-demo_webdata</span></span></code></pre></div>
</div>
<p>Now comes the crucial point:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose down
</span></span><span class="line"><span class="cl">docker volume ls</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl"> Container compose-demo-web-1  Removed
</span></span><span class="line"><span class="cl"> Network compose-demo_default  Removed
</span></span><span class="line"><span class="cl">DRIVER    VOLUME NAME
</span></span><span class="line"><span class="cl">local     compose-demo_webdata</span></span></code></pre></div>
</div>
<p><code>docker compose down</code> removes the container and network – <strong>the volume stays</strong>. This
is exactly why your database contents survive an update. Remember the counterpart:</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-paprika-400 bg-paprika-50 dark:border-paprika-700 dark:bg-paprika-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🔥</span>down -v deletes data
  </p>
  <div class="prose-kitchen text-sm"><code>docker compose down -v</code> also deletes the <strong>named volumes</strong> – i.e. all the stack&rsquo;s
persistent data. Never type the <code>-v</code> out of reflex. For a plain restart,
<code>docker compose down</code> (without <code>-v</code>) is enough.</div>
</div>
<h3 id="step-3-networks--containers-talking-to-each-other">Step 3: Networks – containers talking to each other</h3>
<p>Compose automatically creates <strong>one network per project</strong> (seen above:
<code>compose-demo_default</code>). All services in it reach each other <strong>via their service
name</strong> as the hostname – no fiddling with IPs. That&rsquo;s the reason why, in app
tutorials, the application simply reaches its database under <code>db</code>.</p>
<p>As proof, a second service that talks to the first:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">web</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">nginx:1.31</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./site:/usr/share/nginx/html:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">ping</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">curlimages/curl:8.21.0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">web</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;curl&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;-s&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;http://web&#34;</span><span class="p">]</span></span></span></code></pre></div>
</div>
<ul>
<li><strong><code>depends_on: - web</code></strong> – Compose starts <code>web</code> <strong>before</strong> <code>ping</code>. (Careful: this
only waits for the <em>start</em>, not for &ldquo;fully booted&rdquo; – that&rsquo;s what healthchecks are
for, step 4.)</li>
<li><strong><code>command:</code></strong> – overrides the image&rsquo;s default command. <code>ping</code> calls <code>http://web</code>
– <strong><code>web</code> is the service name from the same file</strong>.</li>
</ul>
<p>Run only the <code>ping</code> service once:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose run --rm ping</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">&lt;h1&gt;Hallo aus der Serverküche&lt;/h1&gt;</span></span></code></pre></div>
</div>
<p><code>ping</code> reached <code>web</code> purely by name – without any port mapping. <strong>Remember: you only
need <code>ports:</code> to make a service reachable from <em>outside</em> (the internet).</strong> Containers
talk to each other over the internal network – that&rsquo;s why we later deliberately run
databases <em>without</em> <code>ports:</code>.</p>
<p>So far, every network lives <strong>inside</strong> a Compose project. But sometimes containers
from <strong>different</strong> projects should talk to each other – the classic example is a
<strong>reverse proxy</strong> sitting in front of many independent app stacks. For that there&rsquo;s
the <strong>external network</strong>: one you create <strong>once by hand</strong> and that several Compose
projects then share:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker network create proxy</span></span></code></pre></div>
</div>
<p>In the <code>compose.yaml</code> you then don&rsquo;t create it again, but reference the already
existing network with <code>external: true</code>:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">web</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">nginx:1.31</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">proxy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">proxy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">external</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span></span></span></code></pre></div>
</div>
<p><code>external: true</code> tells Compose: &ldquo;This network already exists – do <strong>not</strong> create it
and do <strong>not</strong> delete it on <code>down</code>.&rdquo; If the network is missing, <code>up</code> aborts with
<code>network proxy declared as external, but could not be found</code> – then you forgot the
<code>docker network create</code>. This very pattern – a shared <code>proxy</code> network plus
<code>external: true</code> – is the basis of the <a href="/en/tutorials/traefik-reverse-proxy/">Traefik
tutorial</a>, with which every app later gets its
domain and its HTTPS.</p>
<h3 id="step-4-configuration--environment-variables-env-and-healthchecks">Step 4: Configuration – environment variables, .env and healthchecks</h3>
<p>Almost every application is configured via <strong>environment variables</strong>. Two ways:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">app</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">beispiel/app:1.0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">TZ=Europe/Berlin</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">APP_PORT=3000</span></span></span></code></pre></div>
</div>
<p>Secrets (passwords, tokens) do <strong>not</strong> belong in the <code>compose.yaml</code>, but in a <code>.env</code>
file in the same folder. Compose reads it automatically:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;DB_PASSWORD=EIN_LANGES_ZUFALLSPASSWORT&#34;</span> &gt; .env</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">db</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">postgres:18</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_PASSWORD=${DB_PASSWORD}</span></span></span></code></pre></div>
</div>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-amber-400 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">⚠️</span>Never push `.env` to a backup repo
  </p>
  <div class="prose-kitchen text-sm">The <code>.env</code> contains plaintext secrets. Add it to a <code>.gitignore</code> if you version your
Compose files, and back it up separately (encrypted).</div>
</div>
<p>This is how you check whether Compose understands your file <strong>and</strong> inserts the
<code>.env</code> values correctly – without starting anything:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose config</span></span></code></pre></div>
</div>
<p><code>config</code> resolves all variables and prints the finished, normalized configuration:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">compose-demo</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">db</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">POSTGRES_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">EIN_LANGES_ZUFALLSPASSWORT</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">postgres:18</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">default</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span></span></span></code></pre></div>
</div>
<p>If your <strong>actual</strong> value appears there instead of <code>${DB_PASSWORD}</code>, the <code>.env</code> is
working. If you only need a quick syntax check without the whole output, use
<code>docker compose config --quiet</code> – if nothing comes back (exit code <code>0</code>), the file is
valid. That&rsquo;s also your first move for YAML errors (see below).</p>
<p>A <strong>healthcheck</strong> tells Docker when a service is really ready – the basis for
dependent services only starting then:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">db</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">postgres:18</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_PASSWORD=${DB_PASSWORD}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD-SHELL&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;pg_isready -U postgres&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">10s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">timeout</span><span class="p">:</span><span class="w"> </span><span class="l">5s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">5</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">app</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">beispiel/app:1.0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">db</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_healthy</span></span></span></code></pre></div>
</div>
<p>With <code>condition: service_healthy</code>, <code>app</code> only starts once the healthcheck of <code>db</code> is
green – the most common &ldquo;why won&rsquo;t my app connect to the database?&rdquo; disappears with
it. The four healthcheck fields mean: <strong><code>test</code></strong> is the command that runs in the
container (exit code <code>0</code> = healthy), <strong><code>interval</code></strong> the spacing between checks,
<strong><code>timeout</code></strong> how long a check may take, and <strong><code>retries</code></strong> how many consecutive
failures are needed before the container counts as <code>unhealthy</code>. The current state is
shown by the <code>STATUS</code> column of <code>docker compose ps</code> as <code>(healthy)</code> or <code>(unhealthy)</code>.</p>
<h3 id="step-5-the-operations-toolbox">Step 5: The operations toolbox</h3>
<p>You need these commands daily – always run them <strong>in the project folder</strong>:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose up -d        <span class="c1"># start / apply changes</span>
</span></span><span class="line"><span class="cl">docker compose ps           <span class="c1"># status of the services</span>
</span></span><span class="line"><span class="cl">docker compose logs -f web  <span class="c1"># follow logs live (Ctrl+C only ends the viewing)</span>
</span></span><span class="line"><span class="cl">docker compose <span class="nb">exec</span> web sh  <span class="c1"># shell in the running container</span>
</span></span><span class="line"><span class="cl">docker compose restart web  <span class="c1"># restart a single service</span>
</span></span><span class="line"><span class="cl">docker compose stop         <span class="c1"># halt without removing container/network</span>
</span></span><span class="line"><span class="cl">docker compose pull         <span class="c1"># fetch new image versions</span>
</span></span><span class="line"><span class="cl">docker compose down         <span class="c1"># stop and remove the stack (volumes stay)</span></span></span></code></pre></div>
</div>
<p>Almost all commands can be restricted to <strong>one</strong> service by appending its name
(<code>docker compose logs -f web</code>, <code>docker compose restart web</code>) – without a name they
apply to the whole stack. The difference between <code>stop</code> and <code>down</code>: <code>stop</code> only halts
the containers (<code>start</code> continues), <code>down</code> removes them along with the network (the
named volumes stay in both cases).</p>
<p>An update almost always follows the same pattern: bump the tag in the <code>compose.yaml</code>
→ <code>docker compose pull</code> → <code>docker compose up -d</code>. Compose only replaces the
containers whose image has changed.</p>
<h3 id="step-6-all-together--a-realistic-app-stack">Step 6: All together – a realistic app stack</h3>
<p>This is the pattern you&rsquo;ll encounter again and again in the app tutorials: an
application plus its database. This file bundles everything from steps 1–4 – read it
once in full, and you&rsquo;ll have understood 90% of every later <code>compose.yaml</code>:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">app</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">beispiel/app:1.4         </span><span class="w"> </span><span class="c"># fixed version, no latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;8080:3000&#34;</span><span class="w">                  </span><span class="c"># only the app is reachable from outside</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">TZ=Europe/Berlin</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">DATABASE_URL=postgres://app:${DB_PASSWORD}@db:5432/app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">appdata:/data               </span><span class="w"> </span><span class="c"># persistent app data (named volume)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">db</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_healthy  </span><span class="w"> </span><span class="c"># starts only once db is ready</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">db</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">postgres:18</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_USER=app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_PASSWORD=${DB_PASSWORD}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_DB=app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">dbdata:/var/lib/postgresql/data  </span><span class="w"> </span><span class="c"># the actual database files</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD-SHELL&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;pg_isready -U app&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">10s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">timeout</span><span class="p">:</span><span class="w"> </span><span class="l">5s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">5</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c"># no ports: – the database is reachable ONLY internally via the name &#34;db&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">appdata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="l">dbdata:</span></span></span></code></pre></div>
</div>
<p>Three design decisions worth remembering:</p>
<ol>
<li><strong>Only <code>app</code> has <code>ports:</code>.</strong> The database needs no open host port – the app
reaches it internally via the hostname <code>db</code> (the <code>DATABASE_URL</code> points exactly
there). A port that isn&rsquo;t published is a port nobody from the internet can attack.</li>
<li><strong>Two separate named volumes.</strong> App data and database files live cleanly separated
– that makes later backups and restores traceable.</li>
<li><strong>Password only as <code>${DB_PASSWORD}</code>.</strong> The actual value is in the <code>.env</code>, not in
this file. The same <code>compose.yaml</code> can thus be shared safely.</li>
</ol>
<p>This very skeleton – app facing outward, database internal only, data in named
volumes, secrets in the <code>.env</code> – repeats in Nextcloud, Vaultwarden, Paperless and
most other recipes.</p>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong><code>yaml: line 7: did not find expected key</code> (or similar YAML errors).</strong> YAML is
<strong>indentation-sensitive</strong> – spaces only, <strong>never tabs</strong>, and consistent per level
(common: 2 spaces). Check the file without starting it: <code>docker compose config</code>
resolves everything and complains about exactly the wrong line.</p>
<p><strong><code>Error ... address already in use</code> on <code>up</code>.</strong> The host port (left in <code>8080:80</code>) is
already taken. Find the occupant with <code>sudo ss -tlnp | grep 8080</code> or choose a
different host port. Two containers cannot share the same host port.</p>
<p><strong>An app can&rsquo;t find its database (<code>could not translate host name</code>).</strong> The hostname
must be the <strong>service name</strong> (e.g. <code>db</code>), not <code>localhost</code>. Inside a container,
<code>localhost</code> is the container itself, not the neighboring service. And: both services
must be in the same Compose project (the same file).</p>
<p><strong>After <code>docker compose down</code> all data is gone.</strong> Either the volume wasn&rsquo;t declared
as a <strong>named volume</strong> under <code>volumes:</code> (then it was only the ephemeral container
storage), or <code>down -v</code> was used. Always run persistent services with a declared named
volume.</p>
<p><strong><code>docker-compose: command not found</code>.</strong> That&rsquo;s the old Compose v1 (with a hyphen).
The current one is <code>docker compose</code> (with a space, plugin). If it&rsquo;s missing: <code>sudo apt install docker-compose-plugin</code> (see <a href="/en/tutorials/install-docker/">Docker
tutorial</a>).</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<ul>
<li><strong>Maintain image tags:</strong> Fixed tags (<code>nginx:1.31</code>) mean you apply updates
<strong>deliberately</strong> by bumping the tag. That&rsquo;s intended – this way you decide when an
update comes, instead of being surprised. Expect a <strong>monthly</strong> look at your
services&rsquo; release notes.</li>
<li><strong>What belongs in the backup?</strong> Not the containers – they can be rebuilt from the
<code>compose.yaml</code> at any time. What you must back up are <strong>the named volumes</strong> (or
bind-mount folders), the <code>compose.yaml</code> and the <code>.env</code>. We build a well-thought-out
off-site backup of this data with <a href="/en/tutorials/restic-backups/">encrypted Restic
backups</a>.</li>
<li><strong>Cleanup:</strong> <code>docker compose down</code> when tearing down a stack; you remove unused
images with <code>docker image prune</code>. Named volumes are <strong>never</strong> deleted automatically
– that&rsquo;s by design.</li>
</ul>
]]></content:encoded></item><item><title>Installing Docker on Debian</title><link>https://serverkueche.de/en/tutorials/install-docker/</link><pubDate>Wed, 15 Jul 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/install-docker/</guid><description>Install Docker Engine and Docker Compose cleanly from the official repository – the foundation for most self-hosting recipes.</description><content:encoded><![CDATA[<p>Docker is the foundation for almost every application tutorial in the Serverküche.
We install it from the official Docker repository – not from the Debian package
sources.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>By the end, the current <strong>Docker Engine with the Compose plugin</strong> runs on your
<strong>Debian 13</strong>, installed from the official Docker repository. That has two
advantages over the Debian package <code>docker.io</code>: more current versions with timely
security updates, and the official <code>docker compose</code> that all Compose recipes here
build on.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>A <a href="/en/tutorials/harden-ssh/">hardened server</a> with a sudo user</li>
</ul>
<h2 id="step-by-step">Step by step</h2>
<h3 id="step-1-set-up-the-repository-key">Step 1: Set up the repository key</h3>
<p>First, install the tools needed to verify the Docker repository:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo apt update
</span></span><span class="line"><span class="cl">sudo apt install -y ca-certificates curl</span></span></code></pre></div>
</div>
<p>Then create the keyring directory and download Docker&rsquo;s GPG key – apt uses it later
to check that the packages really come from Docker:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo install -m <span class="m">0755</span> -d /etc/apt/keyrings
</span></span><span class="line"><span class="cl">sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc</span></span></code></pre></div>
</div>
<h3 id="step-2-add-the-docker-package-source">Step 2: Add the Docker package source</h3>
<p>Add the Docker repository to your package sources. The command detects the
architecture and Debian version automatically, so you can copy it unchanged:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;deb [arch=</span><span class="k">$(</span>dpkg --print-architecture<span class="k">)</span><span class="s2"> signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian </span><span class="k">$(</span>. /etc/os-release <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$VERSION_CODENAME</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2"> stable&#34;</span> <span class="p">|</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  sudo tee /etc/apt/sources.list.d/docker.list &gt; /dev/null</span></span></code></pre></div>
</div>
<h3 id="step-3-install-docker">Step 3: Install Docker</h3>
<p>Refresh the package lists (now including the Docker repository) and install the
Engine together with the Compose plugin:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo apt update
</span></span><span class="line"><span class="cl">sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin</span></span></code></pre></div>
</div>
<p>Check that the service is running:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo systemctl status docker</span></span></code></pre></div>
</div>
<p>You should see <code>active (running)</code>. A quick smoke test:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo docker run --rm hello-world</span></span></code></pre></div>
</div>
<p>The output <code>Hello from Docker!</code> confirms that the installation works.</p>
<h3 id="step-4-use-docker-without-sudo">Step 4: Use Docker without sudo</h3>
<p>Add your user to the <code>docker</code> group so you don&rsquo;t have to prefix every command with
<code>sudo</code>:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo usermod -aG docker <span class="nv">$USER</span></span></span></code></pre></div>
</div>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-herb-400 bg-herb-50 dark:border-herb-700 dark:bg-herb-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🧑‍🍳</span>Tip
  </p>
  <div class="prose-kitchen text-sm">The group membership only takes effect after you <strong>log out and back in</strong> (end the
SSH session and reconnect).</div>
</div>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-amber-400 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">⚠️</span>Security note
  </p>
  <div class="prose-kitchen text-sm">Members of the <code>docker</code> group effectively have root rights on the server – only add
your own admin user, no shared or unprivileged accounts.</div>
</div>
<p>After that, <code>docker ps</code> works without sudo and shows a (still empty) container list.</p>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong><code>E: Unable to locate package docker-ce</code>.</strong> The package source from step 2 is
missing or malformed. Check the contents of <code>/etc/apt/sources.list.d/docker.list</code> –
it must contain your Debian version (e.g. <code>trixie</code>) – and then run <code>sudo apt update</code>
again.</p>
<p><strong><code>permission denied while trying to connect to the Docker daemon socket</code>.</strong> The
<code>docker</code> group membership hasn&rsquo;t taken effect yet. End the SSH session and
reconnect; <code>groups</code> must then include <code>docker</code>. If not, repeat step 4.</p>
<p><strong>Conflicts during installation with already-present packages.</strong> Another Docker
variant is already installed (<code>docker.io</code>, <code>podman-docker</code>, …). Remove the old
packages first: <code>sudo apt remove docker.io docker-doc docker-compose podman-docker containerd runc</code> – existing containers/images are preserved.</p>
<p><strong><code>docker compose</code> reports <code>'compose' is not a docker command</code>.</strong> The Compose plugin
is missing – Docker was probably installed differently earlier. Run <code>sudo apt install docker-compose-plugin</code>. Note: the old <code>docker-compose</code> (with a hyphen) is a
different, outdated tool.</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<ul>
<li><strong>Updates:</strong> Docker updates along with the normal
<code>sudo apt update &amp;&amp; sudo apt upgrade</code> – one reason we use the official
repository. When the Engine is updated, running containers restart briefly (or
stop until you start them again) – plan for that.</li>
<li><strong>Cleanup:</strong> Unused images and build leftovers pile up quickly.
<code>docker system df</code> shows the usage, <code>docker system prune</code> cleans up
(<strong>careful:</strong> it also removes stopped containers).</li>
<li><strong>Backups:</strong> The actual data of your applications will later live in volumes or
bind mounts – we build the backup strategy for that with
<a href="/en/tutorials/restic-backups/">encrypted Restic backups</a> and in the individual
application tutorials.</li>
</ul>
]]></content:encoded></item></channel></rss>