<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Docker – Serverküche</title><link>https://serverkueche.de/en/tags/docker/</link><description>Docker – 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>Fri, 24 Jul 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://serverkueche.de/en/tags/docker/index.xml" rel="self" type="application/rss+xml"/><item><title>CrowdSec: modern, collaborative intrusion prevention</title><link>https://serverkueche.de/en/tutorials/crowdsec-setup/</link><pubDate>Fri, 24 Jul 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/crowdsec-setup/</guid><description>Set up CrowdSec with the Traefik bouncer: detect attacks from the access logs, block attackers with a 403 and benefit from the community blocklist.</description><content:encoded><![CDATA[<p><a href="/en/tutorials/fail2ban-setup/">Fail2ban</a> blocks brute force on your SSH access – but what about the scanners that probe your web apps for holes all day long? CrowdSec closes exactly this gap: it detects attacks in Traefik&rsquo;s access logs and bans the attackers before they even reach your applications.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>By the end, a <strong>CrowdSec agent</strong> runs alongside your Traefik, reading along with the <strong>access logs</strong>, detecting suspicious behavior based on <strong>scenarios</strong> (port scans, path probing, known CVE exploitation, login brute force) and putting attacker IPs on a block list. A <strong>bouncer</strong> – here as a Traefik plugin – enforces these decisions: a banned IP only gets <code>403 Forbidden</code> at <strong>every</strong> one of your apps. Tested with <strong>CrowdSec v1.7.8</strong>, the <strong>Traefik bouncer plugin v1.6.0</strong> and <strong>Traefik v3.7</strong>.</p>
<p>This is how the parts interlock:</p>
<ul>
<li><strong>Agent + Local API (LAPI):</strong> reads the logs, evaluates them against the scenarios and manages the active bans (&ldquo;decisions&rdquo;). That&rsquo;s the container we set up.</li>
<li><strong>Bouncer:</strong> asks the LAPI on every request &ldquo;is this IP banned?&rdquo; and blocks if yes. The Traefik bouncer runs as a plugin directly in the reverse proxy – no additional container.</li>
<li><strong>Community blocklist:</strong> CrowdSec reports (anonymized) detected attacks to the central network and gets a <strong>blocklist fed by all users</strong> back in return. IPs that were already flagged as malicious elsewhere are then banned at your place from the start. That&rsquo;s the &ldquo;collaborative&rdquo; in the title.</li>
</ul>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-sky-300 bg-sky-50 dark:border-sky-800 dark:bg-sky-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">ℹ️</span>CrowdSec instead of fail2ban – or in addition?
  </p>
  <div class="prose-kitchen text-sm">CrowdSec doesn&rsquo;t replace fail2ban, it complements it. <strong>fail2ban</strong> is unbeatably simple for <strong>a single service on the host</strong> (SSH). <strong>CrowdSec</strong> plays to its strength with <strong>HTTP apps behind the reverse proxy</strong>: one bouncer protects all apps at once, the scenarios are maintained and current, and you get the community blocklist for free. The clean split: <strong>fail2ban stays on SSH</strong>, <strong>CrowdSec takes over the web layer</strong>. Both may run in parallel.</div>
</div>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>A running <a href="/en/tutorials/traefik-reverse-proxy/">reverse proxy with Traefik</a> (the <code>proxy</code> network and the resolver <code>le</code> come from there) with at least one app behind it.</li>
<li><a href="/en/tutorials/fail2ban-setup/">Fail2ban</a> for SSH protection – CrowdSec handles the HTTP layer here, not SSH.</li>
<li>Basic knowledge of <a href="/en/tutorials/docker-compose-basics/">Docker Compose</a> and a hardened server with an active <a href="/en/tutorials/firewall-ufw-setup/">firewall</a>.</li>
</ul>
<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">CrowdSec runs with a few hundred MB alongside your existing app stack – the VPS 2000 has enough reserve for that.</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="step-by-step">Step by step</h2>
<h3 id="step-1-enable-traefiks-access-logs">Step 1: Enable Traefik&rsquo;s access logs</h3>
<p>CrowdSec can only detect what it sees – and it sees attacks via <strong>Traefik&rsquo;s access logs</strong>. They&rsquo;re off by default. Open the <code>compose.yaml</code> of your Traefik (from the <a href="/en/tutorials/traefik-reverse-proxy/">Traefik tutorial</a>, folder <code>~/traefik</code>) and add these three lines to the <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;--accesslog=true&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--accesslog.filepath=/var/log/traefik/access.log&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--accesslog.format=json&#34;</span></span></span></code></pre></div>
</div>
<p>The JSON format is important – the CrowdSec parsers expect it that way. So that both Traefik (writing) and CrowdSec (reading) can reach the same file, you put it on a fixed host path. Add to Traefik&rsquo;s <code>volumes</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="l">/var/log/traefik:/var/log/traefik</span></span></span></code></pre></div>
</div>
<p>Create the directory on the host 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">sudo mkdir -p /var/log/traefik
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> ~/traefik <span class="o">&amp;&amp;</span> docker compose up -d</span></span></code></pre></div>
</div>
<p>Call one of your apps once and check that lines arrive:</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">tail -n <span class="m">2</span> /var/log/traefik/access.log</span></span></code></pre></div>
</div>
<p>You should see JSON lines with <code>&quot;ClientHost&quot;</code>, <code>&quot;RequestPath&quot;</code> and <code>&quot;DownstreamStatus&quot;</code>. If the file stays empty, the path in the volume is wrong, or simply no request has come in yet.</p>
<h3 id="step-2-set-up-the-crowdsec-container">Step 2: Set up the CrowdSec container</h3>
<p>CrowdSec gets its own folder. First generate a <strong>bouncer key</strong> – the Traefik bouncer later authenticates with it at the LAPI:</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 ~/crowdsec <span class="o">&amp;&amp;</span> <span class="nb">cd</span> ~/crowdsec
</span></span><span class="line"><span class="cl">openssl rand -hex <span class="m">32</span></span></span></code></pre></div>
</div>
<p>Copy the output; it&rsquo;s used in <strong>two</strong> places shortly. Create the <strong>acquisition</strong> – it tells CrowdSec which file with which log type to read:</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"># ~/crowdsec/acquis.yaml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">filenames</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/log/traefik/access.log</span><span class="w">
</span></span></span><span class="line"><span class="cl"><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="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">traefik</span></span></span></code></pre></div>
</div>
<p>Now the <code>compose.yaml</code>. Replace <code>YOUR_BOUNCER_KEY</code> with the generated key:</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">crowdsec</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">crowdsecurity/crowdsec:v1.7.8</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">crowdsec</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">COLLECTIONS</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;crowdsecurity/traefik&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">BOUNCER_KEY_TRAEFIK</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;YOUR_BOUNCER_KEY&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">GID</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;1000&#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">./acquis.yaml:/etc/crowdsec/acquis.d/traefik.yaml:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/var/log/traefik:/var/log/traefik:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">crowdsec-db:/var/lib/crowdsec/data/</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">crowdsec-config:/etc/crowdsec/</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">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">crowdsec-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">crowdsec-config</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">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>COLLECTIONS: crowdsecurity/traefik</code></strong> installs the matching collection of parsers and scenarios for Traefik logs on the first start (including the HTTP base scenarios).</li>
<li><strong><code>BOUNCER_KEY_TRAEFIK</code></strong> registers a bouncer named <code>TRAEFIK</code> with <em>your</em> key right at startup – that saves the detour via <code>cscli bouncers add</code>.</li>
<li><strong><code>GID: &quot;1000&quot;</code></strong> runs the CrowdSec process under this group. <code>1000</code> is the usual default for the first user; if Traefik runs as root, the <code>access.log</code> is usually world-readable and the GID doesn&rsquo;t matter anyway. If it&rsquo;s set more tightly, check with <code>ls -l /var/log/traefik/access.log</code> and enter the GID of the <strong>file&rsquo;s group</strong>.</li>
<li>The log is mounted <strong>read-only</strong> (<code>:ro</code>). CrowdSec is on the <strong><code>proxy</code> network</strong> so the Traefik bouncer can reach the LAPI at <code>crowdsec:8080</code> later.</li>
</ul>
<p>Start the container and check that everything takes effect:</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 <span class="nb">exec</span> crowdsec cscli collections list</span></span></code></pre></div>
</div>
<p>The Traefik collection and the HTTP scenarios should appear as <code>enabled</code>:</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"> crowdsecurity/base-http-scenarios    ✔️  enabled
</span></span><span class="line"><span class="cl"> crowdsecurity/http-cve               ✔️  enabled
</span></span><span class="line"><span class="cl"> crowdsecurity/traefik                ✔️  enabled</span></span></code></pre></div>
</div>
<p>Check that the <strong>community blocklist</strong> is active – it costs nothing and is half the benefit of CrowdSec:</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 <span class="nb">exec</span> crowdsec cscli capi status</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">You can successfully interact with Central API (CAPI)
</span></span><span class="line"><span class="cl">Sharing signals is enabled
</span></span><span class="line"><span class="cl">Pulling community blocklist is enabled
</span></span><span class="line"><span class="cl">Pulling blocklists from the console is enabled</span></span></code></pre></div>
</div>
<p><code>Pulling community blocklist is enabled</code> confirms that you receive the collective block list of the CrowdSec community – attackers that stood out elsewhere are already banned at your place before they even try.</p>
<h3 id="step-3-add-the-traefik-bouncer">Step 3: Add the Traefik bouncer</h3>
<p>Now the part that enforces the bans. The bouncer is a <strong>Traefik plugin</strong> and consists of two additions to your Traefik: load the plugin and define a middleware that acts on <strong>all</strong> apps.</p>
<p>Load the plugin by adding to the <code>command</code> block of <code>~/traefik/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="w">      </span>- <span class="s2">&#34;--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--experimental.plugins.bouncer.version=v1.6.0&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--entrypoints.websecure.http.middlewares=crowdsec@file&#34;</span></span></span></code></pre></div>
</div>
<p>The last line attaches the bouncer middleware <strong>globally to the <code>websecure</code> entrypoint</strong> – so every HTTPS request to every app runs through the bouncer, without you touching each app individually. You define the middleware itself via the <strong>file provider</strong>.</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>Don&#39;t overwrite existing entrypoint middlewares
  </p>
  <div class="prose-kitchen text-sm"><code>--entrypoints.websecure.http.middlewares</code> is <strong>single-valued</strong>: if you&rsquo;ve already set middlewares on this entrypoint in the <a href="/en/tutorials/traefik-reverse-proxy/">Traefik setup</a> (e.g. security headers), a <strong>second</strong> flag overwrites the existing value – then your previous middlewares silently drop out. So don&rsquo;t set a second flag, but extend the existing value <strong>comma-separated</strong>, e.g. <code>--entrypoints.websecure.http.middlewares=header@file,crowdsec@file</code>. Only if <strong>no</strong> middleware is attached to this entrypoint so far do you add the flag anew as above.</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>No second file-provider flag
  </p>
  <div class="prose-kitchen text-sm">The <a href="/en/tutorials/traefik-reverse-proxy/">Traefik setup</a> usually already has the file provider active (e.g. as <code>--providers.file.filename=…</code> or <code>--providers.file.directory=…</code>). Traefik does <strong>not</strong> allow <code>filename</code> and <code>directory</code> at the same time – so don&rsquo;t add a second flag blindly. Put the following middleware file either into your <strong>existing</strong> dynamic directory, or – if your provider points to a single <code>filename</code> file – write the <code>http.middlewares</code> block right in there. The paths below (<code>~/traefik/dynamic</code> or <code>/etc/traefik/dynamic</code>) only apply if you already use a directory.</div>
</div>
<p>Create the middleware (path adapted to your existing file provider):</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"># ~/traefik/dynamic/crowdsec.yaml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">http</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">middlewares</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">crowdsec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">plugin</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">bouncer</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">enabled</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">crowdsecMode</span><span class="p">:</span><span class="w"> </span><span class="l">stream</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">crowdsecLapiScheme</span><span class="p">:</span><span class="w"> </span><span class="l">http</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">crowdsecLapiHost</span><span class="p">:</span><span class="w"> </span><span class="l">crowdsec:8080</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">crowdsecLapiKey</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;YOUR_BOUNCER_KEY&#34;</span></span></span></code></pre></div>
</div>
<p><code>crowdsecLapiKey</code> is the <strong>same</strong> key as in step 2 – the bouncer identifies itself with it at the LAPI. With <code>crowdsecMode: stream</code> the bouncer pulls the block list completely in advance at a fixed interval and answers every request from the local cache – the mode recommended by the plugin, because it doesn&rsquo;t load the LAPI on every request.</p>
<p>Make sure Traefik also <strong>sees</strong> the directory. If your existing file provider already uses a mounted dynamic directory, the new file is in it anyway – then there&rsquo;s nothing to do here. If you create the directory anew, add it to Traefik&rsquo;s <code>volumes</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="l">./dynamic:/etc/traefik/dynamic:ro</span></span></span></code></pre></div>
</div>
<p>Restart Traefik so it downloads the plugin and loads the middleware:</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">cd</span> ~/traefik <span class="o">&amp;&amp;</span> 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 log must show <strong>no</strong> <code>ERR</code> about the plugin or the middleware (Traefik v3 logs only errors at the default level – a quiet output is the good sign). Check from the CrowdSec side that the bouncer has connected:</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 <span class="nb">exec</span> crowdsec cscli bouncers list</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     IP Address  Valid  Last API pull         Type
</span></span><span class="line"><span class="cl"> TRAEFIK  172.19.0.2  ✔️      2026-07-23T10:49:58Z  Crowdsec-Bouncer-Traefik-Plugin</span></span></code></pre></div>
</div>
<p>A set <code>Last API pull</code> proves: the plugin talks to the LAPI.</p>
<h3 id="step-4-arm-it-and-test-the-ban">Step 4: Arm it and test the ban</h3>
<p>Call one of your apps – it should respond <strong>perfectly normally</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">curl -s -o /dev/null -w <span class="s2">&#34;%{http_code}\n&#34;</span> https://YOUR_APP.YOUR_DOMAIN/</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">200</span></span></code></pre></div>
</div>
<p>Now you test-ban an IP by hand and see whether the bouncer locks it out. Take your own public IP (<code>curl -s https://ifconfig.me</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">docker <span class="nb">exec</span> crowdsec cscli decisions add --ip YOUR_TEST_IP --duration 5m --reason <span class="s2">&#34;test&#34;</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>New bans don&#39;t take effect immediately
  </p>
  <div class="prose-kitchen text-sm">The bouncer fetches the decisions in the background and <strong>caches</strong> them – it can take <strong>up to ~60 seconds</strong> for a fresh ban to take effect. That&rsquo;s no bug but intended (less load on the LAPI). In <strong>stream</strong> mode the delay comes from the pull interval (<code>updateIntervalSeconds</code>, default 60 seconds), in <strong>live</strong> mode from the per-IP cache of each decision (<code>defaultDecisionSeconds</code>, default 60 seconds). In real operation this is irrelevant; when testing you have to wait briefly. Whoever wants to reduce the reaction time lowers the respective value – i.e. <code>updateIntervalSeconds</code> (stream) or <code>defaultDecisionSeconds</code> (live) – in the middleware, instead of switching modes.</div>
</div>
<p>After the wait, the banned IP only gets:</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 -s -o /dev/null -w <span class="s2">&#34;%{http_code}\n&#34;</span> https://YOUR_APP.YOUR_DOMAIN/</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">403</span></span></code></pre></div>
</div>
<p>Lift the test ban again:</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 <span class="nb">exec</span> crowdsec cscli decisions delete --ip YOUR_TEST_IP</span></span></code></pre></div>
</div>
<h3 id="step-5-let-it-detect-real-attacks">Step 5: Let it detect real attacks</h3>
<p>The actual point: CrowdSec bans <strong>on its own</strong>, without you writing rules. As soon as a scanner probes your domain for paths (many <code>404</code> in a short time), the scenario <code>crowdsecurity/http-probing</code> kicks in. This is what a real, automatically detected ban looks like:</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 <span class="nb">exec</span> crowdsec cscli alerts list</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"> ID  value              reason                       country  as                      decisions
</span></span><span class="line"><span class="cl"> 4   Ip:109.250.21.197  crowdsecurity/http-probing   DE       8881 1&amp;1 Versatel GmbH  ban:1</span></span></code></pre></div>
</div>
<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 <span class="nb">exec</span> crowdsec cscli decisions list</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"> Source    Scope:Value        Reason                       Action  Country  AS
</span></span><span class="line"><span class="cl"> crowdsec  Ip:109.250.21.197  crowdsecurity/http-probing   ban     DE       8881 1&amp;1 Versatel GmbH</span></span></code></pre></div>
</div>
<p>CrowdSec automatically enriches the ban with <strong>country and provider (AS)</strong>. Besides the self-detected attacks, bans with the source <code>lists:crowdsecurity/...</code> appear here over time – that&rsquo;s the <strong>community blocklist</strong>: IPs that already stood out negatively worldwide, you block before they hit you the first time.</p>
<p>Whoever prefers to see the bans graphically instead of in <code>cscli</code> tables enrolls the instance for free in the <a href="https://app.crowdsec.net/">CrowdSec console</a> (the enroll command is in &ldquo;Maintenance &amp; backups&rdquo;). There the same decisions appear presented nicely:</p>
<p><figure class="my-6"><img src="/en/tutorials/crowdsec-setup/crowdsec-console-decisions_hu_edda9bf11738a503.webp" srcset="/en/tutorials/crowdsec-setup/crowdsec-console-decisions_hu_c4a4dc9c1c0bad75.webp 480w, /en/tutorials/crowdsec-setup/crowdsec-console-decisions_hu_edda9bf11738a503.webp 768w, /en/tutorials/crowdsec-setup/crowdsec-console-decisions_hu_c963e2d6d8f9a3d2.webp 1200w, /en/tutorials/crowdsec-setup/crowdsec-console-decisions_hu_7abd8b2f243af422.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="360"
    data-full="/en/tutorials/crowdsec-setup/crowdsec-console-decisions_hu_c6e0877ac3db367b.webp"
    alt="The CrowdSec console lists the active bans as a table: several IP addresses with the action &ldquo;ban&rdquo;, an expiry time and the triggering scenario http-probing, each &ldquo;via crowdsec&rdquo;" title="The CrowdSec console (optional, free): the same decisions as in cscli, just graphical"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The CrowdSec console (optional, free): the same decisions as in cscli, just graphical</figcaption></figure></p>
<p>The HTTP scenarios installed with the Traefik collection cover what real scanners try all day: <strong>probing non-existent paths</strong> (<code>http-probing</code>), searching for <strong>sensitive files and backdoors</strong>, <strong>path traversal</strong> and <strong>SQL injection/XSS</strong> probes, suspicious <strong>user agents</strong> of known scanners, as well as the exploitation of <strong>known CVEs</strong> (from the collection <code>crowdsecurity/http-cve</code>). Which scenarios are active is shown by <code>docker exec crowdsec cscli scenarios list</code>.</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>One notch sharper: AppSec (WAF)
  </p>
  <div class="prose-kitchen text-sm">Beyond pure IP blocking, CrowdSec can work as a <strong>web application firewall</strong> and detect malicious requests directly by their content (virtual patching). The Traefik plugin supports this AppSec mode (from plugin 1.2.0 and CrowdSec 1.6.0). For getting started, the scenario-based blocking shown here is enough; AppSec is the worthwhile next expansion step when a particularly exposed app needs additional protection.</div>
</div>
<p>Whether CrowdSec reads your logs at all is shown by the metrics overview:</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 <span class="nb">exec</span> crowdsec cscli metrics</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">| Source                           | Lines read | Lines parsed |
</span></span><span class="line"><span class="cl">| file:/var/log/traefik/access.log | 54         | 54           |</span></span></code></pre></div>
</div>
<p>If it says <code>Lines read: 0</code>, no log is arriving – then back to step 1.</p>
<h3 id="step-6-dont-lock-yourself-out">Step 6: Don&rsquo;t lock yourself out</h3>
<p>A false alarm against your own maintenance IP is annoying. Enter your fixed IP as <strong>trusted</strong> in the middleware – the bouncer then doesn&rsquo;t even check it. Add to <code>~/traefik/dynamic/crowdsec.yaml</code> under <code>bouncer:</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="w">          </span><span class="nt">clientTrustedIPs</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;YOUR_FIXED_IP/32&#34;</span></span></span></code></pre></div>
</div>
<p>If you did lock yourself out, one command lifts the ban immediately – and in an emergency you can always reach the server via the <a href="/en/tutorials/netcup-snapshots-scp/">VNC console in the SCP</a>:</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 <span class="nb">exec</span> crowdsec cscli decisions delete --ip YOUR_IP   <span class="c1"># one IP</span>
</span></span><span class="line"><span class="cl">docker <span class="nb">exec</span> crowdsec cscli decisions delete --all           <span class="c1"># all bans</span></span></span></code></pre></div>
</div>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong>A freshly set ban doesn&rsquo;t take effect, the IP keeps getting <code>200</code>.</strong> The bouncer caches the
decisions and, in stream mode, pulls new ones only after the pull interval (up to ~60 seconds). Wait
briefly. Whoever wants to reduce the reaction time lowers <code>updateIntervalSeconds</code> in the middleware
(step 3).</p>
<p><strong><code>cscli metrics</code> shows <code>Lines read: 0</code> for the log file.</strong> CrowdSec isn&rsquo;t reading the logs. Check
that Traefik really writes to <code>/var/log/traefik/access.log</code> (step 1), that <strong>both</strong> containers mount
the same host path, and that the <code>acquis.yaml</code> names exactly this path.</p>
<p><strong>After restarting Traefik, all apps get an error or don&rsquo;t load.</strong> In <strong>stream</strong> mode (set above),
the bouncer can lock down after several failed LAPI pulls – controlled via <code>updateMaxFailure</code>. If
the CrowdSec LAPI is unreachable, the bouncer then blocks in doubt instead of letting through
unprotected. That&rsquo;s safe, but makes CrowdSec a <strong>critical component</strong>: if the <code>crowdsec</code> container
isn&rsquo;t running, your site is affected. Check with <code>docker ps</code> that it&rsquo;s <code>Up</code>; <code>restart: unless-stopped</code> (set above) brings it back automatically after a restart.</p>
<p><strong>CrowdSec bans lots of harmless or no real visitors – the detected IP is always your CDN&rsquo;s.</strong> If a
CDN like Cloudflare sits <strong>in front of</strong> Traefik, Traefik only sees its IP. Then have the real
client IP read from the <code>X-Forwarded-For</code> header: fill <code>forwardedHeadersTrustedIPs</code> in the
middleware with the CDN networks – otherwise you end up banning the CDN. Directly at netcup (without
a CDN) there&rsquo;s nothing to do here.</p>
<p><strong><code>docker compose up -d</code> on Traefik reports a plugin error.</strong> Traefik downloads the plugin from the
net on startup. Check that the server may go out (the <a href="/en/tutorials/netcup-firewall-setup/">netcup
firewall</a> blocks nothing <strong>outbound</strong>, UFW likewise) and that
<code>modulename</code> and <code>version</code> are exactly right.</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<ul>
<li>
<p><strong>Keep scenarios current.</strong> CrowdSec&rsquo;s strength is maintained detection rules – pull them regularly: <code>docker exec crowdsec cscli hub update &amp;&amp; docker exec crowdsec cscli hub upgrade</code>. You bump the image (<code>crowdsecurity/crowdsec</code>) and the <strong>plugin version</strong> (<code>v1.6.0</code>) deliberately; read the release notes on major versions. CrowdSec develops briskly – a good candidate for regular version checks.</p>
</li>
<li>
<p><strong>Rotate the access log.</strong> Traefik&rsquo;s <code>/var/log/traefik/access.log</code> otherwise grows unbounded. Set up a logrotate rule – mandatorily with <strong><code>copytruncate</code></strong>: this variant copies the file away and empties the original <em>in place</em>, instead of renaming it and creating a new one. Only this way does CrowdSec&rsquo;s tail keep the file access (with rename/create it would lose the handle and read nothing anymore). Create <code>/etc/logrotate.d/traefik</code>:</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">/var/log/traefik/access.log {
</span></span><span class="line"><span class="cl">    daily
</span></span><span class="line"><span class="cl">    rotate 7
</span></span><span class="line"><span class="cl">    compress
</span></span><span class="line"><span class="cl">    missingok
</span></span><span class="line"><span class="cl">    notifempty
</span></span><span class="line"><span class="cl">    copytruncate
</span></span><span class="line"><span class="cl">}</span></span></code></pre></div>
</div>
<p>After the truncate, CrowdSec reads on perfectly normally from the file that&rsquo;s growing again – a restart of the container isn&rsquo;t needed.</p>
</li>
<li>
<p><strong>fail2ban stays.</strong> Don&rsquo;t remove fail2ban – it continues to protect your SSH access at the host level, while CrowdSec covers the web apps. Two tools, two responsibilities.</p>
</li>
<li>
<p><strong>Backups.</strong> What you must back up is above all your <strong>configuration</strong>: the folder <code>~/crowdsec</code> (<code>compose.yaml</code>, <code>acquis.yaml</code>) and <code>~/traefik/dynamic/crowdsec.yaml</code> with the bouncer key. Include them in your <a href="/en/tutorials/restic-backups/">Restic backup</a>. The block lists themselves are ephemeral and rebuild on their own – no backup needed.</p>
</li>
<li>
<p><strong>Optional: the CrowdSec dashboard.</strong> Whoever wants to see the attacks graphically enrolls the instance for free in the <a href="https://app.crowdsec.net/">CrowdSec console</a> (<code>docker exec crowdsec cscli console enroll YOUR_ENROLL_KEY</code>). The community blocklist runs without enrollment too.</p>
</li>
<li>
<p><strong>Integrate it into your monitoring.</strong> <code>cscli metrics</code> gives you numbers for the terminal; for continuous observation, CrowdSec provides a Prometheus endpoint (enable it in the config under <code>prometheus:</code>) that you can hang on your <a href="/en/tutorials/monitoring-grafana-prometheus/">Grafana stack</a>. Then you see bans by origin and scenario at a glance:</p>
<p><figure class="my-6"><img src="/en/tutorials/crowdsec-setup/crowdsec-grafana-dashboard_hu_8bf8cd1517fc9096.webp" srcset="/en/tutorials/crowdsec-setup/crowdsec-grafana-dashboard_hu_9232ed57309a2207.webp 480w, /en/tutorials/crowdsec-setup/crowdsec-grafana-dashboard_hu_8bf8cd1517fc9096.webp 768w, /en/tutorials/crowdsec-setup/crowdsec-grafana-dashboard_hu_a4133624712c35ed.webp 1200w, /en/tutorials/crowdsec-setup/crowdsec-grafana-dashboard_hu_c65d2820e7b1facb.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/crowdsec-setup/crowdsec-grafana-dashboard_hu_2209b8ac321bafec.webp"
    alt="A Grafana dashboard with CrowdSec metrics: around 15,000 active bans total, broken down by origin (community blocklist vs. locally detected) and by scenario like ssh:bruteforce, generic:scan and http-probing" title="CrowdSec in Grafana: active bans by origin and scenario, fed from the Prometheus endpoint"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">CrowdSec in Grafana: active bans by origin and scenario, fed from the Prometheus endpoint</figcaption></figure></p>
</li>
<li>
<p><strong>Honest about the effort:</strong> After setup, CrowdSec runs largely on its own. Plan a <code>hub upgrade</code> and a look at <code>cscli metrics</code> and the container version once a month.</p>
</li>
</ul>
]]></content:encoded></item><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>Self-hosting Jellyfin: your own media server behind Traefik</title><link>https://serverkueche.de/en/tutorials/jellyfin-media-server/</link><pubDate>Tue, 21 Jul 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/jellyfin-media-server/</guid><description>Set up Jellyfin with Docker behind Traefik: stream your movies, series and music – with HTTPS and the library on netcup's Local Block Storage.</description><content:encoded><![CDATA[<p>Netflix, Spotify and Google Photos in one – only on your own server and without a monthly
fee: <strong>Jellyfin</strong> streams your movie, series and music collection to any device. In this
recipe we hang Jellyfin behind Traefik and put the library on netcup&rsquo;s <strong>Local Block
Storage</strong> so you don&rsquo;t run out of space.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>By the end, <strong>Jellyfin 10.11</strong> runs as a container on your server, reachable at
<code>https://jellyfin.YOUR_DOMAIN</code> with valid HTTPS. Jellyfin is a completely free media server
(no cloud, no telemetry, no subscription): you store your files, Jellyfin automatically
pulls covers, descriptions and metadata and streams everything to the browser, phone,
smart TV or the Jellyfin app.</p>
<p>The real challenge with a media server is <strong>storage space</strong> – a movie collection quickly
bursts the small system SSD of a VPS. That&rsquo;s why we mount netcup&rsquo;s <strong>Local Block Storage</strong>:
an additional, local disk that appears in the server like a normal drive and on which the
library lives.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-sky-300 bg-sky-50 dark:border-sky-800 dark:bg-sky-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">ℹ️</span>Honest about transcoding
  </p>
  <div class="prose-kitchen text-sm">Jellyfin re-encodes videos on demand live into a format your end device understands
(<strong>transcoding</strong>) – that costs <strong>a lot of CPU</strong>. netcup servers are pure CPU machines
<strong>without a graphics card</strong>, so real hardware transcoding (via a GPU) doesn&rsquo;t exist there.
On a root server with <strong>dedicated cores</strong> (RS 1000), software transcoding works well for
one or two parallel streams; but the royal road remains <strong>Direct Play</strong> – more on that
below.</div>
</div>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>A running <strong>Traefik reverse proxy</strong> with the <code>proxy</code> network and the resolver <code>le</code> – see
<a href="/en/tutorials/traefik-reverse-proxy/">reverse proxy with Traefik</a>.</li>
<li>A subdomain <code>jellyfin.YOUR_DOMAIN</code> with a DNS record to your server IP – see
<a href="/en/tutorials/connect-domain-to-server/">connecting a domain to your server</a>.</li>
<li><strong>Dedicated CPU cores</strong> are clearly at an advantage for smooth streaming (transcoding).
On a shared VPS it works too for Direct Play, but gets tight quickly on re-encoding.</li>
<li>Optional but recommended: booked <strong>netcup Local Block Storage</strong> for the library (step 2).
Without it, you simply put the media in a folder on the system disk – until the space runs
out.</li>
<li>A few <strong>of your own</strong> media files (movies/series/music) that you&rsquo;re allowed to host.</li>
</ul>
<p>Whether your setup really needs dedicated cores is shown by the
<a href="/en/server-calculator/">server calculator</a> – with transcoding the answer is usually yes.</p>
<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">Root-Server RS 1000 G12</p>
      <p class="mt-1 text-sm text-slate-600 dark:text-slate-300">4 dedicated cores · 8 GB RAM · 256 GB NVMe</p>
      <p class="mt-1 text-sm font-semibold text-paprika-700 dark:text-paprika-400">from €12.79/month</p>
      <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">Dedicated cores for transcoding – and Local Block Storage can be added on.</p>
    </div>
    <a href="https://www.netcup.com/en/server/root-server/rs-1000-g12-ip-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="step-by-step">Step by step</h2>
<h3 id="step-1-create-the-dns-record">Step 1: Create the DNS record</h3>
<p>Create <code>jellyfin.YOUR_DOMAIN</code> (A/AAAA to your server IP) and check that it resolves:</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">dig +short jellyfin.YOUR_DOMAIN</span></span></code></pre></div>
</div>
<p>Your server IP must come back – otherwise Traefik won&rsquo;t fetch a certificate later.</p>
<h3 id="step-2-mount-netcup-local-block-storage">Step 2: Mount netcup Local Block Storage</h3>
<p>The <strong>Local Block Storage</strong> is additional storage you book to your server in the netcup
customer account. Unlike the network-attached <strong>Storage Space</strong> of other providers (which
is connected via NFS and is slower), it appears in the server as a <strong>completely normal
local disk</strong> – ideal for a library because it&rsquo;s fast and freely partitionable.</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>No block storage booked?
  </p>
  <div class="prose-kitchen text-sm">Then skip this step and simply create the library under <code>~/jellyfin/media</code> – all the
following commands work the same, only the path is different. You can add the block storage
later at any time and move the data.</div>
</div>
<p>First look at which disks the server knows:</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 fdisk -l</span></span></code></pre></div>
</div>
<p>The system disk is typically <code>/dev/sda</code> (or <code>/dev/vda</code>). The new block storage appears as
an <strong>additional</strong> device, usually <code>/dev/vdb</code> – recognizable by the size you booked.</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>Check first, then partition
  </p>
  <div class="prose-kitchen text-sm">The device names can differ per server. If you accidentally partition the <strong>system disk</strong>,
your server is toast. Identify the device unambiguously by its <strong>size</strong> before you
continue. When in doubt, create a <a href="/en/tutorials/netcup-snapshots-scp/">snapshot</a> first.</div>
</div>
<p>Create a partition on the new disk (here <code>/dev/vdb</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 cfdisk /dev/vdb</span></span></code></pre></div>
</div>
<p>Choose <strong>gpt</strong> at the prompt (the modern standard – <strong>dos</strong>/MBR can address at most 2 TiB,
libraries like to grow beyond that), then <strong>New</strong>, take the full size, and write the table
with <strong>Write</strong> (type <code>yes</code>), then <strong>Quit</strong>. The partition <code>/dev/vdb1</code> is created. Format it
with an ext4 filesystem:</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 mkfs.ext4 /dev/vdb1</span></span></code></pre></div>
</div>
<p>Create a mount point and mount the partition:</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 mkdir -p /mnt/media
</span></span><span class="line"><span class="cl">sudo mount /dev/vdb1 /mnt/media</span></span></code></pre></div>
</div>
<p>So that the disk is mounted automatically <strong>even after a reboot</strong>, you enter it in
<code>/etc/fstab</code> with its <strong>UUID</strong> (not with <code>/dev/vdb1</code>, which can change). Read out the UUID:</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 blkid /dev/vdb1</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">/dev/vdb1: UUID=&#34;a1b2c3d4-....&#34; TYPE=&#34;ext4&#34; ...</span></span></code></pre></div>
</div>
<p>Open the <code>fstab</code> and append the line with <strong>your</strong> UUID:</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 nano /etc/fstab</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">UUID=a1b2c3d4-....   /mnt/media   ext4   defaults   0   2</span></span></code></pre></div>
</div>
<p>Check the entries <strong>before</strong> you rely on the reboot – a typo in the <code>fstab</code> can block the
boot process:</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 umount /mnt/media <span class="o">&amp;&amp;</span> sudo mount -a <span class="o">&amp;&amp;</span> df -h /mnt/media</span></span></code></pre></div>
</div>
<p>If no error appears here and <code>df -h</code> shows your new disk under <code>/mnt/media</code>, everything is
entered correctly.</p>
<h3 id="step-3-create-the-project-and-media-folders">Step 3: Create the project and media folders</h3>
<p>Create the Compose project – the configuration is small and may go on the system disk. You
create the subfolders <code>config</code> and <code>cache</code> right along, <strong>before</strong> the container starts for
the first time: if the Docker daemon created them first, they would belong to <code>root</code> – but
Jellyfin runs as UID 1000 (see step 4) and couldn&rsquo;t write into its own <code>/config</code>, the
container would crash on the first start.</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 ~/jellyfin/<span class="o">{</span>config,cache<span class="o">}</span> <span class="o">&amp;&amp;</span> <span class="nb">cd</span> ~/jellyfin</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>Audiobooks belong elsewhere
  </p>
  <div class="prose-kitchen text-sm">Jellyfin can do music – but it is awkward for <strong>audiobooks and podcasts</strong>: it does not sync your
listening position across devices, and it knows nothing about chapter marks or a sleep timer. If you
want both, use <a href="/en/tutorials/audiobookshelf-audiobooks/">Audiobookshelf</a> for those; the two services
run side by side on the same server without trouble.</div>
</div>
<p>And the folder structure for the library on the block storage. Jellyfin sorts best when
movies, series and music are kept separate:</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 mkdir -p /mnt/media/<span class="o">{</span>filme,serien,musik<span class="o">}</span></span></span></code></pre></div>
</div>
<p>So that the Jellyfin container may read the files, they have to belong to your user.
Determine your user and group ID (usually <code>1000</code>) and hand over the folder:</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">id -u <span class="o">&amp;&amp;</span> id -g
</span></span><span class="line"><span class="cl">sudo chown -R 1000:1000 /mnt/media</span></span></code></pre></div>
</div>
<p>Now copy your media into the appropriate subfolders (via <code>scp</code>, <code>rsync</code> or an SFTP client).
For clean recognition, a clear naming helps, e.g. <code>filme/The Godfather (1972)/The Godfather (1972).mkv</code>.</p>
<h3 id="step-4-the-composeyaml">Step 4: The compose.yaml</h3>
<p>Now the central file. Replace <code>jellyfin.YOUR_DOMAIN</code> with your subdomain:</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">jellyfin</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">jellyfin/jellyfin:10.11.11</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">user</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;1000:1000&#34;</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">JELLYFIN_PublishedServerUrl=https://jellyfin.YOUR_DOMAIN</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">./config:/config</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./cache:/cache</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/mnt/media:/media: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.jellyfin.rule=Host(`jellyfin.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.jellyfin.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.jellyfin.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.jellyfin.loadbalancer.server.port=8096&#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>What the lines mean:</p>
<ul>
<li><strong><code>image: …:10.11.11</code></strong> – the fixed, current stable version. No <code>latest</code> (see
&ldquo;Maintenance &amp; backups&rdquo;).</li>
<li><strong><code>user: &quot;1000:1000&quot;</code></strong> – Jellyfin runs with <strong>your</strong> user/group ID so it can read the
media. If you adjust other IDs in step 3, change them here too.</li>
<li><strong><code>JELLYFIN_PublishedServerUrl</code></strong> – tells Jellyfin under which public address it&rsquo;s
reachable. This prevents app links from suddenly pointing to an internal IP.</li>
<li><strong><code>volumes</code></strong>: <code>config</code> (settings, users, metadata) and <code>cache</code> live small in the project
folder; the <strong>library</strong> is mounted from the block storage under <code>/mnt/media</code> into the
container as <code>/media</code> – <strong><code>:ro</code></strong> (read-only), so Jellyfin never changes your originals.</li>
<li><strong>The four Traefik labels</strong> are the familiar pattern. New is only the port: Jellyfin
listens on <strong>8096</strong>, hence the <code>loadbalancer.server.port</code> label. Without it, there&rsquo;d be a
<code>502 Bad Gateway</code>.</li>
<li><strong>No <code>ports:</code></strong> – Jellyfin is reachable exclusively via Traefik (HTTPS), not directly from
the internet.</li>
</ul>
<p>Start the stack and watch it boot:</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 jellyfin</span></span></code></pre></div>
</div>
<p>On the first start, certificate issuance takes a few seconds. Once <code>Startup complete</code>
appears in the log, Jellyfin is ready.</p>
<h3 id="step-5-the-initial-setup-wizard">Step 5: The initial setup wizard</h3>
<p>Open <code>https://jellyfin.YOUR_DOMAIN</code> in the browser. On the very first call, a wizard guides
you through the basic setup:</p>
<ol>
<li>Choose the <strong>language</strong>.</li>
<li>Create an <strong>administrator account</strong> – set a <strong>long, unique password</strong>, because the login
page is publicly on the net.</li>
<li><strong>Add media libraries</strong>: click &ldquo;Add media library&rdquo;, choose the type (e.g. <em>Movies</em>), and
enter the <strong>container path</strong> as the folder – i.e. <code>/media/filme</code> (not <code>/mnt/media/filme</code>,
that&rsquo;s the path on the host!). Repeat this for <code>serien</code> and <code>musik</code>.</li>
<li>Set the <strong>metadata language</strong> and you can accept the rest.</li>
</ol>
<p><figure class="my-6"><img src="/en/tutorials/jellyfin-media-server/jellyfin-einrichtung_hu_c1c02efb7c260d14.webp" srcset="/en/tutorials/jellyfin-media-server/jellyfin-einrichtung_hu_458de0a2575dec2c.webp 480w, /en/tutorials/jellyfin-media-server/jellyfin-einrichtung_hu_c1c02efb7c260d14.webp 768w, /en/tutorials/jellyfin-media-server/jellyfin-einrichtung_hu_6b6f7e3feb89f399.webp 1200w, /en/tutorials/jellyfin-media-server/jellyfin-einrichtung_hu_27aefc6cf81a8bf0.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/jellyfin-media-server/jellyfin-einrichtung_hu_874c631adaf10bb8.webp"
    alt="The Jellyfin setup wizard on first launch with fields for server name and preferred display language" title="The setup wizard on first launch"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The setup wizard on first launch</figcaption></figure></p>
<p>After the wizard, Jellyfin scans the folders and loads covers and descriptions. For large
collections the first scan takes a while – you see the progress under <strong>Dashboard →
Scheduled Tasks</strong>. After that you land on the home page and see your library with the first
titles:</p>
<p><figure class="my-6"><img src="/en/tutorials/jellyfin-media-server/jellyfin-startseite_hu_a427defcc02cab4a.webp" srcset="/en/tutorials/jellyfin-media-server/jellyfin-startseite_hu_8ee95ee88c46f3a5.webp 480w, /en/tutorials/jellyfin-media-server/jellyfin-startseite_hu_a427defcc02cab4a.webp 768w, /en/tutorials/jellyfin-media-server/jellyfin-startseite_hu_c9cf18598e9354f8.webp 1200w, /en/tutorials/jellyfin-media-server/jellyfin-startseite_hu_36db899c147b3bfc.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/jellyfin-media-server/jellyfin-startseite_hu_f5429822b251a633.webp"
    alt="The Jellyfin home page after setup with the &ldquo;Movies&rdquo; library and a recently added movie" title="The home page with the first library"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The home page with the first library</figcaption></figure></p>
<h3 id="step-6-configure-jellyfin-cleanly-behind-the-proxy">Step 6: Configure Jellyfin cleanly behind the proxy</h3>
<p>Two settings make Jellyfin work correctly behind Traefik. Open <strong>Dashboard → Networking</strong>:</p>
<p><figure class="my-6"><img src="/en/tutorials/jellyfin-media-server/jellyfin-dashboard_hu_49ea4f89f0c94e48.webp" srcset="/en/tutorials/jellyfin-media-server/jellyfin-dashboard_hu_236cd86efabb984.webp 480w, /en/tutorials/jellyfin-media-server/jellyfin-dashboard_hu_49ea4f89f0c94e48.webp 768w, /en/tutorials/jellyfin-media-server/jellyfin-dashboard_hu_d69db5ed4f3767ff.webp 1200w, /en/tutorials/jellyfin-media-server/jellyfin-dashboard_hu_474858f0a34026a.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/jellyfin-media-server/jellyfin-dashboard_hu_656fccc814e03344.webp"
    alt="The Jellyfin administration dashboard with server version 10.11.11, active devices and the storage paths, on the left the menu with network and playback settings" title="The admin dashboard – networking and playback sit here too"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The admin dashboard – networking and playback sit here too</figcaption></figure></p>
<ul>
<li>
<p>Under <strong>Known proxies</strong> you enter the Traefik network so Jellyfin sees the <strong>real</strong> client
IP (instead of Traefik&rsquo;s container IP). You determine the subnet with:</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 inspect proxy --format <span class="s1">&#39;{{ (index .IPAM.Config 0).Subnet }}&#39;</span></span></span></code></pre></div>
</div>
<p>Enter the returned range (e.g. <code>172.19.0.0/16</code>) there.</p>
</li>
<li>
<p><strong>HTTPS</strong>: since Traefik handles the encryption, Jellyfin itself needs <strong>no</strong> TLS. Leave
the HTTPS options in Jellyfin empty – everything runs via the proxy.</p>
</li>
</ul>
<p>WebSockets (for live updates of the interface) Traefik forwards automatically, nothing to do
there.</p>
<h3 id="step-7-transcoding-vs-direct-play">Step 7: Transcoding vs. Direct Play</h3>
<p>Whether your server breaks a sweat is decided here. Two cases:</p>
<ul>
<li><strong>Direct Play</strong> – the end device can play the file as-is. The server just pushes bytes
through, <strong>almost no CPU load</strong>. That&rsquo;s the ideal case.</li>
<li><strong>Transcoding</strong> – format, codec or bitrate don&rsquo;t fit the device (or the line is too slow),
so Jellyfin re-encodes <strong>live</strong>. On a netcup server this happens via the <strong>CPU</strong> – with a
4K stream that can max out a whole server.</li>
</ul>
<p>This is how you keep the load low:</p>
<ul>
<li>Provide <strong>suitable formats</strong>: H.264/AAC in an <code>.mp4</code>/<code>.mkv</code> plays directly on practically
every device. Exotic codecs (e.g. certain 4K HEVC audio tracks) force transcoding.</li>
<li>Set the <strong>client quality</strong> in the app to &ldquo;Original/Direct Play&rdquo; when the line is enough.</li>
<li>Under <strong>Dashboard → Playback</strong> you can set the transcoding settings and a limit for
simultaneous streams.</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>Hardware transcoding needs a GPU
  </p>
  <div class="prose-kitchen text-sm">The <strong>hardware acceleration</strong> selectable in Jellyfin (VAAPI, QSV, NVENC) requires a graphics
unit under <code>/dev/dri</code>. Standard VPS and root servers at netcup are KVM machines <strong>without</strong> a
passed-through GPU – leave hardware acceleration <strong>off</strong> there, it would only produce errors.
Instead plan with dedicated CPU cores and Direct Play.</div>
</div>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong><code>502 Bad Gateway</code> on access, even though the container is running.</strong> Traefik reaches the
container but hits the wrong port. Jellyfin listens on <strong>8096</strong> – the label
<code>traefik.http.services.jellyfin.loadbalancer.server.port=8096</code> must be set. Also check that
Jellyfin is on the <strong><code>proxy</code> network</strong>.</p>
<p><strong>The libraries stay empty, even though files are there.</strong> Almost always a <strong>permission
problem</strong>. The container runs as <code>1000:1000</code> (step 4), so the media must belong to that
user: <code>sudo chown -R 1000:1000 /mnt/media</code>. And: in Jellyfin the <strong>container path</strong> must be
entered (<code>/media/filme</code>), not the host path. Afterwards start &ldquo;Scan all libraries&rdquo; under
<strong>Dashboard → Scheduled Tasks</strong>.</p>
<p><strong>Video stutters/buffers, <code>docker stats</code> shows Jellyfin at ~100% CPU.</strong> It&rsquo;s
<strong>transcoding</strong>. Under <strong>Dashboard → Playback</strong> the activity monitor shows whether it says
&ldquo;Transcode&rdquo; instead of &ldquo;Direct Play&rdquo;. Set the client quality to original, change exotic
codecs to a widely supported format, or give the server more (dedicated) cores.</p>
<p><strong>After a reboot the library is gone and Jellyfin shows empty folders.</strong> The block storage
wasn&rsquo;t mounted – usually a missing or wrong <code>fstab</code> entry (step 2). Check with <code>df -h /mnt/media</code> and <code>sudo mount -a</code>. The line must use the <strong>UUID</strong>, not <code>/dev/vdb1</code>.</p>
<p><strong>The login page loads, but the app can&rsquo;t find the server / links point to an internal
address.</strong> <code>JELLYFIN_PublishedServerUrl</code> is missing or wrong. Set it in the <code>compose.yaml</code>
to <code>https://jellyfin.YOUR_DOMAIN</code> and restart with <code>docker compose up -d</code> (a <code>restart</code> isn&rsquo;t
enough for the environment change to take effect).</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<ul>
<li><strong>Apply updates deliberately.</strong> The fixed tag (<code>jellyfin:10.11.11</code>) means: you decide when
to update. Bump the tag → <code>docker compose pull</code> → <code>docker compose up -d</code>. Before jumping to
a new major version, read the release notes – database migrations aren&rsquo;t always reversible.</li>
<li><strong>Back up the config, treat the media separately.</strong> The <code>config</code> volume (users, settings,
playback state, metadata) is small and the actually valuable part – back it up
<a href="/en/tutorials/restic-backups/">encrypted off-site with Restic</a>. The <strong>library</strong> itself is
usually too large for a classic backup and reproducible from the original media; decide
deliberately whether you back it up or classify it as &ldquo;replaceable&rdquo;. Honestly: a second
backup of your movie collection costs real storage.</li>
<li><strong>Monitor disk space.</strong> A library grows steadily. Keep an eye on usage
(<code>df -h /mnt/media</code>) and best set up a monitor in
<a href="/en/tutorials/uptime-kuma-monitoring/">Uptime Kuma</a> that raises the alarm before the
block storage is full.</li>
<li><strong>Publicly reachable = secure it.</strong> The login page is on the net, and Jellyfin brings <strong>no
two-factor authentication</strong> (not even via an official plugin). So consistently assign
strong passwords, deactivate unused accounts – and for purely private use, consider making
Jellyfin reachable not publicly but only via a VPN.</li>
</ul>
]]></content:encoded></item><item><title>Self-hosting Immich: your private photo backup behind Traefik</title><link>https://serverkueche.de/en/tutorials/self-host-immich-photos/</link><pubDate>Mon, 20 Jul 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/self-host-immich-photos/</guid><description>Set up Immich with Docker behind Traefik: the self-hosted alternative to Google Photos – with automatic phone backup, face recognition and HTTPS.</description><content:encoded><![CDATA[<p>Thousands of photos and videos from your phone – and all of them at Google or Apple.
<strong>Immich</strong> brings them back to your server: a self-hosted photo service that comes
astonishingly close to Google Photos, including automatic phone upload, timeline, search
and face recognition. In this recipe we set up Immich cleanly behind Traefik.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>By the end, <strong>Immich v3</strong> runs behind your Traefik proxy, reachable at
<code>https://photos.YOUR_DOMAIN</code> with valid HTTPS. The official <strong>Immich phone app</strong> then backs
up your new photos and videos automatically to your server – like the cloud backup of Google
Photos, except the images live exclusively with you. Via the web interface you browse your
timeline, search by full text (&ldquo;beach&rdquo;, &ldquo;dog&rdquo;) and let Immich group faces.</p>
<p>Immich consists of <strong>four containers</strong>: the server (web + API), a machine-learning service
(for search and face recognition), a PostgreSQL database with a vector extension, and a cache
(Valkey). Sounds like a lot – but the official template takes most of it off your hands, and
we only hang the server onto Traefik.</p>
<p>The appeal over Google Photos: the images don&rsquo;t leave your server, there are no storage
subscriptions and no automatic analysis of your shots by a corporation. In return, you bear
responsibility for operation and – very importantly – for <strong>backups</strong>: if the server dies and
you have no backup, the photos are gone. That&rsquo;s exactly why the backup below is not optional
but mandatory.</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>Immich moves fast – pin the version
  </p>
  <div class="prose-kitchen text-sm">Immich develops rapidly. Database migrations run automatically on update and are <strong>not
backward-compatible</strong> – a downgrade is no longer cleanly possible afterwards. So <strong>always pin
a fixed version</strong> (here <code>v3.0.3</code>) instead of <code>release</code> or <code>latest</code>, and make a <strong>backup
before every update</strong>. That way you decide when to update.</div>
</div>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>A running <strong>Traefik reverse proxy</strong> with the <code>proxy</code> network and the Let&rsquo;s Encrypt resolver
<code>le</code> – see <a href="/en/tutorials/traefik-reverse-proxy/">reverse proxy with Traefik</a>.</li>
<li>A subdomain <code>photos.YOUR_DOMAIN</code> with a DNS record to your server IP – see
<a href="/en/tutorials/connect-domain-to-server/">connecting a domain to your server</a>.</li>
<li>A <strong>backup</strong>. Your photo collection is irreplaceable – first set up
<a href="/en/tutorials/restic-backups/">backups with Restic</a>.</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>How big does the server need to be?
  </p>
  <div class="prose-kitchen text-sm">Immich is the most resource-hungry service in the Serverküche. Officially, <strong>at least 6 GB
RAM (8 recommended)</strong> are required – the machine-learning service for face recognition and
search in particular needs memory. The tested <strong>VPS 1000 with 8 GB RAM</strong> just meets that but
has little reserve. For a large library or several users, a server with <strong>16 GB RAM</strong> (e.g.
VPS 2000) is more relaxed. On a 4 GB server, Immich only runs with <strong>ML turned off</strong> (see
&ldquo;When things go wrong&rdquo;). And: photos need space – plan for enough disk.</div>
</div>
<p>How much RAM your photo library including AI search really needs is calculated by the
<a href="/en/server-calculator/">server calculator</a>.</p>
<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">For a large photo library with face recognition, 16 GB RAM is comfortable.</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="step-by-step">Step by step</h2>
<h3 id="step-1-create-the-dns-record">Step 1: Create the DNS record</h3>
<p>Create an entry <code>photos.YOUR_DOMAIN</code> that points to your server IP, and check 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">dig +short photos.YOUR_DOMAIN</span></span></code></pre></div>
</div>
<p>Your server IP must come back – otherwise Traefik won&rsquo;t fetch a certificate later.</p>
<h3 id="step-2-prepare-the-folder-and-env">Step 2: Prepare the folder and .env</h3>
<p>Immich is configured via a <code>.env</code> file. Create the project:</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 ~/immich <span class="o">&amp;&amp;</span> <span class="nb">cd</span> ~/immich</span></span></code></pre></div>
</div>
<p>Create a <code>.env</code> with the core values (set a strong DB password – <strong>letters and digits
only</strong>, no special characters, Immich&rsquo;s DB init doesn&rsquo;t like those):</p>
<div class="sk-code">
  <span class="sk-code-head">INI</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="na">UPLOAD_LOCATION</span><span class="o">=</span><span class="s">./library</span>
</span></span><span class="line"><span class="cl"><span class="na">DB_DATA_LOCATION</span><span class="o">=</span><span class="s">./postgres</span>
</span></span><span class="line"><span class="cl"><span class="na">DB_PASSWORD</span><span class="o">=</span><span class="s">YOUR_DB_PASSWORD</span>
</span></span><span class="line"><span class="cl"><span class="na">DB_USERNAME</span><span class="o">=</span><span class="s">postgres</span>
</span></span><span class="line"><span class="cl"><span class="na">DB_DATABASE_NAME</span><span class="o">=</span><span class="s">immich</span>
</span></span><span class="line"><span class="cl"><span class="na">IMMICH_VERSION</span><span class="o">=</span><span class="s">v3.0.3</span></span></span></code></pre></div>
</div>
<ul>
<li><strong><code>UPLOAD_LOCATION</code></strong> – this is where your <strong>photos and videos</strong> land. It&rsquo;s the most
important directory for the backup.</li>
<li><strong><code>DB_DATA_LOCATION</code></strong> – the PostgreSQL data. Must be on a <strong>local disk</strong> (no
network drive/NFS – the DB resents that).</li>
<li><strong><code>IMMICH_VERSION</code></strong> – the pinned version.</li>
</ul>
<h3 id="step-3-create-the-composeyaml">Step 3: Create the compose.yaml</h3>
<p>We adopt the official Immich template and only add the Traefik labels on the <code>immich-server</code>.
Create <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">name</span><span class="p">:</span><span class="w"> </span><span class="l">immich</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">immich-server</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">ghcr.io/immich-app/immich-server:${IMMICH_VERSION}</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">${UPLOAD_LOCATION}:/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/etc/localtime:/etc/localtime:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">env_file</span><span class="p">:</span><span class="w"> </span><span class="l">.env</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">DB_HOSTNAME</span><span class="p">:</span><span class="w"> </span><span class="l">database</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">REDIS_HOSTNAME</span><span class="p">:</span><span class="w"> </span><span class="l">redis</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">redis</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_started</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">database</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></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.immich.rule=Host(`photos.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.immich.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.immich.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.immich.loadbalancer.server.port=2283&#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">default, 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="w">  </span><span class="nt">immich-machine-learning</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">ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION}</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">model-cache:/cache</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">default]</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">redis</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">docker.io/valkey/valkey:9</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">default]</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">database</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">ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.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="nt">POSTGRES_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">${DB_PASSWORD}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">POSTGRES_USER</span><span class="p">:</span><span class="w"> </span><span class="l">${DB_USERNAME}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">POSTGRES_DB</span><span class="p">:</span><span class="w"> </span><span class="l">${DB_DATABASE_NAME}</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">${DB_DATA_LOCATION}:/var/lib/postgresql/data</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 ${DB_USERNAME} -d ${DB_DATABASE_NAME}&#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">networks</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">default]</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">model-cache</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">default</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>Important to understand:</p>
<ul>
<li><strong>Only <code>immich-server</code> is on the <code>proxy</code> network</strong> and carries Traefik labels. The ML
service, Valkey and the database stay exclusively on the internal <code>default</code> network – not
reachable from outside.</li>
<li><strong><code>loadbalancer.server.port=2283</code></strong> – Immich listens on port 2283 in the container.</li>
<li>The <strong>database</strong> is deliberately the prebuilt Immich Postgres image with the vector
extension <em>VectorChord</em> (for image search). Don&rsquo;t just use a standard <code>postgres</code> – the
extension is then missing.</li>
<li><strong>Valkey</strong> is the Redis successor; the service is still called <code>redis</code> in the Immich
template for compatibility reasons.</li>
<li>The <strong>healthcheck</strong> on the database reports when Postgres is really ready. Via
<code>depends_on: … condition: service_healthy</code>, <code>immich-server</code> only starts then – this
prevents migration errors and the &ldquo;database not reachable&rdquo; start on the first boot.</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>Applying changes to the .env
  </p>
  <div class="prose-kitchen text-sm">If you later change something in the <code>.env</code>, a <code>docker compose restart</code> is <strong>not</strong> enough –
Docker only reads the environment variables when <strong>recreating</strong> the containers. Use
<code>docker compose up -d</code> then; Compose detects the change and rebuilds the affected containers.</div>
</div>
<h3 id="step-4-allow-large-uploads-traefik-timeout">Step 4: Allow large uploads (Traefik timeout)</h3>
<p>This is <strong>the</strong> stumbling block with Immich behind Traefik: by default Traefik aborts
connections after <strong>60 seconds</strong> (<code>readTimeout</code>). When uploading large videos from the phone
this leads to aborted uploads (error 502/499). Increase the timeout on the <code>websecure</code>
entrypoint in your <strong>Traefik configuration</strong> (the <code>traefik</code> service from the
<a href="/en/tutorials/traefik-reverse-proxy/">Traefik tutorial</a>):</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">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"># ... your existing lines ...</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--entrypoints.websecure.transport.respondingTimeouts.readTimeout=600s&#34;</span></span></span></code></pre></div>
</div>
<p>Restart Traefik afterwards (<code>docker compose up -d</code> in the Traefik folder). Unlike nginx,
Traefik has no fixed size limit for uploads – only this timeout has to be high.</p>
<h3 id="step-5-start-and-run-through-the-initial-setup">Step 5: Start and run through the initial setup</h3>
<p>Pull the images (several GB – this takes a while) and start:</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 immich-server</span></span></code></pre></div>
</div>
<p>The images are several gigabytes in size – the first <code>pull</code> takes a few minutes depending on
your connection. Then check that all four 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>You should see <code>immich-server</code>, <code>immich-machine-learning</code>, <code>redis</code> and <code>database</code> with status
<code>running</code> (the database <code>healthy</code>). Wait until
<code>Immich Server is listening on http://[::1]:2283 [v3.0.3]</code> appears in the log. Then open
<code>https://photos.YOUR_DOMAIN</code>. Traefik fetches the Let&rsquo;s Encrypt certificate on the first
access (be patient for a moment). On the very first start, Immich greets you with <strong>&ldquo;Welcome
to Immich&rdquo;</strong> and the choice of whether you <strong>start fresh</strong> or restore a backup:</p>
<p><figure class="my-6"><img src="/en/tutorials/self-host-immich-photos/immich-willkommen_hu_df42e8458020275e.webp" srcset="/en/tutorials/self-host-immich-photos/immich-willkommen_hu_a2524608a6612199.webp 480w, /en/tutorials/self-host-immich-photos/immich-willkommen_hu_df42e8458020275e.webp 768w, /en/tutorials/self-host-immich-photos/immich-willkommen_hu_22b944244b61376c.webp 1200w, /en/tutorials/self-host-immich-photos/immich-willkommen_hu_6355c1dbc067f168.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/self-host-immich-photos/immich-willkommen_hu_157a6edfcb1fc735.webp"
    alt="The welcome screen of Immich with the options &ldquo;Getting Started&rdquo; and &ldquo;Restore from database&rdquo;" title="First launch: choose \&#34;Getting Started\&#34; for a fresh installation"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">First launch: choose \&#34;Getting Started\&#34; for a fresh installation</figcaption></figure></p>
<p>Click <strong>Getting Started</strong> (you only need Restore if you&rsquo;re importing a backup). Then you
create the <strong>administrator account</strong> – as the first user you automatically become admin:</p>
<p><figure class="my-6"><img src="/en/tutorials/self-host-immich-photos/immich-registrierung_hu_5e17e8d959b4be91.webp" srcset="/en/tutorials/self-host-immich-photos/immich-registrierung_hu_5da2b7656e747eea.webp 480w, /en/tutorials/self-host-immich-photos/immich-registrierung_hu_5e17e8d959b4be91.webp 768w, /en/tutorials/self-host-immich-photos/immich-registrierung_hu_dc0630b55968f943.webp 1200w, /en/tutorials/self-host-immich-photos/immich-registrierung_hu_94e19f1b46ee602b.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/self-host-immich-photos/immich-registrierung_hu_7d1f7c90347fc007.webp"
    alt="The Immich form &ldquo;Admin Registration&rdquo; with fields for email, password and name" title="Admin registration – the first user automatically becomes administrator"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Admin registration – the first user automatically becomes administrator</figcaption></figure></p>
<p>After the first login, a short <strong>setup wizard</strong> guides you through theme, language and basic
privacy settings:</p>
<p><figure class="my-6"><img src="/en/tutorials/self-host-immich-photos/immich-onboarding_hu_605f1bfc221f0d38.webp" srcset="/en/tutorials/self-host-immich-photos/immich-onboarding_hu_3985d8f71d5bbe6b.webp 480w, /en/tutorials/self-host-immich-photos/immich-onboarding_hu_605f1bfc221f0d38.webp 768w, /en/tutorials/self-host-immich-photos/immich-onboarding_hu_7a4acec9c89174a5.webp 1200w, /en/tutorials/self-host-immich-photos/immich-onboarding_hu_afbeaf6cfb205ba2.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/self-host-immich-photos/immich-onboarding_hu_8ded2f93b7bae082.webp"
    alt="The Immich setup wizard greets the new administrator" title="The onboarding wizard after the first login"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The onboarding wizard after the first login</figcaption></figure></p>
<p>After that you land on your (still empty) <strong>timeline</strong> – the heart of Immich:</p>
<p><figure class="my-6"><img src="/en/tutorials/self-host-immich-photos/immich-timeline_hu_7736fed89cfe0c0f.webp" srcset="/en/tutorials/self-host-immich-photos/immich-timeline_hu_29dd4a91051bc5d2.webp 480w, /en/tutorials/self-host-immich-photos/immich-timeline_hu_7736fed89cfe0c0f.webp 768w, /en/tutorials/self-host-immich-photos/immich-timeline_hu_5f77e6240ffda01c.webp 1200w, /en/tutorials/self-host-immich-photos/immich-timeline_hu_3e1ec3671920a427.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/self-host-immich-photos/immich-timeline_hu_d8deafe9761a334f.webp"
    alt="The empty photo timeline of Immich with the prompt to upload the first photo" title="The photo timeline – all images later flow in here (server online, v3.0.3)"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The photo timeline – all images later flow in here (server online, v3.0.3)</figcaption></figure></p>
<h3 id="step-6-connect-the-phone-app">Step 6: Connect the phone app</h3>
<p>The real benefit comes from the app. Install <strong>Immich</strong> from the App Store or Play Store (or
F-Droid). At launch it asks for the <strong>server address</strong> – enter <code>https://photos.YOUR_DOMAIN</code>
and log in with your account. Then, in the app settings, enable the <strong>backup</strong> and choose the
albums to be uploaded.</p>
<p>From now on your phone backs up new photos automatically to your server – in the background
and over Wi-Fi. The first pass of a large library takes a while; after that only new shots are
added.</p>
<p>After the upload, Immich keeps working in the background: it generates thumbnails, reads the
capture metadata (date, location) and lets the ML service <strong>recognize faces</strong> and index the
images for <strong>smart search</strong>. These jobs run for some time after the first big import – so
faces and search hits only appear gradually. You see the progress under <strong>Administration →
Job queues</strong>. In the sidebar you then find the typical photo features: <strong>Explore</strong> (by people
and places), the <strong>map</strong> with geolocation, <strong>albums</strong> for sharing and the <strong>memories</strong> (&ldquo;a
year ago&rdquo;).</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-sky-300 bg-sky-50 dark:border-sky-800 dark:bg-sky-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">ℹ️</span>Videos &amp; transcoding on the VPS
  </p>
  <div class="prose-kitchen text-sm">Immich converts videos for playback in the browser (transcoding). On a normal VPS <strong>without a
graphics card</strong> this happens via the CPU – with many or long videos this is noticeably slower
and maxes out cores. For a private phone backup this is usually no problem; whoever has a lot
of video should choose the server correspondingly larger.</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>More users
  </p>
  <div class="prose-kitchen text-sm">For the family, create further accounts under <strong>Administration → Users</strong> – each gets its own
separate library. Registration from outside is off by default; new users only arise via the
admin account.</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>Your photos are on the internet
  </p>
  <div class="prose-kitchen text-sm">As soon as Immich is reachable via <code>photos.YOUR_DOMAIN</code>, the login page is open on the net. So
set a <strong>long, unique password</strong> for the admin and all user accounts and enable <strong>two-factor
authentication</strong> in the account settings. Whoever wants to be extra safe makes Immich reachable
only via a VPN – but for automatic phone upload, direct HTTPS reachability is usually the more
practical way.</div>
</div>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong>Uploads of large videos abort after about a minute (error 502 or 499).</strong> Traefik&rsquo;s
<code>readTimeout</code> (default 60 s) kicks in. Set it to <code>600s</code> (or higher) as in step 4 and restart
Traefik. This is by far the most common Immich-behind-proxy error.</p>
<p><strong>The <code>immich-machine-learning</code> container crashes or exits with &ldquo;exit 137&rdquo;, search and face
recognition don&rsquo;t work.</strong> Too little RAM – the container was killed by the system (out of
memory). Give the server more memory, or <strong>disable ML</strong> by removing the service
<code>immich-machine-learning</code> from the <code>compose.yaml</code>. Immich then runs without face recognition
and smart search, but upload and timeline work normally.</p>
<p><strong>After an update the containers no longer start or report migration errors.</strong> Server, machine
learning and database must run on the <strong>same</strong> version. Set the new <code>IMMICH_VERSION</code> in the
<code>.env</code> and update <strong>all</strong> containers together (<code>docker compose pull &amp;&amp; docker compose up -d</code>).
A downgrade after a migration isn&rsquo;t possible – only a reset from the backup.</p>
<p><strong>Immich shows &ldquo;maintenance mode&rdquo; / &ldquo;temporarily unavailable&rdquo;.</strong> Immich v3 starts into a
maintenance mode under certain database states. The log (<code>docker compose logs immich-server</code>)
then contains a URL with a <strong>one-time token</strong> (<code>…/maintenance?token=…</code>) – through it you log
into the maintenance mode and choose &ldquo;restart&rdquo; or end it. After that the normal interface is
back.</p>
<p><strong>Container won&rsquo;t start, &ldquo;permission denied&rdquo; on the database or upload directory.</strong>
<code>UPLOAD_LOCATION</code> or <code>DB_DATA_LOCATION</code> have the wrong access rights or are on a network
drive. Put both on a local disk and make sure Docker may write into them.</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<ul>
<li>
<p><strong>Two things belong in the backup – consistently together.</strong> Your <strong>photos</strong>
(<code>UPLOAD_LOCATION</code>, i.e. <code>~/immich/library</code>) and the <strong>database</strong>. The DB dump contains only
the metadata; without the matching files it&rsquo;s worthless, and vice versa. Cleanest is to stop
Immich briefly and back up both together:</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 stop immich-server
</span></span><span class="line"><span class="cl">docker compose <span class="nb">exec</span> -T database pg_dump --clean --if-exists <span class="se">\
</span></span></span><span class="line"><span class="cl">  --dbname<span class="o">=</span>immich --username<span class="o">=</span>postgres <span class="p">|</span> gzip &gt; immich-db.sql.gz
</span></span><span class="line"><span class="cl">docker compose start immich-server</span></span></code></pre></div>
</div>
<p>Back up the dump together with the <code>library</code> folder <strong>encrypted and off-site</strong> with
<a href="/en/tutorials/restic-backups/">Restic</a>. Alternatively, Immich can generate automatic DB
dumps under <strong>Administration → Job queues</strong> – you still have to back up the photos
separately.</p>
</li>
<li>
<p><strong>Rehearse the worst case once.</strong> A backup you&rsquo;ve never restored is just a hope. To restore,
you import the DB dump into a fresh Immich instance of the same version and place the
<code>library</code> folder in the same spot – Immich offers the option <strong>&ldquo;Restore from database&rdquo;</strong> on
the first start for this (the second button from step 5). What matters is the <strong>same
version</strong>: the database state and the program must match.</p>
</li>
<li>
<p><strong>Backup before every update.</strong> Because migrations aren&rsquo;t reversible, the backup is your only
way back. Back up first, then raise <code>IMMICH_VERSION</code>, then
<code>docker compose pull &amp;&amp; docker compose up -d</code>.</p>
</li>
<li>
<p><strong>Honest about the effort:</strong> Immich often brings several releases per month. You don&rsquo;t have
to follow every one – but before a jump across several versions, read the release notes, and
stick to the pinned version until you deliberately update.</p>
</li>
<li>
<p><strong>Keep an eye on disk space.</strong> Photos and videos grow steadily; Immich additionally creates
thumbnails and converted videos. Monitor the disk usage (e.g. with
<a href="/en/tutorials/uptime-kuma-monitoring/">Uptime Kuma</a>) so the server doesn&rsquo;t fill up.</p>
</li>
</ul>
]]></content:encoded></item><item><title>Self-hosting Paperless-ngx: the paperless office with OCR</title><link>https://serverkueche.de/en/tutorials/self-host-paperless-ngx/</link><pubDate>Mon, 20 Jul 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/self-host-paperless-ngx/</guid><description>Set up Paperless-ngx with Docker behind Traefik: archive documents searchably via OCR – with full-text search, tags and automatic import.</description><content:encoded><![CDATA[<p>Invoices, contracts, official mail – the paper pile grows and grows, and in the end you
never find anything anyway. <strong>Paperless-ngx</strong> turns it into a searchable digital archive:
you throw a document in, it&rsquo;s recognized via <strong>OCR</strong>, tagged, and back in seconds via the
full-text search. In this recipe we set it up behind Traefik.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>By the end, <strong>Paperless-ngx 2.20</strong> runs behind your Traefik proxy, reachable at
<code>https://paperless.YOUR_DOMAIN</code> with HTTPS. Paperless is a document management system: you
feed it scans or PDFs, it reads out the text via <strong>OCR</strong> (even from pure image scans),
recognizes date and content, and stores everything searchably. Via tags, correspondents and
document types you bring order into it; the full-text search later finds every document.</p>
<p>The clever part is the <strong>consume folder</strong>: everything you place there (e.g. from a network
scanner) is imported and processed automatically. Paperless consists of <strong>five containers</strong> –
the web server, a PostgreSQL database, a Redis broker for background processing, plus
<strong>Gotenberg</strong> and <strong>Tika</strong>, which convert office documents into PDF. The official template
provides the complete team.</p>
<p>The gain over a folder full of PDFs on the disk: Paperless makes every document
<strong>searchable</strong> (even scanned paper), keeps the original and archive version cleanly
separated, and can be automated via rules. Your documents stay on your server – no cloud
service reads along, and you&rsquo;re not tied to a proprietary format.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>A running <strong>Traefik reverse proxy</strong> with the <code>proxy</code> network and the resolver <code>le</code> – see
<a href="/en/tutorials/traefik-reverse-proxy/">reverse proxy with Traefik</a>.</li>
<li>A subdomain <code>paperless.YOUR_DOMAIN</code> with a DNS record to your server IP – see
<a href="/en/tutorials/connect-domain-to-server/">connecting a domain to your server</a>.</li>
<li>A <strong>backup</strong>. Your most important documents land in Paperless – first set up
<a href="/en/tutorials/restic-backups/">backups with Restic</a>.</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>How big does the server need to be?
  </p>
  <div class="prose-kitchen text-sm">Paperless itself is frugal, but it&rsquo;s five containers, and the <strong>OCR processing is
CPU-heavy</strong>: when recognizing a scan, a core runs at full tilt for a few seconds to minutes.
For private use, <strong>2 vCPU and 4 GB RAM</strong> are quite enough; the tested VPS 1000 (4 vCore /
8 GB) has plenty of headroom. During bulk imports of many documents you notice the CPU load –
then Paperless just works through the queue bit by bit.</div>
</div>
<p>Not sure which server size is enough? The <a href="/en/server-calculator/">server calculator</a>
calculates the RAM and CPU needs for your services.</p>
<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 1000 G12</p>
      <p class="mt-1 text-sm text-slate-600 dark:text-slate-300">4 vCores · 8 GB RAM · 256 GB NVMe</p>
      <p class="mt-1 text-sm font-semibold text-paprika-700 dark:text-paprika-400">from €10.36/month</p>
      <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">The VPS 1000 handles Paperless including OCR with ease.</p>
    </div>
    <a href="https://www.netcup.com/en/server/vps/vps-1000-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="step-by-step">Step by step</h2>
<h3 id="step-1-create-the-dns-record">Step 1: Create the DNS record</h3>
<p>Create <code>paperless.YOUR_DOMAIN</code> (A/AAAA to your server IP) and 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">dig +short paperless.YOUR_DOMAIN</span></span></code></pre></div>
</div>
<p>Your server IP must come back – otherwise Traefik won&rsquo;t fetch a certificate later.</p>
<h3 id="step-2-generate-a-secret-key">Step 2: Generate a secret key</h3>
<p>Paperless encrypts sessions with a secret key. The default is <strong>publicly known</strong> – a real
risk for an instance on the internet. Generate your own:</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">head -c <span class="m">50</span> /dev/urandom <span class="p">|</span> base64</span></span></code></pre></div>
</div>
<p>Copy the output – it goes into the configuration as <code>PAPERLESS_SECRET_KEY</code> shortly.</p>
<h3 id="step-3-create-the-composeyaml">Step 3: Create the compose.yaml</h3>
<p>Create the project – <strong>including</strong> the two bind-mount folders:</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 ~/paperless/<span class="o">{</span>consume,export<span class="o">}</span> <span class="o">&amp;&amp;</span> <span class="nb">cd</span> ~/paperless</span></span></code></pre></div>
</div>
<p>You deliberately create the subfolders <code>consume</code> and <code>export</code> <strong>now, as a normal user</strong>: if
the Docker daemon created them at startup, they would belong to <code>root</code> – then neither you
(<code>cp</code> into the consume folder) nor Paperless itself (runs via <code>USERMAP_UID</code> as UID 1000)
could write into them.</p>
<p>Create <code>compose.yaml</code>. Replace <code>paperless.YOUR_DOMAIN</code>, the passwords and the
<code>PAPERLESS_SECRET_KEY</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">name</span><span class="p">:</span><span class="w"> </span><span class="l">paperless</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">broker</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">docker.io/library/redis:8</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">redisdata:/data</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">default]</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">docker.io/library/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="nt">POSTGRES_DB</span><span class="p">:</span><span class="w"> </span><span class="l">paperless</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">POSTGRES_USER</span><span class="p">:</span><span class="w"> </span><span class="l">paperless</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">A_STRONG_DB_PASSWORD</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">pgdata:/var/lib/postgresql</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">default]</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">gotenberg</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">docker.io/gotenberg/gotenberg:8.34</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;gotenberg&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--chromium-disable-javascript=true&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--chromium-allow-list=file:///tmp/.*&#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">default]</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">tika</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">docker.io/apache/tika:3.3.1.0</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">default]</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">webserver</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">ghcr.io/paperless-ngx/paperless-ngx:2.20.15</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">db, broker, gotenberg, tika]</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">PAPERLESS_REDIS</span><span class="p">:</span><span class="w"> </span><span class="l">redis://broker:6379</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PAPERLESS_DBHOST</span><span class="p">:</span><span class="w"> </span><span class="l">db</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PAPERLESS_DBUSER</span><span class="p">:</span><span class="w"> </span><span class="l">paperless</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PAPERLESS_DBPASS</span><span class="p">:</span><span class="w"> </span><span class="l">A_STRONG_DB_PASSWORD</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PAPERLESS_TIKA_ENABLED</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 class="nt">PAPERLESS_TIKA_GOTENBERG_ENDPOINT</span><span class="p">:</span><span class="w"> </span><span class="l">http://gotenberg:3000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PAPERLESS_TIKA_ENDPOINT</span><span class="p">:</span><span class="w"> </span><span class="l">http://tika:9998</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PAPERLESS_URL</span><span class="p">:</span><span class="w"> </span><span class="l">https://paperless.YOUR_DOMAIN</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PAPERLESS_SECRET_KEY</span><span class="p">:</span><span class="w"> </span><span class="l">YOUR_LONG_SECRET_KEY</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PAPERLESS_OCR_LANGUAGE</span><span class="p">:</span><span class="w"> </span><span class="l">eng</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PAPERLESS_TIME_ZONE</span><span class="p">:</span><span class="w"> </span><span class="l">Europe/Berlin</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PAPERLESS_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">PAPERLESS_ADMIN_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">A_STRONG_ADMIN_PASSWORD</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">USERMAP_UID</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;1000&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">USERMAP_GID</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;1000&#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">data:/usr/src/paperless/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">media:/usr/src/paperless/media</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./export:/usr/src/paperless/export</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./consume:/usr/src/paperless/consume</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.ppl.rule=Host(`paperless.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.ppl.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.ppl.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.ppl.loadbalancer.server.port=8000&#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">default, 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">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">media</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">pgdata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">redisdata</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">default</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 points:</p>
<ul>
<li><strong>Only <code>webserver</code> is on the <code>proxy</code> network</strong> and carries Traefik labels (port <strong>8000</strong>).
The four helper services (db, broker, gotenberg, tika) stay internal.</li>
<li><strong><code>PAPERLESS_URL</code></strong> is mandatory behind a proxy. Without it, Paperless rejects the login
with a CSRF/&ldquo;403 Forbidden&rdquo; error. It simultaneously sets the allowed hosts and the trusted
origins.</li>
<li><strong><code>PAPERLESS_OCR_LANGUAGE: eng</code></strong> sets text recognition to English. English, German and a
few other languages are already included in the image – no additional package needed.</li>
<li><strong><code>PAPERLESS_ADMIN_USER</code> / <code>_PASSWORD</code></strong> create the superuser automatically on the first
start, so you can log in directly.</li>
<li><strong><code>USERMAP_UID</code>/<code>GID</code></strong> should match your server user&rsquo;s ID (determine via <code>id -u</code> or
<code>id -g</code>, usually <code>1000</code>). Otherwise there&rsquo;s &ldquo;Permission denied&rdquo; in the consume folder.</li>
<li><strong><code>export</code> and <code>consume</code></strong> are deliberately folders in the project directory (bind mounts):
<code>consume</code> is the inbox, <code>export</code> the target for backups.</li>
</ul>
<p>Paperless uses <strong>four storage areas</strong> in total, which you should keep apart: <code>media</code> contains
your <strong>processed documents</strong> (the heart!), <code>data</code> the search index and helper data, <code>consume</code>
is the inbox and <code>export</code> the backup target. For the backup, <code>media</code> and <code>data</code> plus the
database count – exactly what the <code>document_exporter</code> further below takes off your hands.</p>
<h3 id="step-4-start-and-log-in">Step 4: Start and log in</h3>
<p>Pull the images (several GB) and start:</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 webserver</span></span></code></pre></div>
</div>
<p>On the first start, Paperless sets up the database (migrations) – that takes a moment. Check
that all five 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>You should see <code>webserver</code>, <code>db</code>, <code>broker</code>, <code>gotenberg</code> and <code>tika</code> with status <code>running</code> (the
<code>webserver</code> becomes <code>healthy</code> after a short while). Once the worker is ready (<code>celery@… ready</code>
in the log), open <code>https://paperless.YOUR_DOMAIN</code>. The login page appears:</p>
<p><figure class="my-6"><img src="/en/tutorials/self-host-paperless-ngx/paperless-login_hu_8c17b0adc53e6d7c.webp" srcset="/en/tutorials/self-host-paperless-ngx/paperless-login_hu_eab31263f0071468.webp 480w, /en/tutorials/self-host-paperless-ngx/paperless-login_hu_8c17b0adc53e6d7c.webp 768w, /en/tutorials/self-host-paperless-ngx/paperless-login_hu_4c5105a0d43bf62d.webp 1200w, /en/tutorials/self-host-paperless-ngx/paperless-login_hu_b34074ae087a306b.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/self-host-paperless-ngx/paperless-login_hu_548768b5baa3e389.webp"
    alt="The login page of Paperless-ngx under your own domain" title="The login – the superuser was created via environment variable"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The login – the superuser was created via environment variable</figcaption></figure></p>
<p>Log in with <code>admin</code> and your password. You land on the <strong>home page</strong> with a short welcome
message and initial statistics. At the bottom left you see the running <strong>version</strong> – handy to
know the starting point before an update. You can change the language under <strong>Settings</strong> if
needed; by default Paperless follows your browser&rsquo;s language:</p>
<p><figure class="my-6"><img src="/en/tutorials/self-host-paperless-ngx/paperless-dashboard_hu_532c17eeb9a02757.webp" srcset="/en/tutorials/self-host-paperless-ngx/paperless-dashboard_hu_24d85502f951755a.webp 480w, /en/tutorials/self-host-paperless-ngx/paperless-dashboard_hu_532c17eeb9a02757.webp 768w, /en/tutorials/self-host-paperless-ngx/paperless-dashboard_hu_5f8d48fef757cd12.webp 1200w, /en/tutorials/self-host-paperless-ngx/paperless-dashboard_hu_d217a5d44c702c2a.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/self-host-paperless-ngx/paperless-dashboard_hu_b512bdf2de2fef4d.webp"
    alt="The home page of Paperless-ngx with a welcome message and statistics widget" title="The home page after the first login"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The home page after the first login</figcaption></figure></p>
<h3 id="step-5-import-the-first-document">Step 5: Import the first document</h3>
<p>Now the core. There are three ways to get a document in:</p>
<ol>
<li><strong>Via the web interface:</strong> click <strong>Upload documents</strong> at the top right and choose a PDF or
image file.</li>
<li><strong>Via the consume folder:</strong> place a file in <code>~/paperless/consume</code> – Paperless detects it
automatically, processes it and then deletes it from the folder. Ideal for a network
scanner that scans directly there.</li>
<li><strong>From a mailbox:</strong> under <strong>Email</strong>, Paperless can fetch an IMAP account and import
attachments automatically – handy for invoices that come to you by mail anyway.</li>
</ol>
<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">cp ~/an-invoice.pdf ~/paperless/consume/</span></span></code></pre></div>
</div>
<p>Paperless accepts PDFs, images (JPG/PNG/TIFF) and – thanks to Gotenberg and Tika – office
files like Word or Excel. For a pure image scan, the <strong>OCR</strong> reads out the text and places it
as a searchable layer over the document; for a PDF with an existing text layer, Paperless
skips the recognition and is correspondingly faster. The original is preserved untouched –
Paperless additionally creates a searchable archive version.</p>
<p>After a few seconds (OCR takes some time), the document appears under <strong>Documents</strong> – with a
preview and the recognized title:</p>
<p><figure class="my-6"><img src="/en/tutorials/self-host-paperless-ngx/paperless-dokumente_hu_84777342d82cdc6b.webp" srcset="/en/tutorials/self-host-paperless-ngx/paperless-dokumente_hu_9bb40ad0b8765b92.webp 480w, /en/tutorials/self-host-paperless-ngx/paperless-dokumente_hu_84777342d82cdc6b.webp 768w, /en/tutorials/self-host-paperless-ngx/paperless-dokumente_hu_a4d3e509f7d12dd9.webp 1200w, /en/tutorials/self-host-paperless-ngx/paperless-dokumente_hu_e1dfa31205f60c26.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/self-host-paperless-ngx/paperless-dokumente_hu_cd05852f670666a2.webp"
    alt="The document list of Paperless-ngx with a processed document as a tile" title="The document list – the imported document is immediately searchable"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The document list – the imported document is immediately searchable</figcaption></figure></p>
<p>A click opens the <strong>detail view</strong>: on the left the metadata (title, date, correspondent,
tags) and the tabs for content, metadata and history, on the right the document with a
zoomable preview. Paperless has already <strong>recognized the date</strong> from the scan and suggests it
for confirmation – exactly what the OCR does for you in the background:</p>
<p><figure class="my-6"><img src="/en/tutorials/self-host-paperless-ngx/paperless-dokument-detail_hu_2f3ba14ecbae68c7.webp" srcset="/en/tutorials/self-host-paperless-ngx/paperless-dokument-detail_hu_cc142753382bd2b3.webp 480w, /en/tutorials/self-host-paperless-ngx/paperless-dokument-detail_hu_2f3ba14ecbae68c7.webp 768w, /en/tutorials/self-host-paperless-ngx/paperless-dokument-detail_hu_510c85a62c169f85.webp 1200w, /en/tutorials/self-host-paperless-ngx/paperless-dokument-detail_hu_975081bceb44bc8e.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/self-host-paperless-ngx/paperless-dokument-detail_hu_96c911f3a177fc51.webp"
    alt="The detail view of a document in Paperless-ngx with metadata on the left and the recognized document on the right" title="Detail view: OCR recognized text and date, metadata can be supplemented"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Detail view: OCR recognized text and date, metadata can be supplemented</figcaption></figure></p>
<h3 id="step-6-order-with-tags-correspondents--document-types">Step 6: Order with tags, correspondents &amp; document types</h3>
<p>So that the search works later, you assign <strong>tags</strong> (e.g. <code>Tax</code>, <code>Insurance</code>), assign a
<strong>correspondent</strong> (the sender) and a <strong>document type</strong> (invoice, contract …). You can do that
by hand – or automate it via <strong>workflows</strong>: rules that automatically tag incoming documents
based on their content. This way your archive sorts itself over time.</p>
<p>The <strong>full-text search</strong> at the top then searches not only titles and tags, but the complete
recognized text – a search for <code>invoice amount</code> or a customer name finds the matching
document in seconds. Combined with the filters (correspondent, period, document type), the
paper pile finally becomes a searchable archive.</p>
<p>A simple example of a <strong>workflow</strong>: if a new document contains the word &ldquo;electricity bill&rdquo;,
automatically assign the tag <code>Energy</code>, set the correspondent to your electricity provider and
the document type to <code>Invoice</code>. You create such rules under <strong>Administration → Workflows</strong>;
they take effect for every incoming document. At the start it pays to sort a few documents by
hand – from that you quickly see which rules repeat and can be automated. After a few weeks,
the majority of your mail lands in the right place without your involvement.</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>Scanner directly into the consume folder
  </p>
  <div class="prose-kitchen text-sm">The full convenience comes with a document scanner. Set up a network share on the server
(e.g. Samba) that points to <code>~/paperless/consume</code>, and configure your scanner to scan there.
From then on: insert sheet, press button – a few seconds later the document is recognized,
tagged and searchable in the archive. For on the go there are also community apps (e.g.
&ldquo;Paperless Mobile&rdquo;) that connect to your instance.</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>Your documents are on the internet
  </p>
  <div class="prose-kitchen text-sm">Paperless holds sensitive documents – invoices, contracts, official mail. As soon as the
instance is reachable via <code>paperless.YOUR_DOMAIN</code>, the login page is open on the net. So set
a <strong>long, unique password</strong>, and enable <strong>two-factor authentication</strong> under <strong>Settings</strong>.
Whoever wants maximum security makes Paperless reachable only via a VPN – for a purely private
archive that only you use, that&rsquo;s worth considering.</div>
</div>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong>On login, &ldquo;Forbidden (403)&rdquo; or &ldquo;CSRF verification failed&rdquo; appears.</strong> <code>PAPERLESS_URL</code> is not
set or set wrong. It must exactly match your HTTPS address (<code>https://paperless.YOUR_DOMAIN</code>,
without a trailing slash). After the correction, <code>docker compose up -d</code>.</p>
<p><strong>Files in the consume folder aren&rsquo;t processed, &ldquo;permission denied&rdquo;.</strong> Most common cause: the
folders weren&rsquo;t created beforehand (step 3), but generated by the Docker daemon on the first
start – then they belong to <code>root</code>. With <code>sudo chown -R $(id -u):$(id -g) ~/paperless/consume ~/paperless/export</code> they belong to you again. Otherwise: <code>USERMAP_UID</code>/<code>USERMAP_GID</code> don&rsquo;t
match the folder&rsquo;s owner – determine your ID with <code>id -u</code> and <code>id -g</code>, enter the values and
restart.</p>
<p><strong>Uploaded documents get stuck &ldquo;in processing&rdquo;.</strong> The background processing runs via Redis.
Check that the <code>broker</code> container is running, and look under <strong>File Tasks</strong> for the error
message of the failed task.</p>
<p><strong>Text recognition returns gibberish or recognizes nothing.</strong> Wrong OCR language. Set
<code>PAPERLESS_OCR_LANGUAGE=eng</code> (or <code>eng+deu</code> for mixed documents). Only installed languages
work.</p>
<p><strong>Office documents (Word, Excel) aren&rsquo;t accepted or end in a timeout.</strong> Gotenberg and Tika
are responsible for those. Check that both containers are running and
<code>PAPERLESS_TIKA_ENABLED=1</code> plus the two endpoint variables are set.</p>
<p><strong>During bulk import the server gets very slow, the CPU is constantly maxed out.</strong> OCR is
compute-intensive, and Paperless uses all cores by default. On small servers you can throttle
the load by limiting the number of workers or threads (<code>PAPERLESS_TASK_WORKERS</code>,
<code>PAPERLESS_THREADS_PER_WORKER</code>). Then the import takes longer, but the interface stays
usable.</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<ul>
<li>
<p><strong>The clean backup is done by the <code>document_exporter</code>.</strong> It writes all documents,
thumbnails, metadata and the database content into the <code>export</code> folder – portable and
re-importable:</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 <span class="nb">exec</span> -T webserver document_exporter ../export</span></span></code></pre></div>
</div>
<p>Then back up the <code>export</code> folder <strong>encrypted and off-site</strong> with
<a href="/en/tutorials/restic-backups/">Restic</a>. Alternatively you back up the volumes <code>media</code>,
<code>data</code> and the database directly – but the exporter is the recommended, migration-safe way.</p>
</li>
<li>
<p><strong>Use the same version when restoring.</strong> An export contains an image matching the database
schema; only import it into a Paperless instance of the <strong>same version</strong>
(<code>document_importer ../export</code>).</p>
</li>
<li>
<p><strong>Updates.</strong> Before the update, pull a backup, then raise the image tag (e.g. <code>2.20.15</code> →
next version), <code>docker compose pull</code> and <code>docker compose up -d</code>. The database migrations run
automatically at startup. Stay on the stable <code>2.x</code> series – the <code>3.0</code> beta isn&rsquo;t meant for
production yet.</p>
</li>
<li>
<p><strong>Automate the backup.</strong> Best put the <code>document_exporter</code> in a daily cron job (e.g. at
night) that afterwards backs up the <code>export</code> folder via Restic. That way you have a fresh,
restorable state every morning – without having to think about it.</p>
</li>
<li>
<p><strong>Honest about the effort:</strong> Paperless runs very low-maintenance after that. The actual
effort is filing new documents – but with a bit of set-up automation, the software
increasingly takes that off your hands too. Plan the rough tag structure once, then the
archive largely maintains itself.</p>
</li>
</ul>
]]></content:encoded></item><item><title>Installing Uptime Kuma: server monitoring behind Traefik</title><link>https://serverkueche.de/en/tutorials/uptime-kuma-monitoring/</link><pubDate>Sun, 19 Jul 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/uptime-kuma-monitoring/</guid><description>Set up Uptime Kuma behind Traefik and monitor your services: monitors, notifications and a status page – the first real app of the series.</description><content:encoded><![CDATA[<p>Traefik is up – now we hang the first real app behind it. <strong>Uptime Kuma</strong> is ideal for
that: tiny, immediately useful and without a database. It monitors your services and raises
the alarm when one fails – from now on <em>you</em> notice first that something is stuck, not your
users. Along the way you learn the pattern every further app repeats.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>By the end, <strong>Uptime Kuma 2.4</strong> runs at <code>https://status.YOUR_DOMAIN</code>, secured via Traefik
with automatic HTTPS. You&rsquo;ve set up the first monitor (which checks every minute whether a
service responds), connected a notification and optionally a public status page. For the
first time we bind a service that <strong>doesn&rsquo;t listen on port 80</strong> – an important detail for all
the following apps.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>A running <a href="/en/tutorials/traefik-reverse-proxy/">Traefik reverse proxy</a> including the
<code>proxy</code> network and a working Let&rsquo;s Encrypt resolver <code>le</code></li>
<li>A DNS record <code>status.YOUR_DOMAIN</code> that
<a href="/en/tutorials/connect-domain-to-server/">points to the server</a></li>
</ul>
<h2 id="step-by-step">Step by step</h2>
<h3 id="step-1-the-composeyaml">Step 1: The compose.yaml</h3>
<p>Own folder, own file:</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 ~/uptime-kuma <span class="o">&amp;&amp;</span> <span class="nb">cd</span> ~/uptime-kuma</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">uptime-kuma</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">louislam/uptime-kuma:2</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">kuma-data:/app/data</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">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.kuma.rule=Host(`status.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.kuma.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.kuma.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.kuma.loadbalancer.server.port=3001&#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="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">kuma-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">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>You know this from the Traefik tutorial – except for <strong>one new, decisive line</strong>:</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.services.kuma.loadbalancer.server.port=3001&#34;</span></span></span></code></pre></div>
</div>
<p>Uptime Kuma listens internally on <strong>port 3001</strong>, not on 80. This line tells Traefik which
port to forward the requests to. Without it, Traefik guesses wrong and you get a <code>Bad Gateway</code>. Remember the label – every app that doesn&rsquo;t run on port 80 needs it.</p>
<p>The rest is the familiar pattern: <strong>no <code>ports:</code></strong> (only reachable via Traefik), <code>proxy</code>
network, named volume for the data.</p>
<h3 id="step-2-start-and-first-login">Step 2: Start and first login</h3>
<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 uptime-kuma</span></span></code></pre></div>
</div>
<p>When Kuma is ready, the log ends with this line – from here the service accepts requests
(<code>Ctrl+C</code> only ends the following):</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">[SERVER] INFO: Welcome to Uptime Kuma
</span></span><span class="line"><span class="cl">[SERVER] INFO: Uptime Kuma Version: 2.4.0
</span></span><span class="line"><span class="cl">[SETUP-DATABASE] INFO: Listening on:
</span></span><span class="line"><span class="cl">[SETUP-DATABASE] INFO: -  http://localhost:3001</span></span></code></pre></div>
</div>
<p>On the first start, Kuma creates its database in the volume – that takes a few seconds. Then
open <code>https://status.YOUR_DOMAIN</code> in the browser. Uptime Kuma 2.x first asks for the
<strong>database</strong> – for a setup like ours, <strong>SQLite</strong> is the right, simplest choice (select it,
click <strong>Next</strong>). Right after that you create the <strong>admin account</strong> (username + strong
password).</p>
<p><figure class="my-6"><img src="/en/tutorials/uptime-kuma-monitoring/kuma-setup_hu_1d0c56e67101b8af.webp" srcset="/en/tutorials/uptime-kuma-monitoring/kuma-setup_hu_8fae09095cd4616e.webp 480w, /en/tutorials/uptime-kuma-monitoring/kuma-setup_hu_1d0c56e67101b8af.webp 768w, /en/tutorials/uptime-kuma-monitoring/kuma-setup_hu_6992115843b8c5c4.webp 1200w, /en/tutorials/uptime-kuma-monitoring/kuma-setup_hu_7dac57d86ca48603.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/uptime-kuma-monitoring/kuma-setup_hu_2fd60a2da8963f4b.webp"
    alt="Uptime Kuma&rsquo;s initial setup: choose the language and create the admin account" title="Initial setup: choose the language and create the admin account"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Initial setup: choose the language and create the admin account</figcaption></figure></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>Create the admin account immediately
  </p>
  <div class="prose-kitchen text-sm">As long as no admin account exists, <strong>anyone</strong> who opens the page can create one. Do this
right after the first start – not &ldquo;later&rdquo;.</div>
</div>
<h3 id="step-3-create-the-first-monitor">Step 3: Create the first monitor</h3>
<p>A <strong>monitor</strong> is a recurring check. Click <strong>Add New Monitor</strong> and create one for Traefik
itself:</p>
<ul>
<li><strong>Monitor type:</strong> <code>HTTP(s)</code></li>
<li><strong>Friendly name:</strong> <code>Traefik Dashboard</code></li>
<li><strong>URL:</strong> <code>https://traefik.YOUR_DOMAIN</code></li>
<li><strong>Heartbeat interval:</strong> <code>60</code> seconds</li>
<li><strong>Authentication:</strong> the Traefik dashboard is protected by basic auth (step 7 of the
<a href="/en/tutorials/traefik-reverse-proxy/">Traefik tutorial</a>) – so choose <strong>HTTP Basic Auth</strong> as
the authentication method below and enter the user and password. Without credentials, Kuma
only gets <code>401</code> and reports the monitor as <strong>Down</strong>.</li>
</ul>
<p>Save – after a few seconds the monitor is <strong>Up</strong> (green) and shows the response time.</p>
<p><figure class="my-6"><img src="/en/tutorials/uptime-kuma-monitoring/kuma-dashboard_hu_ecad2a835a6b6005.webp" srcset="/en/tutorials/uptime-kuma-monitoring/kuma-dashboard_hu_b9fd222e62394661.webp 480w, /en/tutorials/uptime-kuma-monitoring/kuma-dashboard_hu_ecad2a835a6b6005.webp 768w, /en/tutorials/uptime-kuma-monitoring/kuma-dashboard_hu_84686854fd419462.webp 1200w, /en/tutorials/uptime-kuma-monitoring/kuma-dashboard_hu_25a9861864b2b7ef.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/uptime-kuma-monitoring/kuma-dashboard_hu_efc1a897fd354ecc.webp"
    alt="The Uptime Kuma dashboard with two running monitors – both green with 100% availability and &ldquo;200 – OK&rdquo;" title="The dashboard with two running monitors"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The dashboard with two running monitors</figcaption></figure></p>
<p>Besides the simple <code>HTTP(s)</code> check, it pays to choose the matching <strong>monitor type</strong>:</p>
<ul>
<li><strong>HTTP(s) – Keyword:</strong> additionally checks whether a certain word appears in the response
text. This detects &ldquo;the server responds, but shows an error page&rdquo;.</li>
<li><strong>TCP port:</strong> for services without a web interface (e.g. a database, an SSH port). Only
checks whether the port is open.</li>
<li><strong>Ping:</strong> the simplest reachability test via ICMP.</li>
<li><strong>Docker container:</strong> checks the container status directly via the Docker socket – handy
for internal services without their own domain.</li>
<li><strong>Push:</strong> here <em>the monitored service</em> calls Kuma regularly. Ideal for cron jobs and
backups: if the script doesn&rsquo;t report in time, Kuma raises the alarm (&ldquo;dead man&rsquo;s switch&rdquo;).</li>
</ul>
<p>Create the matching monitor for every important service.</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">Check <strong>public services via their real domain</strong> (<code>https://…</code>), not via <code>localhost</code>. This way
you simultaneously test that Traefik and the certificate work from outside – not just that
the container is running.</div>
</div>
<p>For the <code>HTTP(s)</code> monitor, the option <strong>&ldquo;certificate expiry notification&rdquo;</strong> is also worth it:
Kuma then warns in good time before a TLS certificate expires. For services behind Traefik,
Let&rsquo;s Encrypt renews automatically – but this very automation occasionally fails silently (a
DNS record is changed, port 80 accidentally closed). The monitor is your safety net and
reports in while there are still days to fix it, instead of visitors suddenly facing a
certificate warning.</p>
<h3 id="step-4-set-up-notifications-email-and-telegram">Step 4: Set up notifications (email and Telegram)</h3>
<p>A monitor without an alarm is just a pretty chart. Under <strong>Settings → Notifications → Setup
Notification</strong> you create a channel. Uptime Kuma supports over 90 – we set up the two most
common completely: <strong>email</strong> for the classic message and <strong>Telegram</strong> for push straight to
the phone.</p>
<h4 id="email-smtp">Email (SMTP)</h4>
<p>Choose &ldquo;Email (SMTP)&rdquo; as the <strong>notification service</strong> and enter your mail provider&rsquo;s data:</p>
<ul>
<li><strong>Hostname / port:</strong> e.g. <code>smtp.YOUR_PROVIDER.com</code> and <code>587</code></li>
<li><strong>Security:</strong> <code>STARTTLS</code> (port 587) or <code>TLS/SSL</code> (port 465)</li>
<li><strong>Username / password:</strong> your SMTP credentials</li>
<li><strong>From / to address:</strong> which address the warning comes from and which it&rsquo;s sent to</li>
</ul>
<p><figure class="my-6"><img src="/en/tutorials/uptime-kuma-monitoring/kuma-email_hu_7f1e9c91a901332a.webp" srcset="/en/tutorials/uptime-kuma-monitoring/kuma-email_hu_e7e333a89de51892.webp 480w, /en/tutorials/uptime-kuma-monitoring/kuma-email_hu_7f1e9c91a901332a.webp 768w, /en/tutorials/uptime-kuma-monitoring/kuma-email_hu_a1d62b8025eec0b7.webp 1200w, /en/tutorials/uptime-kuma-monitoring/kuma-email_hu_6c1a492676432907.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/uptime-kuma-monitoring/kuma-email_hu_dc87d46846cc61b7.webp"
    alt="The email notification (SMTP) in Uptime Kuma: hostname, port, security and from address" title="Setting up the email notification (SMTP)"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Setting up the email notification (SMTP)</figcaption></figure></p>
<p>Click <strong>Test</strong> – within a few seconds a test message lands in the mailbox. Only when it
really arrives are the credentials and port correct. Don&rsquo;t forget to save.</p>
<h4 id="telegram">Telegram</h4>
<p>Telegram is ideal for instant push alerts to the phone – without your own mail server. You
need two pieces of information:</p>
<ol>
<li><strong>Bot token:</strong> message <a href="https://t.me/BotFather">@BotFather</a> in Telegram, send <code>/newbot</code>,
assign a name – BotFather replies with the <strong>token</strong>.</li>
<li><strong>Chat ID:</strong> send your new bot any message, then open
<code>https://api.telegram.org/bot&lt;YOUR_TOKEN&gt;/getUpdates</code> in the browser and read the
<code>chat.id</code> from the response.</li>
</ol>
<p>Enter the token and chat ID into the Telegram notification (Uptime Kuma links both helpers
directly in the dialog) and click <strong>Test</strong> – the message should appear in the chat
immediately.</p>
<p><figure class="my-6"><img src="/en/tutorials/uptime-kuma-monitoring/kuma-telegram_hu_1feb4facbe88ee6e.webp" srcset="/en/tutorials/uptime-kuma-monitoring/kuma-telegram_hu_c4bb1dc005fd5de9.webp 480w, /en/tutorials/uptime-kuma-monitoring/kuma-telegram_hu_1feb4facbe88ee6e.webp 768w, /en/tutorials/uptime-kuma-monitoring/kuma-telegram_hu_f338e7e25e86656.webp 1200w, /en/tutorials/uptime-kuma-monitoring/kuma-telegram_hu_fa7e43c2efbd8e16.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/uptime-kuma-monitoring/kuma-telegram_hu_2c05a870636172bd.webp"
    alt="The Telegram notification in Uptime Kuma: bot token and chat ID, with a direct link to the BotFather" title="Telegram notification: bot token and chat ID"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Telegram notification: bot token and chat ID</figcaption></figure></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>Otherwise the alarm stays silent
  </p>
  <div class="prose-kitchen text-sm">A configured notification does <strong>not</strong> take effect automatically. Enable it in each monitor
(checkbox in the monitor form) or, in the notification dialog, turn on &ldquo;Enabled by default&rdquo;
and &ldquo;Apply on all existing monitors&rdquo;.</div>
</div>
<p>If you want push messages entirely under your own control (without a Telegram server),
<strong>ntfy</strong> comes later – self-hosted, with its own recipe in the series.</p>
<h3 id="step-5-a-public-status-page-optional">Step 5: A public status page (optional)</h3>
<p>Kuma can publish a <strong>status page</strong> where visitors see whether your services are running.
Here&rsquo;s how:</p>
<ol>
<li><strong>Status Pages → New Status Page</strong>, then assign a <strong>name</strong> (e.g. &ldquo;Serverküche Status&rdquo;) and
a <strong>slug</strong> (the URL, e.g. <code>serverkueche</code>) and click <strong>Next</strong>.</li>
<li>In the editor, <strong>Add Group</strong> (e.g. &ldquo;Services&rdquo;), and below it, add the desired <strong>monitors</strong>
via the selection field.</li>
<li><strong>Save</strong> at the top right – done. The page is then publicly reachable at
<code>https://status.YOUR_DOMAIN/status/serverkueche</code>.</li>
</ol>
<p>Only include what really everyone may see – better leave internal services out.</p>
<p><figure class="my-6"><img src="/en/tutorials/uptime-kuma-monitoring/kuma-statusseite_hu_c4b45400765e134.webp" srcset="/en/tutorials/uptime-kuma-monitoring/kuma-statusseite_hu_34f9e0e0768594d.webp 480w, /en/tutorials/uptime-kuma-monitoring/kuma-statusseite_hu_c4b45400765e134.webp 768w, /en/tutorials/uptime-kuma-monitoring/kuma-statusseite_hu_b036ea3223a2da7.webp 1200w, /en/tutorials/uptime-kuma-monitoring/kuma-statusseite_hu_3c0dc17506dd64f2.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/uptime-kuma-monitoring/kuma-statusseite_hu_652cbd6c4d4b33a1.webp"
    alt="The public status page &ldquo;Serverküche Status&rdquo;: &ldquo;All Systems Operational&rdquo; and both services with 100% availability" title="The finished public status page"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The finished public status page</figcaption></figure></p>
<h3 id="step-6-avoid-false-alarms--fine-tune-the-alarm-behavior">Step 6: Avoid false alarms – fine-tune the alarm behavior</h3>
<p>A monitor that raises the alarm at every brief network hiccup is quickly ignored – and then
you miss the real outage. In the monitor form you set the behavior appropriately:</p>
<ul>
<li><strong>Retries:</strong> the service only counts as &ldquo;Down&rdquo; after <em>n</em> failed checks. <code>2</code>–<code>3</code> filters out
individual dropouts without obscuring real outages for long.</li>
<li><strong>Heartbeat interval on failure:</strong> Kuma may check more often in the error case (e.g. every
20 seconds) to detect recovery quickly.</li>
<li><strong>Resend notification:</strong> Kuma can remind you every <em>x</em> minutes as long as a service is down
– useful so an outage at night doesn&rsquo;t get lost in a single mail.</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>Plan maintenance windows
  </p>
  <div class="prose-kitchen text-sm">If you plan an update with downtime, create a <strong>maintenance window</strong> under <strong>Maintenance</strong>.
Kuma then pauses the alarms for the affected monitors – so you (and the status page) don&rsquo;t
get false alarms while you&rsquo;re at work.</div>
</div>
<h3 id="step-7-monitor-cron-jobs-with-a-push-monitor">Step 7: Monitor cron jobs with a push monitor</h3>
<p>Classic monitors check from outside whether a service <em>responds</em>. For things that <strong>run
silently in the background</strong> – a nightly backup, a sync script, a cron job – the <strong>push
monitor</strong> flips the principle: not Kuma asks, but <em>your script reports in</em>. If the report
fails to come, Kuma raises the alarm – the classic &ldquo;dead man&rsquo;s switch&rdquo;.</p>
<p>Create a monitor of type <strong>Push</strong>. Kuma then shows you a unique <strong>push URL</strong>:</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">https://status.YOUR_DOMAIN/api/push/YOUR_TOKEN?status=up&amp;msg=OK&amp;ping=</span></span></code></pre></div>
</div>
<p>You call this URL at the end of your script – for example after a successfully completed
backup:</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="c1"># ... your backup command ...</span>
</span></span><span class="line"><span class="cl">curl -fsS <span class="s2">&#34;https://status.YOUR_DOMAIN/api/push/YOUR_TOKEN?status=up&amp;msg=Backup+OK&#34;</span> &gt; /dev/null</span></span></code></pre></div>
</div>
<p>Set the <strong>check interval</strong> in Kuma a bit more generously than your cron cadence (if the
backup runs hourly, give Kuma e.g. 90 minutes of tolerance). If no <code>curl</code> comes in that time,
the monitor goes to <strong>Down</strong> and you&rsquo;re notified – so you learn about a backup that did <em>not</em>
run, not only when you urgently need it.</p>
<h3 id="step-8-secure-the-admin-login-with-2fa">Step 8: Secure the admin login with 2FA</h3>
<p>Your Kuma login protects access to all monitors, the stored notification credentials and the
status-page configuration – and it&rsquo;s publicly on the net. So enable <strong>two-factor
authentication</strong>: under <strong>Settings → Security → Two-Factor Authentication</strong>. Kuma shows a QR
code you scan with an authenticator app (e.g. Aegis or 2FAS); to activate, you enter the
generated code once.</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>Secure the recovery
  </p>
  <div class="prose-kitchen text-sm">Keep the TOTP secret or a second authenticator in a safe place (password manager). If you lose
your phone <strong>and</strong> have no copy, you can otherwise only get back in via the database in the
<code>kuma-data</code> volume.</div>
</div>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong><code>Bad Gateway</code> (502) when opening <code>status.YOUR_DOMAIN</code>.</strong> Almost always the port label
<code>traefik.http.services.kuma.loadbalancer.server.port=3001</code> is missing or has a wrong port.
Traefik then reaches the container but knocks on the wrong port. Check the label and run
<code>docker compose up -d</code> again.</p>
<p><strong><code>404 page not found</code> instead of Kuma.</strong> As with every app behind Traefik:
<code>traefik.enable=true</code> set? Container on the <code>proxy</code> network? Is the domain in the <code>Host(...)</code>
rule correct and does the DNS record <code>status.YOUR_DOMAIN</code> point to the server? The Traefik
dashboard shows under &ldquo;HTTP Routers&rdquo; whether <code>kuma</code> is registered.</p>
<p><strong>The interface loads, but the live update stutters / breaks off.</strong> Kuma uses WebSockets.
Traefik forwards those correctly by default – if the problem still occurs, it&rsquo;s usually an
upstream CDN/proxy (e.g. Cloudflare in &ldquo;proxy&rdquo; mode) that blocks WebSockets. For direct
operation behind Traefik, no extra configuration is needed.</p>
<p><strong>After a re-setup all monitors are gone.</strong> The <code>kuma-data</code> volume was deleted (e.g. by
<code>docker compose down -v</code>). All configuration and history lives solely in this volume – that&rsquo;s
why it&rsquo;s at the top of the next section.</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<ul>
<li><strong>Back up:</strong> the complete heart of Kuma is the <code>kuma-data</code> volume (a SQLite database). Back
it up regularly – if it&rsquo;s gone, all monitors and the history are gone. We build the off-site
backup for it in the <a href="/en/tutorials/restic-backups/">Restic tutorial</a>.</li>
<li><strong>Updates:</strong> the tag <code>:2</code> stays on the 2.x series and brings bug fixes with
<code>docker compose pull &amp;&amp; docker compose up -d</code>. Before a jump to a new major version (e.g.
later <code>:3</code>), read the release notes and back up the volume first.</li>
<li><strong>Coming from 1.x?</strong> The switch to <code>:2</code> <strong>migrates the SQLite database automatically on the
first start</strong> – that can take a moment, and going back to <code>:1</code> is not intended afterwards.
So back up the <code>kuma-data</code> volume <strong>beforehand</strong>, then you&rsquo;re on the safe side. New
installations (like above) aren&rsquo;t affected.</li>
<li><strong>Honest limitation:</strong> a monitor that runs <strong>on the same server</strong> as the monitored services
can&rsquo;t warn you when the whole server fails – then Kuma is offline too. For that case, add an
<strong>external</strong> watcher. Two cheap ways: a <strong>second Uptime Kuma</strong> on a small server (or at
home) that only monitors this instance via HTTP – or a <strong>free external ping service</strong> that
pings your public status page. This way you also get a notice when the whole host is gone –
the only case a local monitor inherently can&rsquo;t cover.</li>
</ul>
<p>With that you&rsquo;ve internalized the app pattern and monitor everything you hang behind Traefik
from now on. What every further app requires are
<a href="/en/tutorials/restic-backups/">encrypted off-site backups with Restic</a> – so your data
survives a server crash.</p>
]]></content:encoded></item><item><title>Self-hosting Nextcloud: your own cloud behind Traefik</title><link>https://serverkueche.de/en/tutorials/self-host-nextcloud/</link><pubDate>Sun, 19 Jul 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/self-host-nextcloud/</guid><description>Set up Nextcloud with Docker behind Traefik: your own cloud for files, calendar and contacts – with MariaDB, Redis and automatic HTTPS.</description><content:encoded><![CDATA[<p>Your files, your calendar, your contacts – but on your server instead of at a cloud
corporation. <strong>Nextcloud</strong> is the flagship of self-hosting: a full-featured cloud you run
yourself. In this recipe we set it up cleanly behind Traefik, with its own database,
caching and automatic HTTPS.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>By the end, <strong>Nextcloud 34</strong> runs behind your Traefik proxy, reachable at
<code>https://cloud.YOUR_DOMAIN</code> with a valid Let&rsquo;s Encrypt certificate. Nextcloud is your
private cloud: sync files (like Dropbox), calendar and contacts across all devices, photos,
notes, office documents. You connect the official <strong>desktop sync client</strong> and the <strong>phone
apps</strong> with your server – the data lives exclusively with you.</p>
<p>We deliberately use the <strong>classic <code>nextcloud</code> image</strong> (not &ldquo;Nextcloud All-in-One&rdquo;) plus
<strong>MariaDB</strong> as the database and <strong>Redis</strong> for caching and file locking. This combination is
stable, well documented and fits exactly into the Traefik pattern from the Serverküche.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-sky-300 bg-sky-50 dark:border-sky-800 dark:bg-sky-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">ℹ️</span>Why not Nextcloud AIO?
  </p>
  <div class="prose-kitchen text-sm">Nextcloud also offers an &ldquo;All-in-One&rdquo; package (AIO). But it brings its <strong>own TLS and own
ports</strong> and wants to handle the reverse-proxy part itself – that clashes with an existing
Traefik. For our setup, the classic image is the right, controllable choice.</div>
</div>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<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 the tutorial
<a href="/en/tutorials/traefik-reverse-proxy/">reverse proxy with Traefik</a>.</li>
<li>A subdomain <code>cloud.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>
<li>A working <strong>backup</strong>. A cloud is the place where data loss hurts most – first set up
<a href="/en/tutorials/restic-backups/">backups with Restic</a>.</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>How big does the server need to be?
  </p>
  <div class="prose-kitchen text-sm">Nextcloud is more frugal than its reputation: for private use or a small family (1–5 users),
<strong>2 vCPU and 4 GB RAM</strong> are quite enough – the tested VPS 1000 fits. The bottleneck is
memory: with too little RAM the server starts swapping and gets sluggish. Redis (we build it
in shortly) relieves this noticeably. For Nextcloud Office/Collabora or many parallel users
you should rather plan for 8 GB.</div>
</div>
<p>How big your server should be for your user count is estimated by the
<a href="/en/server-calculator/">server calculator</a> in a few clicks.</p>
<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 1000 G12</p>
      <p class="mt-1 text-sm text-slate-600 dark:text-slate-300">4 vCores · 8 GB RAM · 256 GB NVMe</p>
      <p class="mt-1 text-sm font-semibold text-paprika-700 dark:text-paprika-400">from €10.36/month</p>
      <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">For a private Nextcloud with a few users, the VPS 1000 is quite enough.</p>
    </div>
    <a href="https://www.netcup.com/en/server/vps/vps-1000-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="step-by-step">Step by step</h2>
<h3 id="step-1-create-the-dns-record">Step 1: Create the DNS record</h3>
<p>At your DNS provider, create an entry <code>cloud.YOUR_DOMAIN</code> that points to your server IP
(A record for IPv4, AAAA for IPv6). Check that it resolves:</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">dig +short cloud.YOUR_DOMAIN</span></span></code></pre></div>
</div>
<p>You should see your server IP. Only once the record is set can Traefik fetch the HTTPS
certificate later.</p>
<h3 id="step-2-determine-the-proxy-subnet">Step 2: Determine the proxy subnet</h3>
<p>Nextcloud sits behind Traefik. So that Nextcloud&rsquo;s brute-force protection recognizes the
<strong>real</strong> visitor IP (and doesn&rsquo;t ban the internal Traefik IP), we have to enter Traefik as a
&ldquo;trusted proxy&rdquo;. For that you need the subnet of your <code>proxy</code> network:</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 inspect proxy -f <span class="s1">&#39;{{(index .IPAM.Config 0).Subnet}}&#39;</span></span></span></code></pre></div>
</div>
<p>You get something like <code>172.18.0.0/16</code>. <strong>Remember this value</strong> – it goes into the
configuration as <code>TRUSTED_PROXIES</code> shortly.</p>
<h3 id="step-3-create-the-composeyaml">Step 3: Create the compose.yaml</h3>
<p>Create a folder and change into 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">mkdir -p ~/nextcloud <span class="o">&amp;&amp;</span> <span class="nb">cd</span> ~/nextcloud</span></span></code></pre></div>
</div>
<p>Create the <code>compose.yaml</code>. Replace <code>cloud.YOUR_DOMAIN</code>, all passwords and the
<code>TRUSTED_PROXIES</code> subnet from step 2:</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">nc-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">mariadb:11.4</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">nc-db</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="l">transaction-isolation=READ-COMMITTED --log-bin=binlog --binlog-format=ROW</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">MARIADB_ROOT_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">A_STRONG_ROOT_PASSWORD</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">MARIADB_DATABASE</span><span class="p">:</span><span class="w"> </span><span class="l">nextcloud</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">MARIADB_USER</span><span class="p">:</span><span class="w"> </span><span class="l">nextcloud</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">MARIADB_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">A_STRONG_DB_PASSWORD</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">nc_db:/var/lib/mysql</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&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;healthcheck.sh&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;--connect&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;--innodb_initialized&#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">6</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">default]</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">nc-redis</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">redis:8-alpine</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">nc-redis</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="l">redis-server --requirepass A_STRONG_REDIS_PASSWORD</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">default]</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">nc-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">nextcloud:34-apache</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">nc-app</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">nc-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></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">nc-redis</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_started</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">MYSQL_HOST</span><span class="p">:</span><span class="w"> </span><span class="l">nc-db</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">MYSQL_DATABASE</span><span class="p">:</span><span class="w"> </span><span class="l">nextcloud</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">MYSQL_USER</span><span class="p">:</span><span class="w"> </span><span class="l">nextcloud</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">MYSQL_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">A_STRONG_DB_PASSWORD</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">REDIS_HOST</span><span class="p">:</span><span class="w"> </span><span class="l">nc-redis</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">REDIS_HOST_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">A_STRONG_REDIS_PASSWORD</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">NEXTCLOUD_TRUSTED_DOMAINS</span><span class="p">:</span><span class="w"> </span><span class="l">cloud.YOUR_DOMAIN</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">OVERWRITEPROTOCOL</span><span class="p">:</span><span class="w"> </span><span class="l">https</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">OVERWRITECLIURL</span><span class="p">:</span><span class="w"> </span><span class="l">https://cloud.YOUR_DOMAIN</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">TRUSTED_PROXIES</span><span class="p">:</span><span class="w"> </span><span class="m">172.18.0.0</span><span class="l">/16</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PHP_MEMORY_LIMIT</span><span class="p">:</span><span class="w"> </span><span class="l">1024M</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PHP_UPLOAD_LIMIT</span><span class="p">:</span><span class="w"> </span><span class="l">10G</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">nc_html:/var/www/html</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.nc.rule=Host(`cloud.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.nc.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.nc.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.nc.middlewares=nc-dav&#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.nc.loadbalancer.server.port=80&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># .well-known redirect for CalDAV/CardDAV (see below)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.middlewares.nc-dav.redirectregex.regex=https://(.*)/.well-known/(?:card|cal)dav&#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.nc-dav.redirectregex.replacement=https://cloud.YOUR_DOMAIN/remote.php/dav/&#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.nc-dav.redirectregex.permanent=true&#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">default, 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">nc_html</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">nc_db</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">default</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>Three containers:</strong> <code>nc-app</code> (Nextcloud), <code>nc-db</code> (MariaDB), <code>nc-redis</code>. Only <code>nc-app</code> is
on the <strong><code>proxy</code> network</strong> (for Traefik) <em>and</em> on the internal <code>default</code> network. Database
and Redis stay exclusively internal – they have <strong>no <code>ports:</code></strong> and are not reachable from
outside.</li>
<li><strong>Two named volumes:</strong> <code>nc_html</code> (program code, <code>config.php</code>, user data) and <code>nc_db</code> (the
MariaDB data). The <code>nc_db</code> volume is mandatory: without the entry, the database lands in an
<strong>anonymous</strong> volume, and a <code>docker compose down</code> followed by <code>up</code> would <strong>lose it
irrecoverably</strong>.</li>
<li><strong><code>healthcheck</code> + <code>depends_on: service_healthy</code>:</strong> Nextcloud starts faster than the
database initializes. Without this healthcheck the initial install fails with a &ldquo;Connection
refused&rdquo;. This way the app container waits until MariaDB is really ready.</li>
<li><strong><code>NEXTCLOUD_TRUSTED_DOMAINS</code>:</strong> without your own domain here, Nextcloud greets you with
&ldquo;Access through untrusted domain&rdquo;.</li>
<li><strong><code>OVERWRITEPROTOCOL: https</code></strong> tells Nextcloud it runs behind HTTPS – otherwise it builds
internal links as <code>http://</code> and logins/redirects break.</li>
<li><strong><code>TRUSTED_PROXIES</code>:</strong> the subnet from step 2. This lets Nextcloud recognize the real client
IP; otherwise the brute-force protection bans the Traefik IP.</li>
<li><strong><code>PHP_MEMORY_LIMIT</code> / <code>PHP_UPLOAD_LIMIT</code>:</strong> the defaults (512 MB) are too small for large
uploads. Set generously here.</li>
<li><strong><code>loadbalancer.server.port=80</code>:</strong> the Apache image listens on port 80 in the container.</li>
<li><strong>The <code>nc-dav</code> middleware</strong> solves a classic Nextcloud problem – more on that shortly.</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>CalDAV/CardDAV: the .well-known redirect
  </p>
  <div class="prose-kitchen text-sm">Nextcloud wants to send calendar/contact clients via <code>/.well-known/caldav</code> and
<code>/.well-known/carddav</code> to <code>/remote.php/dav/</code>. Behind a reverse proxy, Nextcloud&rsquo;s own
redirect doesn&rsquo;t work reliably – this later leads to a warning in the security check and to
problems with calendar setup. The <code>nc-dav</code> middleware above does the redirect directly in
Traefik. Important: the pattern must match the <strong>full <code>https://…</code> URL</strong> – <code>redirectregex</code>
always checks the complete URL, not just the path. A pure path pattern like <code>^/.well-known/…</code>
therefore never matches.</div>
</div>
<h3 id="step-4-start-and-run-through-the-initial-setup">Step 4: Start and run through the initial setup</h3>
<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>On the first start, MariaDB initializes the database and Nextcloud unpacks itself – that
takes a minute or two. Follow it in the log:</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 logs -f nc-app</span></span></code></pre></div>
</div>
<p>On the first run the image unpacks Nextcloud into the volume; you see lines like
<code>Initializing nextcloud 34.0.1.2 ...</code> and finally <code>Initializing finished</code>, followed by the
Apache start. If <code>restarting</code> appears repeatedly instead, take a look at the &ldquo;When things go
wrong&rdquo; section. With <code>Ctrl+C</code> you leave the log view again (the container keeps running).</p>
<p>Then open <code>https://cloud.YOUR_DOMAIN</code>. Traefik fetches the certificate on the first access
(can take a few seconds). Because we already configured the database and Redis via
environment variables, Nextcloud detects that automatically and only asks for an
<strong>administration account</strong>:</p>
<p><figure class="my-6"><img src="/en/tutorials/self-host-nextcloud/nextcloud-setup_hu_c50c35957723d189.webp" srcset="/en/tutorials/self-host-nextcloud/nextcloud-setup_hu_41179e40d0ac8fe6.webp 480w, /en/tutorials/self-host-nextcloud/nextcloud-setup_hu_c50c35957723d189.webp 768w, /en/tutorials/self-host-nextcloud/nextcloud-setup_hu_64a4383ace5c261b.webp 1200w, /en/tutorials/self-host-nextcloud/nextcloud-setup_hu_ab4c44f37cb05b2f.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/self-host-nextcloud/nextcloud-setup_hu_a6187c5c382c3ab8.webp"
    alt="The Nextcloud initial setup detects the autoconfig and only asks for the name and password of the administration account" title="Initial setup: the database is preconfigured, you only create the admin account"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Initial setup: the database is preconfigured, you only create the admin account</figcaption></figure></p>
<p>Set an admin name and a strong password and click <strong>Install</strong>. Nextcloud sets up the instance
– after that you land on the login page:</p>
<p><figure class="my-6"><img src="/en/tutorials/self-host-nextcloud/nextcloud-login_hu_ab5d9fc01a9c0266.webp" srcset="/en/tutorials/self-host-nextcloud/nextcloud-login_hu_5703a02035c4a1d2.webp 480w, /en/tutorials/self-host-nextcloud/nextcloud-login_hu_ab5d9fc01a9c0266.webp 768w, /en/tutorials/self-host-nextcloud/nextcloud-login_hu_1328b37d05a830c5.webp 1200w, /en/tutorials/self-host-nextcloud/nextcloud-login_hu_82532d9c9d89c6e1.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/self-host-nextcloud/nextcloud-login_hu_ff7e27fbd50c96bb.webp"
    alt="The Nextcloud login page under your own HTTPS domain" title="The login page at cloud.YOUR_DOMAIN – with valid HTTPS from Traefik"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The login page at cloud.YOUR_DOMAIN – with valid HTTPS from Traefik</figcaption></figure></p>
<p>After the login, the <strong>dashboard</strong> greets you, and under <strong>Files</strong> you find your cloud with a
few example files:</p>
<p><figure class="my-6"><img src="/en/tutorials/self-host-nextcloud/nextcloud-dashboard_hu_db3401a59a72d3bd.webp" srcset="/en/tutorials/self-host-nextcloud/nextcloud-dashboard_hu_c4f9d6d8f49e635d.webp 480w, /en/tutorials/self-host-nextcloud/nextcloud-dashboard_hu_db3401a59a72d3bd.webp 768w, /en/tutorials/self-host-nextcloud/nextcloud-dashboard_hu_9086315a95ddf210.webp 1200w, /en/tutorials/self-host-nextcloud/nextcloud-dashboard_hu_860e5d90f28daa69.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/self-host-nextcloud/nextcloud-dashboard_hu_f4c7dc282828c0f.webp"
    alt="The Nextcloud dashboard with the &ldquo;Recommended files&rdquo; widget" title="The dashboard after the first login"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The dashboard after the first login</figcaption></figure></p>
<p><figure class="my-6"><img src="/en/tutorials/self-host-nextcloud/nextcloud-dateien_hu_c6651a5b12cc3b8d.webp" srcset="/en/tutorials/self-host-nextcloud/nextcloud-dateien_hu_c2ff2f12d453b9c0.webp 480w, /en/tutorials/self-host-nextcloud/nextcloud-dateien_hu_c6651a5b12cc3b8d.webp 768w, /en/tutorials/self-host-nextcloud/nextcloud-dateien_hu_b5d67ad5d77fc9b.webp 1200w, /en/tutorials/self-host-nextcloud/nextcloud-dateien_hu_a5c245e619d92d9f.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/self-host-nextcloud/nextcloud-dateien_hu_f58abd3991247124.webp"
    alt="The Files interface of Nextcloud with folders and example files" title="The Files app – the heart of your own cloud"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The Files app – the heart of your own cloud</figcaption></figure></p>
<h3 id="step-5-set-up-background-jobs-cron">Step 5: Set up background jobs (cron)</h3>
<p>Nextcloud has to perform tasks regularly (cleanup, notifications, thumbnails). By default
this happens via &ldquo;AJAX&rdquo; on every page load – which is unreliable. The recommended way is a
<strong>cron sidecar</strong>: a second container with the same image that only runs <code>cron.php</code>. Add a
service to the <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="w">  </span><span class="nt">nc-cron</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">nextcloud:34-apache</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">nc-cron</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">entrypoint</span><span class="p">:</span><span class="w"> </span><span class="l">/cron.sh</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">nc-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></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">nc_html:/var/www/html</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">default]</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>The sidecar shares the <code>nc_html</code> volume with the app and runs the jobs every five minutes.
Apply the change and set the mode in Nextcloud to &ldquo;Cron&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">docker compose up -d
</span></span><span class="line"><span class="cl">docker <span class="nb">exec</span> -u www-data nc-app php occ background:cron</span></span></code></pre></div>
</div>
<p>Under <strong>Administration → Basic settings</strong>, &ldquo;Cron&rdquo; should now be active.</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>occ – the command-line tool
  </p>
  <div class="prose-kitchen text-sm"><p><code>occ</code> is Nextcloud&rsquo;s admin tool. You always call it as the user <code>www-data</code> in the app
container: <code>docker exec -u www-data nc-app php occ &lt;command&gt;</code>. Two useful cleanup commands
right after installation:</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 <span class="nb">exec</span> -u www-data nc-app php occ db:add-missing-indices
</span></span><span class="line"><span class="cl">docker <span class="nb">exec</span> -u www-data nc-app php occ config:system:set default_phone_region --value<span class="o">=</span>DE</span></span></code></pre></div>
</div></div>
</div>
<h3 id="step-6-connect-the-clients">Step 6: Connect the clients</h3>
<p>Now the real benefit. There are three ways to use your cloud:</p>
<ol>
<li><strong>Desktop sync client</strong> (Windows/macOS/Linux): install the &ldquo;Nextcloud Desktop&rdquo; client,
enter <code>https://cloud.YOUR_DOMAIN</code> as the server address, log in and choose a local folder.
Files are synced like with Dropbox. With <strong>virtual files</strong> (Windows/macOS) they only take
up space on the disk when you open them – handy when your cloud is larger than the local
disk.</li>
<li><strong>Phone app</strong> (Android/iOS): the official Nextcloud app, same server address. Ideal for
automatic <strong>photo upload</strong> from the smartphone.</li>
<li><strong>Calendar &amp; contacts:</strong> in your device&rsquo;s system settings, create a CalDAV/CardDAV account
with the address <code>https://cloud.YOUR_DOMAIN</code> – thanks to the <code>.well-known</code> middleware from
step 3, automatic detection works.</li>
</ol>
<p>Features like calendar, contacts or mail are separate <strong>apps</strong> you install with one click
under <strong>Administration → Apps</strong> – so Nextcloud is extensible without you touching anything on
the server.</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>Don&#39;t work with the admin account
  </p>
  <div class="prose-kitchen text-sm">The administration account from step 4 is meant for <strong>management</strong>, not for everyday use.
Create a normal user account under <strong>Administration → Accounts</strong> and sync your files through
it. That way your powerful admin account isn&rsquo;t permanently logged in on all devices – a
simple but effective security gain. For family or team, create further accounts here; each
gets its own encrypted area.</div>
</div>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong>&ldquo;Access through untrusted domain&rdquo; instead of the login page.</strong> Your domain isn&rsquo;t in
<code>trusted_domains</code>. Check <code>NEXTCLOUD_TRUSTED_DOMAINS</code> in the Compose file. To set it
afterwards, use occ: <code>docker exec -u www-data nc-app php occ config:system:set trusted_domains 1 --value=cloud.YOUR_DOMAIN</code>.</p>
<p><strong>The security check reports &ldquo;Your web server is not set up properly to resolve
.well-known/caldav&rdquo;.</strong> The CalDAV/CardDAV redirect isn&rsquo;t working. Check the <code>nc-dav</code>
middleware labels (step 3) and that the router includes them via
<code>...routers.nc.middlewares=nc-dav</code>. The regex must match the full <code>https://…</code> URL –
<code>redirectregex</code> checks the complete URL, not just the path.</p>
<p><strong>Warning &ldquo;The &lsquo;Strict-Transport-Security&rsquo; HTTP header is not set&rdquo;.</strong> The HSTS header is
missing. Set it as a Traefik middleware and attach it to the router:
<code>traefik.http.middlewares.nc-secure.headers.stsSeconds=15552000</code>. Attach it additionally to
the <code>nc-dav</code> middleware on the router (<code>...routers.nc.middlewares=nc-dav,nc-secure</code>). The
header must come from the proxy, not from Nextcloud. In <a href="/en/tutorials/harden-optimize-nextcloud/">part
2</a> we set up exactly this <code>nc-secure</code> middleware
fully (including <code>includeSubdomains</code>).</p>
<p><strong>Large uploads break off or end with a timeout / &ldquo;413&rdquo;.</strong> The PHP limit is too small.
Increase <code>PHP_UPLOAD_LIMIT</code> and <code>PHP_MEMORY_LIMIT</code> (step 3) and restart the container. In
rare cases these variables don&rsquo;t take effect – then mount your own <code>php.ini</code> snippet into the
image.</p>
<p><strong>On the first start the installation aborts with &ldquo;MySQL server has gone away&rdquo; or &ldquo;Connection
refused&rdquo;.</strong> The app container was faster than the database. That&rsquo;s exactly what the
<code>healthcheck</code> with <code>depends_on: condition: service_healthy</code> is for – check that both are
present in your Compose file, and restart with <code>docker compose up -d</code>.</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<ul>
<li>
<p><strong>Backups are mandatory for a cloud.</strong> A consistent backup needs <strong>three things</strong>: the
<strong>database</strong> (MariaDB dump), the <strong>data directory</strong> (your files) and the <strong>configuration</strong>
(<code>config.php</code>). Put Nextcloud into maintenance mode briefly before the backup so the
database and files match each other:</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 <span class="nb">exec</span> -u www-data nc-app php occ maintenance:mode --on
</span></span><span class="line"><span class="cl">docker <span class="nb">exec</span> nc-db mariadb-dump -u root -pA_STRONG_ROOT_PASSWORD nextcloud &gt; nextcloud-db.sql
</span></span><span class="line"><span class="cl">docker <span class="nb">exec</span> -u www-data nc-app php occ maintenance:mode --off</span></span></code></pre></div>
</div>
<p>Back up the dump together with the <code>nc_html</code> volume <strong>encrypted and off-site</strong> with
<a href="/en/tutorials/restic-backups/">Restic</a> – thanks to the dump you don&rsquo;t need to additionally
back up the raw <code>nc_db</code> volume. The <code>nc_html</code> volume contains both the program code and the
<code>config.php</code> as well as – under <code>data/</code> – <strong>your actual user files</strong>. If your cloud grows a
lot, you can later move this <code>data/</code> directory to a separate, larger volume; for now one
volume for everything is enough.</p>
</li>
<li>
<p><strong>Updates: only one major version per step.</strong> From 34 to 35, never directly to 36. Bump the
image tag (<code>nextcloud:35-apache</code>), then <code>docker compose pull</code> and <code>docker compose up -d</code>.
The container runs the necessary <code>occ upgrade</code> <strong>automatically</strong> on start. <strong>Always make a
backup first.</strong> Bump the cron sidecar to the same version.</p>
</li>
<li>
<p><strong>Check security.</strong> After setup, run the official
<a href="https://scan.nextcloud.com/">Nextcloud Security Scan</a> against your domain (target: grade A)
and work through the warnings from the admin overview. The overview under <strong>Administration →
Overview</strong> runs automatic checks and shows exactly what&rsquo;s still missing. Keep Nextcloud up
to date – security holes are closed promptly, but only if you update.</p>
</li>
<li>
<p><strong>Honest about the effort:</strong> a self-hosted cloud wants to be maintained. Reckon with an
update pass roughly monthly and a regular look at the admin overview. In return, the data
then really belongs to you.</p>
</li>
</ul>
]]></content:encoded></item><item><title>Vaultwarden: your own password manager behind Traefik</title><link>https://serverkueche.de/en/tutorials/vaultwarden-password-manager/</link><pubDate>Sun, 19 Jul 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/vaultwarden-password-manager/</guid><description>Self-host Vaultwarden: a lean, Bitwarden-compatible password manager behind Traefik with HTTPS, an admin panel and an encrypted backup.</description><content:encoded><![CDATA[<p>Passwords belong in a password manager – but do they have to live in the cloud of some
third-party provider? With <strong>Vaultwarden</strong> you host your own, encrypted and under your
control. It speaks the Bitwarden language, so you use the familiar Bitwarden apps and
browser extensions – they just point at <strong>your</strong> server.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>By the end, <strong>Vaultwarden 1.36</strong> runs in a container behind your Traefik proxy, reachable
at <code>https://vault.YOUR_DOMAIN</code> with a valid HTTPS certificate. Vaultwarden is a lean
reimplementation of the Bitwarden server written in Rust: API-compatible, but frugal
enough to run on a small VPS. You connect the official <strong>Bitwarden clients</strong> (browser
extension, phone app, desktop) with your server, store your credentials – end-to-end
encrypted – and manage the service via a secured <strong>admin panel</strong>.</p>
<p>Your passwords thus live on your server, in your country, under your backup – and you pay
no subscription fee for features like 2FA or attachments.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-sky-300 bg-sky-50 dark:border-sky-800 dark:bg-sky-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">ℹ️</span>Vaultwarden ≠ Bitwarden
  </p>
  <div class="prose-kitchen text-sm">Vaultwarden is an <strong>unofficial</strong>, community-driven project that reimplements the Bitwarden
server API. It&rsquo;s not affiliated with Bitwarden Inc. For private and small-team use it&rsquo;s
excellent; the clients (apps, extensions) are the real, official ones from Bitwarden.</div>
</div>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<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> – exactly the setup from the tutorial
<a href="/en/tutorials/traefik-reverse-proxy/">reverse proxy with Traefik</a>. Without Traefik this
recipe doesn&rsquo;t work: Vaultwarden <strong>requires HTTPS</strong>.</li>
<li>A subdomain, e.g. <code>vault.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>
<li>A working <strong>backup</strong> of your server. A password manager is the place where data loss
hurts most – if you haven&rsquo;t yet, first set up
<a href="/en/tutorials/restic-backups/">backups with Restic</a>.</li>
</ul>
<p>If you want to combine several services later, the <a href="/en/server-calculator/">server calculator</a>
helps with the right server size.</p>
<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 1000 G12</p>
      <p class="mt-1 text-sm text-slate-600 dark:text-slate-300">4 vCores · 8 GB RAM · 256 GB NVMe</p>
      <p class="mt-1 text-sm font-semibold text-paprika-700 dark:text-paprika-400">from €10.36/month</p>
      <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">Vaultwarden is so frugal that even the smallest VPS is easily enough.</p>
    </div>
    <a href="https://www.netcup.com/en/server/vps/vps-1000-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="step-by-step">Step by step</h2>
<h3 id="step-1-generate-the-admin_token">Step 1: Generate the ADMIN_TOKEN</h3>
<p>Vaultwarden has an admin panel at <code>/admin</code>. Access to it is protected by an <code>ADMIN_TOKEN</code>.
You should <strong>not</strong> store it in plaintext, but as a hash – then your panel password appears
nowhere readable in the Compose file.</p>
<p>Vaultwarden brings its own command for this. We run it briefly in a throwaway container:</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 -it vaultwarden/server:1.36.0 /vaultwarden <span class="nb">hash</span> --preset owasp</span></span></code></pre></div>
</div>
<p>The command asks you twice for a password (input stays invisible) and then outputs an
<strong>Argon2id hash</strong> – a long string that starts like this:</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">Generate an Argon2id PHC string using the &#39;owasp&#39; preset.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Password:
</span></span><span class="line"><span class="cl">Confirm Password:
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">ADMIN_TOKEN=&#39;$argon2id$v=19$m=19456,t=2,p=1$FkxFEQ64Wy4zlQOWMI1fJ...$TxULe6MSND3By6GPVPKB1...&#39;</span></span></code></pre></div>
</div>
<p>Copy out the complete string between the quotes (including the <code>$argon2id$…</code> parts) – you
need it in a moment. The password you entered here is your <strong>admin panel password</strong>; store
it in your password manager (in the old one, for now).</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>Why the detour via the hash?
  </p>
  <div class="prose-kitchen text-sm">You could also set the <code>ADMIN_TOKEN</code> as a plaintext password. The hash is safer, though:
even someone who gets hold of your <code>compose.yaml</code> can&rsquo;t compute your panel password back
from it. <code>--preset owasp</code> chooses Argon2 parameters per the current OWASP recommendation.</div>
</div>
<h3 id="step-2-create-the-composeyaml">Step 2: Create the compose.yaml</h3>
<p>Create a dedicated folder and change into 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">mkdir -p ~/vaultwarden <span class="o">&amp;&amp;</span> <span class="nb">cd</span> ~/vaultwarden</span></span></code></pre></div>
</div>
<p>Create the <code>compose.yaml</code>. Replace <code>vault.YOUR_DOMAIN</code> with your real subdomain and the
<code>ADMIN_TOKEN</code> with the hash from step 1:</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">vaultwarden</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">vaultwarden/server:1.36.0</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">./vw-data:/data</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">DOMAIN</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;https://vault.YOUR_DOMAIN&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">SIGNUPS_ALLOWED</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;true&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">ADMIN_TOKEN</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;$$argon2id$$v=19$$m=19456,t=2,p=1$$FkxFE...$$TxULe...&#34;</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.vaultwarden.rule=Host(`vault.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.vaultwarden.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.vaultwarden.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.vaultwarden.loadbalancer.server.port=80&#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>The most important lines in detail:</p>
<ul>
<li><strong><code>image: vaultwarden/server:1.36.0</code></strong> – we deliberately pin the version instead of
taking <code>latest</code>. That way you update in a controlled manner (see &ldquo;Maintenance&rdquo;). For an
even smaller image there&rsquo;s also <code>:1.36.0-alpine</code>.</li>
<li><strong><code>volumes: ./vw-data:/data</code></strong> – this is where your database (<code>db.sqlite3</code>), the
encryption keys and the attachments live. <strong>This volume is mandatory.</strong> Without a volume
Vaultwarden deliberately refuses to start, so you don&rsquo;t lose your data in an ephemeral
container.</li>
<li><strong><code>DOMAIN</code></strong> – the full HTTPS URL. Vaultwarden needs it for WebAuthn/2FA and invitation
links, among other things. Must match your router rule exactly.</li>
<li><strong><code>SIGNUPS_ALLOWED: &quot;true&quot;</code></strong> – allows registration for now so you can create your first
account. We turn that off again shortly.</li>
<li><strong><code>ADMIN_TOKEN</code></strong> – your hash from step 1. <strong>Important:</strong> in a Compose file every dollar
sign must be <strong>doubled</strong> (<code>$</code> → <code>$$</code>), otherwise Compose tries to interpret it as a
variable. So <code>$argon2id$…</code> becomes <code>$$argon2id$$…</code>.</li>
<li><strong><code>loadbalancer.server.port=80</code></strong> – Vaultwarden listens on <strong>port 80</strong> in the container.
This line tells Traefik explicitly where to forward internally, instead of guessing the
target port from the image – the same pattern as with every app behind Traefik.</li>
<li><strong>No <code>ports:</code></strong> – as with every app behind Traefik, Vaultwarden is only reachable via the
proxy, never directly from outside.</li>
</ul>
<h3 id="step-3-start-it-and-the-first-call">Step 3: Start it and the first call</h3>
<p>Start the container:</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>On the first start, Vaultwarden creates the data directory and initializes the database.
Take a look at the log:</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 logs -f vaultwarden</span></span></code></pre></div>
</div>
<p>At the end you should see this line – it confirms the service is running:</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">[INFO] Rocket has launched from http://0.0.0.0:80</span></span></code></pre></div>
</div>
<p>With <code>Ctrl+C</code> you leave the log view again (the container keeps running). Now open
<code>https://vault.YOUR_DOMAIN</code> in the browser. On the first access Traefik fetches the Let&rsquo;s
Encrypt certificate – that can take a few seconds. After that the Bitwarden web interface
appears.</p>
<p>Click <strong>Create account</strong> and create your first account – with your email address and a
name:</p>
<p><figure class="my-6"><img src="/en/tutorials/vaultwarden-password-manager/vaultwarden-konto-erstellen_hu_442c987fd222aaf3.webp" srcset="/en/tutorials/vaultwarden-password-manager/vaultwarden-konto-erstellen_hu_171ec18b0766c4f0.webp 480w, /en/tutorials/vaultwarden-password-manager/vaultwarden-konto-erstellen_hu_442c987fd222aaf3.webp 768w, /en/tutorials/vaultwarden-password-manager/vaultwarden-konto-erstellen_hu_fe725a264430f1c1.webp 1200w, /en/tutorials/vaultwarden-password-manager/vaultwarden-konto-erstellen_hu_19943411b9fb06d0.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/vaultwarden-password-manager/vaultwarden-konto-erstellen_hu_da55256e86e78bf2.webp"
    alt="The Vaultwarden web interface shows the form to create a new account with email address and name" title="Create account – Vaultwarden uses the official Bitwarden web interface"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Create account – Vaultwarden uses the official Bitwarden web interface</figcaption></figure></p>
<p>In the next step you set your <strong>master password</strong>. That&rsquo;s the one key that unlocks your
entire vault:</p>
<p><figure class="my-6"><img src="/en/tutorials/vaultwarden-password-manager/vaultwarden-master-passwort_hu_94b00d92f8745395.webp" srcset="/en/tutorials/vaultwarden-password-manager/vaultwarden-master-passwort_hu_817569daa8f2d151.webp 480w, /en/tutorials/vaultwarden-password-manager/vaultwarden-master-passwort_hu_94b00d92f8745395.webp 768w, /en/tutorials/vaultwarden-password-manager/vaultwarden-master-passwort_hu_eba5c53116ba25e8.webp 1200w, /en/tutorials/vaultwarden-password-manager/vaultwarden-master-passwort_hu_1fe5f1251d11ceab.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/vaultwarden-password-manager/vaultwarden-master-passwort_hu_5f16260444ccea7e.webp"
    alt="The Vaultwarden page for setting a strong master password with a password field and strength indicator" title="The master password encrypts your entire vault – choose it carefully"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The master password encrypts your entire vault – choose it carefully</figcaption></figure></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>The master password is not recoverable
  </p>
  <div class="prose-kitchen text-sm">Your master password is <strong>never</strong> transmitted to the server; it decrypts your vault
locally. If you forget it, <strong>all</strong> passwords stored in it are lost – no one, not even you,
can recover them. Choose a long, unique passphrase and keep it in a safe place (e.g.
printed out in a safe).</div>
</div>
<h3 id="step-4-close-registration-again">Step 4: Close registration again</h3>
<p>Once your account is set up, you want to <strong>prevent strangers from registering too</strong> – your
Vaultwarden is, after all, open on the internet. Set <code>SIGNUPS_ALLOWED</code> to <code>false</code> in the
<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="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">DOMAIN</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;https://vault.YOUR_DOMAIN&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">SIGNUPS_ALLOWED</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">ADMIN_TOKEN</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;$$argon2id$$...&#34;</span></span></span></code></pre></div>
</div>
<p>And apply the change:</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>Compose detects the changed environment variable and restarts the container. From now on
the login page rejects new registrations. Further users (e.g. for the family) you invite
specifically via the admin panel when needed – more on that shortly.</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>Don&#39;t forget
  </p>
  <div class="prose-kitchen text-sm">An open <code>SIGNUPS_ALLOWED: &quot;true&quot;</code> is the most common misconfiguration with self-hosted
Vaultwarden. Anyone who knows your domain could otherwise create an account. This step is
mandatory, not an extra.</div>
</div>
<h3 id="step-5-the-admin-panel">Step 5: The admin panel</h3>
<p>Open <code>https://vault.YOUR_DOMAIN/admin</code> and log in with the <strong>password</strong> you entered in
step 1 when generating the hash (not with the hash itself). You land in the management
interface:</p>
<p><figure class="my-6"><img src="/en/tutorials/vaultwarden-password-manager/vaultwarden-admin_hu_473707ac677392b3.webp" srcset="/en/tutorials/vaultwarden-password-manager/vaultwarden-admin_hu_9aeb46075faece5c.webp 480w, /en/tutorials/vaultwarden-password-manager/vaultwarden-admin_hu_473707ac677392b3.webp 768w, /en/tutorials/vaultwarden-password-manager/vaultwarden-admin_hu_5667882637e7d363.webp 1200w, /en/tutorials/vaultwarden-password-manager/vaultwarden-admin_hu_d5ec00afc73e4d04.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/vaultwarden-password-manager/vaultwarden-admin_hu_6e7aad8d3eec6a20.webp"
    alt="The Vaultwarden admin panel with the areas General, SMTP Email and Backup Database" title="The admin panel – here you manage users, SMTP and global settings"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The admin panel – here you manage users, SMTP and global settings</figcaption></figure></p>
<p>Here you control the service centrally:</p>
<ul>
<li><strong>Users</strong> – view existing users, <strong>invite</strong> new ones (even with <code>SIGNUPS_ALLOWED=false</code>),
deactivate accounts.</li>
<li><strong>Settings → SMTP Email Settings</strong> – enter your mail server&rsquo;s credentials here so
Vaultwarden can send invitations and 2FA codes by email. Without SMTP, invitation by link
works too, but email-based 2FA doesn&rsquo;t.</li>
<li><strong>Diagnostics</strong> – shows you whether your <code>DOMAIN</code> is set correctly and whether Vaultwarden
is reachable from outside – handy for troubleshooting.</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>Settings in the panel vs. environment variables
  </p>
  <div class="prose-kitchen text-sm">What you save in the admin panel is stored in <code>vw-data/config.json</code> and <strong>overrides</strong> the
environment variables from the <code>compose.yaml</code>. Decide per setting on one way to avoid
confusion. For the basic configuration (domain, token, signups) we deliberately stick with
the Compose file – it&rsquo;s versionable and reproducible.</div>
</div>
<h3 id="step-6-connect-the-clients">Step 6: Connect the clients</h3>
<p>Now comes the real benefit. Install the <strong>official Bitwarden app</strong> or browser extension
(from the respective app or add-on store). Before you log in, you switch the server:</p>
<ol>
<li>In the app/extension, <strong>before logging in</strong>, open the settings for the <strong>self-hosted
environment</strong> (gear icon or &ldquo;Region: Self-hosted&rdquo;).</li>
<li>Enter <code>https://vault.YOUR_DOMAIN</code> as the <strong>server URL</strong> and save.</li>
<li>Now log in with your email and your master password – the app talks to your server from
now on.</li>
</ol>
<p>The nice part: it&rsquo;s the same mature clients as with commercial Bitwarden. Autofill,
password generator, secure notes, attachments and 2FA storage work the same way – only the
data lives on your server.</p>
<p><strong>Import existing passwords:</strong> if you&rsquo;re switching from another manager (or the browser&rsquo;s
password store), you don&rsquo;t have to retype everything. Export your entries there as CSV or
JSON and import them via the <strong>web interface</strong> under <code>Tools → Import data</code>. Vaultwarden
understands the export formats of the common managers (KeePass, LastPass, 1Password,
Chrome/Firefox, etc.) directly.</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>Securely delete export files afterwards
  </p>
  <div class="prose-kitchen text-sm">An export CSV contains all passwords <strong>in plaintext</strong>. Delete the file immediately after a
successful import – and empty the trash. Never leave it lying in Downloads or a cloud
folder.</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>Enable two-factor authentication
  </p>
  <div class="prose-kitchen text-sm">In your vault&rsquo;s account settings, enable <strong>2FA</strong> (e.g. via an authenticator app). That way,
even with a cracked master password, your vault isn&rsquo;t immediately open. For email-based 2FA,
SMTP must be set up in the admin panel first.</div>
</div>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong>The container won&rsquo;t start, the log says something like <code>Running without a persistent volume is not recommended</code>.</strong> The <code>volumes:</code> mapping is missing. Vaultwarden deliberately
refuses to start without a data directory so your passwords don&rsquo;t end up in an ephemeral
container. Add <code>./vw-data:/data</code> as in step 2 and restart.</p>
<p><strong>The web interface shows &ldquo;You need to enable HTTPS!&rdquo; or the login fails with crypto
errors.</strong> Vaultwarden uses the browser&rsquo;s Web Crypto API, which is only available in a
<strong>secure context</strong> (real HTTPS). You opened the page over <code>http://</code> or with an invalid
certificate. Make sure Traefik fetched a valid Let&rsquo;s Encrypt certificate (check the Traefik
log) and that you reach the page over <code>https://</code>. The <code>DOMAIN</code> variable must also start with
<code>https://</code>.</p>
<p><strong>The admin panel rejects your password, even though it&rsquo;s correct.</strong> Probably the dollar
signs in the <code>ADMIN_TOKEN</code> aren&rsquo;t doubled. In the <code>compose.yaml</code> every <code>$</code> must become <code>$$</code>.
Check with <code>docker compose config</code> how the token actually arrives (Compose shows the
resolved value there). Also remember: at login you enter the <strong>password</strong>, not the hash.</p>
<p><strong>The phone app can&rsquo;t find the server or reports &ldquo;Server URL invalid&rdquo;.</strong> The server URL must
be the full <code>https://</code> address without a trailing path (<code>https://vault.YOUR_DOMAIN</code>). Also
check whether the domain is reachable from outside via a browser and the certificate is
valid – apps are stricter about certificate errors than browsers.</p>
<p><strong>Despite <code>SIGNUPS_ALLOWED=false</code>, someone was able to register.</strong> The setting was probably
set in the admin panel and overrides the environment variable, or the container wasn&rsquo;t
restarted after the change. Check the value under <strong>Settings → General settings</strong> in the
panel and restart with <code>docker compose up -d</code>.</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<ul>
<li>
<p><strong>Backups are non-negotiable for a password manager.</strong> Your entire vault is in the
<code>vw-data/</code> directory (SQLite database, keys, attachments). Back it up <strong>encrypted and
off-site</strong> with <a href="/en/tutorials/restic-backups/">Restic</a> – add the folder
<code>~/vaultwarden/vw-data</code> to your backup sources. For a consistent database state, briefly
run <code>docker compose stop</code> before the backup, or use the &ldquo;Backup Database&rdquo; function in the
admin panel. A mere file copy while the container is <strong>running</strong> can catch an inconsistent
state (SQLite writes into WAL files) – with a password manager that&rsquo;s not a risk worth
taking.</p>
</li>
<li>
<p><strong>Apply updates in a controlled way.</strong> Because we pinned the version, you update
deliberately: before the switch read the
<a href="https://github.com/dani-garcia/vaultwarden/releases">release notes</a>, then raise the tag
in the <code>compose.yaml</code> (e.g. to the next <code>1.x</code>) and pull anew:</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 pull <span class="o">&amp;&amp;</span> docker compose up -d</span></span></code></pre></div>
</div>
<p>Afterwards check the <code>Rocket has launched</code> line in the log again and test a login.</p>
</li>
<li>
<p><strong>Keep registration closed.</strong> Occasionally check that <code>SIGNUPS_ALLOWED</code> is still <code>false</code> –
always invite new users specifically via the admin panel.</p>
</li>
<li>
<p><strong>Rotate the ADMIN_TOKEN</strong> if it might be compromised: generate a new hash with the <code>hash</code>
command from step 1, replace it in the <code>compose.yaml</code>, restart.</p>
</li>
<li>
<p><strong>Never lose the master password.</strong> There is no recovery. In a team/family setup, an
emergency access can make sense – you set that up per vault in the account settings.</p>
</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>