<?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>Security – Serverküche</title><link>https://serverkueche.de/en/tags/security/</link><description>Security – 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/security/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>Hardening &amp; optimizing Nextcloud: clear every warning (part 2)</title><link>https://serverkueche.de/en/tutorials/harden-optimize-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/harden-optimize-nextcloud/</guid><description>Get your Nextcloud admin overview green: set up HSTS headers, email sending, enforced two-factor auth and brute-force protection with real IPs.</description><content:encoded><![CDATA[<p>Your Nextcloud is running – but the admin overview shows yellow warnings, there&rsquo;s no working
email and no second factor protects your accounts. In this second part we turn &ldquo;running&rdquo;
into &ldquo;cleanly secured and snappy&rdquo;: we work through every warning until the overview is green.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>By the end, the <strong>security &amp; setup overview</strong> of your Nextcloud shows no more warnings.
Concretely, we build on the installation from <a href="/en/tutorials/self-host-nextcloud/">part 1</a>
and add: the missing <strong>HSTS security header</strong> via Traefik, a configured <strong>maintenance
window</strong>, working <strong>email sending</strong> (for password resets and notifications), <strong>enforced
two-factor authentication</strong> and <strong>brute-force protection that sees the real attacker IPs</strong> –
the basis for Fail2ban.</p>
<p>Everything is tested against <strong>Nextcloud 34.0.1</strong> behind <strong>Traefik v3.7</strong> on Debian 13 with
PHP 8.5. This is legwork, but exactly the legwork missing in 90% of all Nextcloud guides –
and it makes the difference between &ldquo;somehow online&rdquo; and &ldquo;properly operated&rdquo;.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>A <strong>running Nextcloud</strong> set up as in
<a href="/en/tutorials/self-host-nextcloud/">self-hosting Nextcloud (part 1)</a>: classic <code>nextcloud</code>
image behind Traefik, with MariaDB, Redis and a cron sidecar. The container names from
part 1 (<code>nc-app</code>, <code>nc-db</code>, <code>nc-redis</code>, <code>nc-cron</code>) and the Compose folder <code>~/nextcloud</code> are
the basis here.</li>
<li><strong>SSH access</strong> to the server and the command-line tool <strong><code>occ</code></strong> – we call it as in part 1
in the app container: <code>docker exec -u www-data nc-app php occ &lt;command&gt;</code>.</li>
<li>A <strong>Traefik reverse proxy</strong> with the <code>proxy</code> network and the resolver <code>le</code> as in the
tutorial <a href="/en/tutorials/traefik-reverse-proxy/">reverse proxy with Traefik</a> – we attach the
security headers as a Traefik middleware.</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>Keeping occ in hand
  </p>
  <div class="prose-kitchen text-sm"><p><code>occ</code> is Nextcloud&rsquo;s admin tool. Because the containers in part 1 got fixed names
(<code>container_name</code>), you address the app container directly – no matter which directory you&rsquo;re
in:</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 status</span></span></code></pre></div>
</div>
<p>You should see <code>installed: true</code> and your version. We won&rsquo;t abbreviate this prefix
(<code>docker exec -u www-data nc-app php occ …</code>) in the following – so you can copy every command
directly. Only the few <code>docker compose …</code> commands (like the container restart in step 3) you
still run from the <code>~/nextcloud</code> folder.</p>
</div>
</div>
<h2 id="step-by-step">Step by step</h2>
<h3 id="step-1-read-the-admin-overview-as-a-stock-take">Step 1: Read the admin overview as a stock-take</h3>
<p>Log in as administrator and open <strong>Administration settings → Overview</strong>. At the top is the
<strong>&ldquo;Security &amp; setup warnings&rdquo;</strong> section. Right after a standard installation it looks like
this:</p>
<p><figure class="my-6"><img src="/en/tutorials/harden-optimize-nextcloud/nc-uebersicht-warnungen_hu_1c52167f2e50ee81.webp" srcset="/en/tutorials/harden-optimize-nextcloud/nc-uebersicht-warnungen_hu_abb4be326f248f94.webp 480w, /en/tutorials/harden-optimize-nextcloud/nc-uebersicht-warnungen_hu_1c52167f2e50ee81.webp 768w, /en/tutorials/harden-optimize-nextcloud/nc-uebersicht-warnungen_hu_486b6513ab603031.webp 1200w, /en/tutorials/harden-optimize-nextcloud/nc-uebersicht-warnungen_hu_2dc71aec63365520.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/harden-optimize-nextcloud/nc-uebersicht-warnungen_hu_19f3c57f5382e577.webp"
    alt="The Nextcloud admin overview lists several yellow warnings about the maintenance window, MIME-type migrations and HTTP headers" title="Starting point: three warnings and several notices in the admin overview"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Starting point: three warnings and several notices in the admin overview</figcaption></figure></p>
<p>Nextcloud distinguishes three levels: <strong>yellow warnings</strong> (⚠, you should fix), <strong>blue notices</strong>
(ℹ, usually optional) and silently passed checks. On our fresh instance there are three
warnings and a few notices:</p>
<ul>
<li>⚠ <strong>Maintenance window start</strong> is not set (step 2)</li>
<li>⚠ <strong>MIME-type migrations available</strong> (step 2)</li>
<li>⚠ <strong>HTTP headers</strong> – the <code>Strict-Transport-Security</code> header is missing (step 3)</li>
<li>ℹ <strong>Email test</strong> – no mail server configured yet (step 4)</li>
<li>ℹ <strong>Two-factor configuration</strong> – 2FA is available but not enforced (step 5)</li>
</ul>
<p>You also get the same report on the command line – handy to check progress in between without
clicking through the web interface:</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 setupchecks</span></span></code></pre></div>
</div>
<p>The command outputs each check with ✓, ⚠ or ✗. We now work through exactly this list from top
to bottom.</p>
<h3 id="step-2-fix-the-two-quick-warnings">Step 2: Fix the two quick warnings</h3>
<p>Two warnings are done with one command each.</p>
<p><strong>Set the maintenance window.</strong> Nextcloud runs compute-intensive cleanup jobs once daily
(previews, activities, calendar repeats). Without a defined time window they run <em>at some
point</em> – even in the middle of the day. Set a start time when hardly anyone works. The value
is a <strong>full hour in UTC</strong>; <code>1</code> sets the start to 01:00 UTC – the check afterwards reports the
window from 1:00 to 7:00 UTC:</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 config:system:set maintenance_window_start --type<span class="o">=</span>integer --value<span class="o">=</span><span class="m">1</span></span></span></code></pre></div>
</div>
<p>To verify, the setup check afterwards confirms:</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">✓ Maintenance window start: Maintenance window to execute heavy background jobs is between 1:00 UTC and 7:00 UTC</span></span></code></pre></div>
</div>
<p><strong>Run MIME-type migrations.</strong> Occasionally Nextcloud learns new file types (e.g. for better
icons and previews). This migration does <em>not</em> run automatically on updates because it can
take a while on large instances. On a fresh cloud it&rsquo;s done in seconds:</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:repair --include-expensive</span></span></code></pre></div>
</div>
<p>You see a list of repair steps performed (including <code>Repair mime types</code>). Afterwards the check
reports <code>Mimetype migrations available: None</code>. Two fewer warnings.</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>And the blue notices?
  </p>
  <div class="prose-kitchen text-sm">The notices <strong>&ldquo;AppAPI deploy daemon&rdquo;</strong> and <strong>&ldquo;Server-ID configuration&rdquo;</strong> you can ignore on a
normal single-server installation. The first concerns only the installation of external
&ldquo;Ex-Apps&rdquo; via a Docker daemon, the second only setups spread across several PHP servers. You
need neither here – that&rsquo;s why they stay notices and not warnings.</div>
</div>
<h3 id="step-3-set-the-hsts-security-header-via-traefik">Step 3: Set the HSTS security header via Traefik</h3>
<p>The third warning is the most important: the <strong><code>Strict-Transport-Security</code> header</strong> (HSTS) is
missing. It instructs the browser to address this domain <strong>exclusively over HTTPS</strong> – even
when someone types <code>http://</code> or an attacker tries to redirect the connection. Without HSTS, a
small time window for downgrade attacks stays open.</p>
<p>Because our Nextcloud sits behind Traefik, we set the header <strong>in the proxy</strong>, not in
Nextcloud – that&rsquo;s where it belongs, since Traefik terminates the TLS. In the <code>compose.yaml</code>
of your Nextcloud (from part 1), add the middleware <code>nc-secure</code> and attach it to the router.
Concretely, two places change in the <code>labels</code> block of <code>nc-app</code>.</p>
<p>First, extend the router line with <code>nc-secure</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;traefik.http.routers.nc.middlewares=nc-dav,nc-secure&#34;</span></span></span></code></pre></div>
</div>
<p>Second, define the header middleware (right by the other <code>nc-app</code> 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.nc-secure.headers.stsSeconds=15552000&#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-secure.headers.stsIncludeSubdomains=true&#34;</span></span></span></code></pre></div>
</div>
<p><code>stsSeconds=15552000</code> is 180 days – the minimum value Nextcloud requires. Apply the change and
reload only the app 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 nc-app</span></span></code></pre></div>
</div>
<p>After a few seconds, check from your own machine that the header is really delivered:</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 -sS -I https://cloud.YOUR_DOMAIN/ <span class="p">|</span> grep -i strict-transport</span></span></code></pre></div>
</div>
<p>You should see exactly 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">strict-transport-security: max-age=15552000; includeSubDomains</span></span></code></pre></div>
</div>
<p>In the admin overview the HTTP-header warning thus turns green: <em>&ldquo;Your server is correctly
configured to send security headers.&rdquo;</em> The remaining headers (<code>X-Content-Type-Options</code>,
<code>X-Frame-Options</code>, <code>Referrer-Policy</code> …) Nextcloud delivers itself – only HSTS had to come from
the proxy.</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>preload is a one-way street
  </p>
  <div class="prose-kitchen text-sm"><code>stsIncludeSubdomains=true</code> extends the HTTPS requirement to all subdomains <strong>of
<code>cloud.YOUR_DOMAIN</code></strong> (not to siblings like <code>www.YOUR_DOMAIN</code> – the header always applies only
to the host that delivers it). Deliberately <strong>not</strong> set is <code>stsPreload=true</code>: with it you&rsquo;d
signal that you want the domain entered into the browsers'
<a href="https://hstspreload.org/">HSTS preload list</a> – and such an entry can only be undone with
weeks of lead time. If you ever deliberately want that: the preload list only accepts
<code>max-age</code> ≥ 31536000 (1 year) – with the 15552000 above the flag would be ineffective anyway.
Only set <code>preload</code> when really <em>every</em> service under the domain permanently speaks HTTPS; the
header is fully functional without it.</div>
</div>
<h3 id="step-4-set-up-email-sending">Step 4: Set up email sending</h3>
<p>Without working mail sending, Nextcloud can send no <strong>password resets</strong>, no <strong>share
notifications</strong> and no <strong>security warnings</strong> – and the &ldquo;Email test&rdquo; notice stays. We catch up
on that.</p>
<p>Open <strong>Administration settings → Basic settings</strong> and scroll to the <strong>&ldquo;Email server&rdquo;</strong>
section. Enter your mail provider&rsquo;s credentials:</p>
<ul>
<li><strong>Send mode:</strong> <code>SMTP</code></li>
<li><strong>Encryption:</strong> <code>SSL/TLS</code> (port <strong>465</strong>) or <code>STARTTLS</code> (port <strong>587</strong>) – never unencrypted</li>
<li><strong>From address:</strong> e.g. <code>cloud</code> @ <code>YOUR_DOMAIN</code></li>
<li><strong>Server address:</strong> host and port of your provider</li>
<li><strong>Authentication:</strong> enable and store username/password</li>
</ul>
<p>At the top, under <strong>Personal information</strong>, enter an email address for your admin account (the
test mail goes there). Then click <strong>&ldquo;Send test email&rdquo;</strong>. If the mail arrives, Nextcloud
confirms it with a green message:</p>
<p><figure class="my-6"><img src="/en/tutorials/harden-optimize-nextcloud/nc-email-test_hu_8d45e6800f6b3498.webp" srcset="/en/tutorials/harden-optimize-nextcloud/nc-email-test_hu_fa94582cdd4ce86a.webp 480w, /en/tutorials/harden-optimize-nextcloud/nc-email-test_hu_8d45e6800f6b3498.webp 768w, /en/tutorials/harden-optimize-nextcloud/nc-email-test_hu_c032c70fb8d96766.webp 1200w, /en/tutorials/harden-optimize-nextcloud/nc-email-test_hu_634c0ea546cf8596.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/harden-optimize-nextcloud/nc-email-test_hu_9c1f6dabcee5c7cf.webp"
    alt="The email server is configured with SMTP, a test send is confirmed at the top right with &ldquo;Email sent&rdquo;" title="Successful test send – the values in the image come from an internal test mail server and are NOT to be adopted for real mailboxes; there belong the host, port 587/465 with STARTTLS/SSL and your provider&#39;s credentials"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Successful test send – the values in the image come from an internal test mail server and are NOT to be adopted for real mailboxes; there belong the host, port 587/465 with STARTTLS/SSL and your provider&#39;s credentials</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>App password instead of account password
  </p>
  <div class="prose-kitchen text-sm">If your mail provider itself uses two-factor authentication (Gmail, Mailbox.org, many others),
your normal login password does <strong>not</strong> work here. Create a dedicated <strong>app password</strong> in your
provider&rsquo;s account and enter that. It can also be revoked specifically without changing your
main password.</div>
</div>
<h3 id="step-5-enforce-two-factor-authentication">Step 5: Enforce two-factor authentication</h3>
<p>A stolen password is the most common way accounts get taken over. A <strong>second factor</strong> (a
time-based code from an authenticator app, TOTP) makes the password alone worthless. Nextcloud
already brings the matching provider – we just have to enable it and make it mandatory.</p>
<p>First enable the TOTP app (it&rsquo;s included):</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 app:enable twofactor_totp</span></span></code></pre></div>
</div>
<p>Each user then sets up their second factor themselves – under <strong>Personal settings → Security →
&ldquo;TOTP (Authenticator app)&rdquo;</strong>. On enabling, Nextcloud shows a QR code you scan with an app like
Aegis, andOTP or Google Authenticator. Additionally, the app <strong>&ldquo;Two-Factor Backup Codes&rdquo;</strong> is
recommended – the one-time codes save you if the phone is lost.</p>
<p>So that nobody &ldquo;forgets&rdquo; the safeguard, you enforce 2FA for the administrators. On the web
you find that under <strong>Administration settings → Security → &ldquo;Enforce two-factor
authentication&rdquo;</strong>, on the command line it works like this:</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 twofactorauth:enforce --on --group<span class="o">=</span>admin</span></span></code></pre></div>
</div>
<p><figure class="my-6"><img src="/en/tutorials/harden-optimize-nextcloud/nc-zweifaktor_hu_852af47470939e12.webp" srcset="/en/tutorials/harden-optimize-nextcloud/nc-zweifaktor_hu_1a27814597c783f5.webp 480w, /en/tutorials/harden-optimize-nextcloud/nc-zweifaktor_hu_852af47470939e12.webp 768w, /en/tutorials/harden-optimize-nextcloud/nc-zweifaktor_hu_4b78d97a12110e38.webp 1200w, /en/tutorials/harden-optimize-nextcloud/nc-zweifaktor_hu_2c4ea048fe38829a.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/harden-optimize-nextcloud/nc-zweifaktor_hu_99357b7cbf56851d.webp"
    alt="The security settings show enforced two-factor authentication for the &ldquo;admin&rdquo; group as well as the brute-force status with a correctly recognized client IP" title="Two-factor requirement for the \&#34;admin\&#34; group – and above it the proof that Nextcloud recognizes the real client IP"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Two-factor requirement for the \&#34;admin\&#34; group – and above it the proof that Nextcloud recognizes the real client IP</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>Don&#39;t lock yourself out
  </p>
  <div class="prose-kitchen text-sm">As soon as enforcement is active, every member of the group <strong>must</strong> set up a second factor on
the next login. So set up <strong>your own</strong> second factor first and keep the backup codes safe
before you arm the requirement. If something does go wrong, you lift the enforcement again via
the command line: <code>docker exec -u www-data nc-app php occ twofactorauth:enforce --off</code>. A
single stuck factor you remove with <code>… php occ twofactorauth:disable USER totp</code>.</div>
</div>
<h3 id="step-6-brute-force-protection-with-real-ips--basis-for-fail2ban">Step 6: Brute-force protection with real IPs – basis for Fail2ban</h3>
<p>Nextcloud throttles failed logins out of the box: after several failed attempts it delays
further requests from <strong>the same IP</strong>. But this protection stands and falls with Nextcloud
seeing the <strong>real client IP</strong> – and not the internal IP of Traefik. That&rsquo;s exactly what the
<code>TRUSTED_PROXIES</code> setting from part 1 does.</p>
<p>Whether it works you see under <strong>Administration settings → Security</strong> in the &ldquo;brute-force
allow list&rdquo; box (in the screenshot above). There stands your current, correctly recognized
public IP:</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">Your current IP address is recognized as &#34;YOUR_IP&#34;. This address is currently not throttled.</span></span></code></pre></div>
</div>
<p>If a <code>172.x.x.x</code> address from the Docker network appears there instead, <code>TRUSTED_PROXIES</code>
isn&rsquo;t taking effect – then the brute-force protection would lump all users together and, in
doubt, ban Traefik itself. In that case, back to part 1, step 2/3.</p>
<p><strong>Hard bans with Fail2ban.</strong> The built-in throttling only delays; whoever wants to really ban
attackers combines Nextcloud with <a href="/en/tutorials/fail2ban-setup/">Fail2ban</a>. Nextcloud logs
every failed attempt in the JSON log – and thanks to <code>TRUSTED_PROXIES</code> with the real IP:</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">{&#34;level&#34;:2,&#34;time&#34;:&#34;2026-07-18T08:14:05+00:00&#34;,&#34;remoteAddr&#34;:&#34;203.0.113.47&#34;,&#34;user&#34;:&#34;--&#34;,&#34;app&#34;:&#34;no app in context&#34;,&#34;method&#34;:&#34;POST&#34;,&#34;url&#34;:&#34;/login&#34;,&#34;message&#34;:&#34;Login failed: USER (Remote IP: 203.0.113.47)&#34;}</span></span></code></pre></div>
</div>
<p>This log lives in the Nextcloud volume. You find the path on the host like this:</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 inspect -f <span class="s1">&#39;{{ range .Mounts }}{{ if eq .Destination &#34;/var/www/html&#34; }}{{ .Source }}{{ end }}{{ end }}&#39;</span> nc-app</span></span></code></pre></div>
</div>
<p>Below it is the file <code>data/nextcloud.log</code>. At that you point a Fail2ban filter that matches on
<code>remoteAddr</code>, plus a jail following the pattern from the
<a href="/en/tutorials/fail2ban-setup/">Fail2ban tutorial</a>. This way, after a few failed attempts, the
attacker IP goes straight into the firewall – before the request even reaches Nextcloud.</p>
<h3 id="step-7-check-caching--php--the-performance-foundation">Step 7: Check caching &amp; PHP – the performance foundation</h3>
<p>Finally, we make sure the performance basis is in place. Much of this part 1 already set up
automatically via the <code>REDIS_HOST</code> variable – checking doesn&rsquo;t hurt anyway, because this is
exactly where a Nextcloud gets sluggish or snappy.</p>
<p>Nextcloud uses <strong>three</strong> cache roles. Check all three:</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 config:system:get memcache.local
</span></span><span class="line"><span class="cl">docker <span class="nb">exec</span> -u www-data nc-app php occ config:system:get memcache.distributed
</span></span><span class="line"><span class="cl">docker <span class="nb">exec</span> -u www-data nc-app php occ config:system:get memcache.locking</span></span></code></pre></div>
</div>
<p>Expected:</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">\OC\Memcache\APCu
</span></span><span class="line"><span class="cl">\OC\Memcache\Redis
</span></span><span class="line"><span class="cl">\OC\Memcache\Redis</span></span></code></pre></div>
</div>
<ul>
<li><strong>Local cache (APCu):</strong> keeps frequently used data in the RAM of the PHP process – the most
noticeable accelerator in everyday use.</li>
<li><strong>Distributed cache (Redis):</strong> shares cache data across processes, important once the app and
cron containers interact.</li>
<li><strong>File locking (Redis):</strong> prevents two accesses from changing the same file simultaneously and
corrupting it. The reason we built in Redis at all in part 1.</li>
</ul>
<p>If the interplay fits, <code>docker exec -u www-data nc-app php occ setupchecks</code> reports the line
<code>✓ Memcache: Configured</code>. (Passed checks aren&rsquo;t shown by the web overview – only warnings and
notices appear there, which is why you check this via CLI.)</p>
<p><strong>OPcache</strong> – PHP&rsquo;s bytecode cache – is already sensibly preconfigured in the official
<code>nextcloud</code> image (including <code>opcache.interned_strings_buffer=32</code>). So you don&rsquo;t have to
adjust anything here; the formerly notorious OPcache warning no longer appears at all with
current images. You can check it if needed like this:</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 -i <span class="p">|</span> grep opcache.interned_strings_buffer</span></span></code></pre></div>
</div>
<p>With that, the overview is warning-free:</p>
<p><figure class="my-6"><img src="/en/tutorials/harden-optimize-nextcloud/nc-uebersicht-gruen_hu_3292f5806336612e.webp" srcset="/en/tutorials/harden-optimize-nextcloud/nc-uebersicht-gruen_hu_6b3888444730c10c.webp 480w, /en/tutorials/harden-optimize-nextcloud/nc-uebersicht-gruen_hu_3292f5806336612e.webp 768w, /en/tutorials/harden-optimize-nextcloud/nc-uebersicht-gruen_hu_2a971e5ec7202dca.webp 1200w, /en/tutorials/harden-optimize-nextcloud/nc-uebersicht-gruen_hu_b6c2c3da23b5f506.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/harden-optimize-nextcloud/nc-uebersicht-gruen_hu_f8c15f84dd9e1da.webp"
    alt="The Nextcloud admin overview shows no more yellow warnings, only two optional notices, and reports the current version" title="Result: no more warnings, version current – only the two optional notices remain"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Result: no more warnings, version current – only the two optional notices remain</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>Generate previews proactively
  </p>
  <div class="prose-kitchen text-sm">If your users store many photos, the app <strong>&ldquo;Preview Generator&rdquo;</strong> is worth it: an <code>occ</code> cron job
generates thumbnails in advance instead of computing them slowly on the fly on first open. If
you add it, limit the maximum preview size via
<code>occ config:system:set preview_max_x --value=2048</code> (and <code>preview_max_y</code>), otherwise the storage
demand grows quickly.</div>
</div>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong>After the Traefik restart, the HSTS header doesn&rsquo;t appear in the <code>curl</code> output.</strong> Usually
the middleware isn&rsquo;t on the router. Check that the line <code>traefik.http.routers.nc.middlewares</code>
names <strong>both</strong> middlewares (<code>nc-dav,nc-secure</code>) and that the three <code>nc-secure</code> header labels
are in the <code>nc-app</code> block. Then <code>docker compose up -d nc-app</code>. Traefik only picks up changed
labels when recreating the container.</p>
<p><strong>The email test fails (&ldquo;There was a problem sending the email&rdquo;).</strong> Almost always
port/encryption or authentication. Combine <code>SSL/TLS</code> with port <strong>465</strong> or <code>STARTTLS</code> with port
<strong>587</strong> – not crosswise. If your provider uses 2FA, you need an <strong>app password</strong> (see step 4).
Details are in the Nextcloud log: <code>docker exec -u www-data nc-app php occ log:tail 20</code>.</p>
<p><strong>You can&rsquo;t get in yourself after enforcing 2FA.</strong> You hadn&rsquo;t set up a second factor yet. Lift
the requirement via the command line, set up your factor calmly and arm it again afterwards:
<code>docker exec -u www-data nc-app php occ twofactorauth:enforce --off</code>.</p>
<p><strong>In the brute-force box a <code>172.x.x.x</code> address appears instead of your real IP.</strong>
<code>TRUSTED_PROXIES</code> doesn&rsquo;t match the actual <code>proxy</code> subnet. Determine it with <code>docker network inspect proxy -f '{{(index .IPAM.Config 0).Subnet}}'</code> and enter the value in the <code>nc-app</code>
environment (part 1, step 2). Otherwise the brute-force protection bans the proxy in an
emergency and thus all users.</p>
<p><strong>After a reboot, Nextcloud reports &ldquo;Redis went away&rdquo; or gets very slow.</strong> The app container
started before Redis. Make sure <code>nc-redis</code> is in <code>depends_on</code> (part 1) and runs with <code>restart: unless-stopped</code> – then Docker catches the start-order case itself.</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<ul>
<li><strong>Check the overview after every update.</strong> New Nextcloud versions bring new checks. After
every upgrade take a look at <strong>Administration settings → Overview</strong> or run <code>occ setupchecks</code> –
this way you catch new warnings early.</li>
<li><strong>Use the official security scan regularly.</strong> The
<a href="https://scan.nextcloud.com/">Nextcloud Security Scan</a> checks your domain from outside and
assigns a grade. With HSTS and current versions, <strong>A/A+</strong> is the realistic target; if the
grade drops, an update is usually overdue.</li>
<li><strong>Keep an eye on second factors.</strong> Keep the backup codes separate from the phone. If a user
loses their device, you remove their factor with <code>occ twofactorauth:disable USER totp</code>, after
which they set it up anew.</li>
<li><strong>Clean up app passwords.</strong> Every connected client (desktop, phone) gets its own app
password. Under <strong>Personal settings → Security</strong> you see all devices and can specifically
revoke individual ones on loss.</li>
<li><strong>The hardening replaces no backup.</strong> All of this protects against foreign access, not
against data loss. The consistent backup of a database dump and data directory stays
mandatory – see the maintenance section in <a href="/en/tutorials/self-host-nextcloud/">part 1</a> and
<a href="/en/tutorials/restic-backups/">backups with Restic</a>. Honest about the effort: firmly plan
the monthly update pass with a quick look at the overview – then your cloud stays green
permanently.</li>
</ul>
]]></content:encoded></item><item><title>Restic backups: encrypted and off-site</title><link>https://serverkueche.de/en/tutorials/restic-backups/</link><pubDate>Sat, 18 Jul 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/restic-backups/</guid><description>Off-site backups with Restic: set up encrypted, restore snapshots, prune with retention and automate via a systemd timer.</description><content:encoded><![CDATA[<p>A snapshot at the provider is <strong>not a backup</strong>: it sits on the same infrastructure and
is gone too if your account is suspended, the data center burns down, or you
accidentally delete the wrong project. <strong>Restic</strong> turns it into a real backup –
encrypted, deduplicated and in a place <strong>outside</strong> your server.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>By the end, <strong>Restic</strong> backs up your important data – app volumes, configurations,
database dumps – <strong>end-to-end encrypted</strong> and <strong>deduplicated</strong> into a repository,
ideally <strong>off-site</strong> (a second server or storage). You can restore individual files or
whole snapshots at any time, automatically thin out old backups by a retention policy,
and check integrity. Finally, everything runs automatically via a systemd timer and
alerts you if a run fails to happen.</p>
<p>Tested with <strong>Restic 0.18</strong> (the version from the Debian 13 package sources); upstream
already has 0.19.x – how to update is in step 1. Restic <strong>always</strong> encrypts – there is
no unencrypted repository.</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>Snapshot ≠ backup
  </p>
  <div class="prose-kitchen text-sm">The <a href="/en/tutorials/netcup-snapshots-scp/">snapshot in the provider panel</a> is a
convenient &ldquo;rewind&rdquo; – but it sits on the <strong>same</strong> infrastructure and under the <strong>same</strong>
account. If the provider, the data center or your access fails, the snapshot is gone
too. A real backup lives <strong>outside</strong> (another provider/location), is <strong>encrypted</strong> and
you can restore it <strong>independently</strong>. That&rsquo;s exactly Restic&rsquo;s job – the snapshot stays
the quick safety net for small mishaps.</div>
</div>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>A server (Debian 13) with the data you want to back up – e.g. the volumes and
Compose files of your <a href="/en/tutorials/install-docker/">Docker apps</a>.</li>
<li>An <strong>off-site target</strong> for the backups. Restic speaks, among others:
<ul>
<li><strong>SFTP</strong> – a second server or a storage box (easiest to start with),</li>
<li><strong>S3-compatible</strong> – e.g. an object-storage bucket,</li>
<li><strong>rclone</strong> – as a bridge to Backblaze B2, Google Drive and dozens more.</li>
</ul>
</li>
<li>Basic familiarity with the shell.</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 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">A small second VPS at a different location is a cheap, dedicated backup target via SFTP.</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-install-restic">Step 1: Install Restic</h3>
<p>Restic is in the package sources and is updated with the system:</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 restic</span></span></code></pre></div>
</div>
<p>Check the version:</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">restic version</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">restic 0.18.0 compiled with go1.24.4 on linux/amd64</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>A newer version if needed
  </p>
  <div class="prose-kitchen text-sm">Upstream, Restic is sometimes ahead (currently 0.19.x). If you need a newer feature,
download the official binary from the
<a href="https://github.com/restic/restic/releases">Restic release page</a> and place it in
<code>/usr/local/bin</code> (that takes precedence over <code>/usr/bin</code>). The built-in command
<code>restic self-update</code> does <strong>not</strong> work with the Debian package version – the package is
built without that feature (<code>unknown command &quot;self-update&quot;</code>). For most people the
package version is entirely enough.</div>
</div>
<h3 id="step-2-create-the-encrypted-repository">Step 2: Create the encrypted repository</h3>
<p>A Restic <strong>repository</strong> is the encrypted storage location of your backups. It&rsquo;s
protected with a password – and this password does <strong>not</strong> belong in a script and
<strong>not</strong> next to the repository. Put it in a file only root may read:</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 -d -m <span class="m">700</span> /etc/restic
</span></span><span class="line"><span class="cl">openssl rand -base64 <span class="m">32</span> <span class="p">|</span> sudo tee /etc/restic/password &gt; /dev/null
</span></span><span class="line"><span class="cl">sudo chmod <span class="m">600</span> /etc/restic/password</span></span></code></pre></div>
</div>
<p>The <code>openssl</code> command generates a long random password and stores it under
<code>/etc/restic/password</code> (the <code>install</code> command before it creates the directory, the
<code>chmod</code> after it protects the file). Tell Restic via environment variables where the
repository is and where the password is – here first an off-site target via SFTP:</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">export</span> <span class="nv">RESTIC_REPOSITORY</span><span class="o">=</span><span class="s2">&#34;sftp:BACKUP_USER@YOUR_BACKUP_HOST:/backups/YOUR_SERVER&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">RESTIC_PASSWORD_FILE</span><span class="o">=</span><span class="s2">&#34;/etc/restic/password&#34;</span></span></span></code></pre></div>
</div>
<p>Replace <code>BACKUP_USER</code>, <code>YOUR_BACKUP_HOST</code> and the path with your real target. If you
want to practice locally first, use a folder instead:
<code>export RESTIC_REPOSITORY=&quot;/srv/restic-repo&quot;</code>. The commands afterwards are
<strong>identical</strong> – only the repository address differs.</p>
<p>You use other off-site targets via the same variable, only with a different prefix:</p>
<ul>
<li><strong>S3-compatible:</strong> <code>s3:https://ENDPOINT/BUCKET</code> – plus the credentials as
<code>AWS_ACCESS_KEY_ID</code> and <code>AWS_SECRET_ACCESS_KEY</code> in the environment (or in the
environment file from step 7).</li>
<li><strong>rclone:</strong> <code>rclone:REMOTE:path</code> – the bridge to Backblaze B2, Google Drive and many
more, once you&rsquo;ve set up the rclone remote.</li>
</ul>
<p>No matter which backend: encryption, deduplication and all the following commands stay
the same.</p>
<p>Now initialize the repository (the example output shows the local practice repository –
with the SFTP target, your <code>sftp:</code> address appears there instead):</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">restic init</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">created restic repository d29613209a at /srv/restic-repo
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Please note that knowledge of your password is required to access
</span></span><span class="line"><span class="cl">the repository. Losing your password means that your data is
</span></span><span class="line"><span class="cl">irrecoverably lost.</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>Password lost = data lost
  </p>
  <div class="prose-kitchen text-sm">There is <strong>no</strong> back door. Without the repository password your backups are
irrecoverably encrypted. Back up the contents of <code>/etc/restic/password</code> additionally in
a <strong>password manager</strong> – separate from the server, otherwise it&rsquo;s no use to you if the
server is lost.</div>
</div>
<h3 id="step-3-the-first-backup">Step 3: The first backup</h3>
<p>Back up the directory with your data. The <code>--tag</code> helps with filtering later:</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">restic backup /root/backup-demo --tag demo</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">no parent snapshot found, will read all files
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Files:           3 new,     0 changed,     0 unmodified
</span></span><span class="line"><span class="cl">Dirs:            2 new,     0 changed,     0 unmodified
</span></span><span class="line"><span class="cl">Added to the repository: 2.002 MiB (2.001 MiB stored)
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">processed 3 files, 2.000 MiB in 0:00
</span></span><span class="line"><span class="cl">snapshot f16c6ffd saved</span></span></code></pre></div>
</div>
<p>On the <strong>second</strong> run, Restic&rsquo;s strength shows: it only backs up the changes
(deduplication). If a file changes and you back up again:</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">using parent snapshot f16c6ffd
</span></span><span class="line"><span class="cl">Files:           0 new,     1 changed,     2 unmodified
</span></span><span class="line"><span class="cl">Added to the repository: 1.820 KiB (1.121 KiB stored)
</span></span><span class="line"><span class="cl">processed 3 files, 2.000 MiB in 0:00
</span></span><span class="line"><span class="cl">snapshot 0bb5e679 saved</span></span></code></pre></div>
</div>
<p>Instead of 2 MiB again, only <strong>1.1 KiB</strong> land in the repository – just the changed
part.</p>
<p>What belongs in the backup? For Docker apps that&rsquo;s three things – recipe, payload, DB:</p>
<ul>
<li><strong>Compose files</strong> (your &ldquo;recipe&rdquo;) and <code>.env</code>: small but indispensable for rebuilding
the stack.</li>
<li><strong>Bind mounts</strong> (<code>./data:/…</code>) live directly in the project folder – you back those up
right along. <strong>Named volumes</strong> you&rsquo;ll find under
<code>/var/lib/docker/volumes/&lt;name&gt;/_data</code>; include the path.</li>
<li><strong>Databases</strong> never as a live file – a backup mid-write is potentially unusable.
Instead create a <strong>consistent dump</strong> (<code>pg_dump</code>/<code>mariadb-dump</code>) and back that up (see
step 7).</li>
</ul>
<p>You exclude the unnecessary:</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">restic backup /opt --tag apps --exclude<span class="o">=</span><span class="s1">&#39;*.log&#39;</span> --exclude<span class="o">=</span><span class="s1">&#39;**/cache/**&#39;</span></span></span></code></pre></div>
</div>
<h3 id="step-4-restore">Step 4: Restore</h3>
<p>A backup is only as good as its restore. First list the snapshots:</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">restic snapshots</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        Time                 Host                  Tags        Paths              Size
</span></span><span class="line"><span class="cl">---------------------------------------------------------------------------------------------
</span></span><span class="line"><span class="cl">f16c6ffd  2026-07-16 20:52:35  v2200000000000000000  demo        /root/backup-demo  2.000 MiB
</span></span><span class="line"><span class="cl">0bb5e679  2026-07-16 20:52:36  v2200000000000000000  demo        /root/backup-demo  2.000 MiB
</span></span><span class="line"><span class="cl">---------------------------------------------------------------------------------------------
</span></span><span class="line"><span class="cl">2 snapshots</span></span></code></pre></div>
</div>
<p>Restore a complete snapshot to a target directory (use <code>latest</code> for the newest or a
specific ID):</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">restic restore latest --target /root/restore-test</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">restoring snapshot 0bb5e679 of [/root/backup-demo] ... to /root/restore-test
</span></span><span class="line"><span class="cl">Summary: Restored 5 files/dirs (2.000 MiB) in 0:00</span></span></code></pre></div>
</div>
<p>If you only need <strong>one</strong> file, there are two ways – restore that specific path:</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">restic restore latest --target /tmp/wiederher --include /root/backup-demo/config.yaml</span></span></code></pre></div>
</div>
<p>… or stream the file directly to standard output, without any detour:</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">restic dump latest /root/backup-demo/config.yaml &gt; /tmp/config.yaml</span></span></code></pre></div>
</div>
<p>To <strong>browse</strong>, you can even mount the repository as a filesystem (<code>restic mount /mnt/backup</code>, requires FUSE) and copy out what you need in the file manager – handy when
you no longer know which snapshot the version you&rsquo;re looking for is in.</p>
<h3 id="step-5-check-integrity">Step 5: Check integrity</h3>
<p><code>check</code> verifies that the repository&rsquo;s structure is intact:</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">restic check</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">load indexes
</span></span><span class="line"><span class="cl">check all packs
</span></span><span class="line"><span class="cl">check snapshots, trees and blobs
</span></span><span class="line"><span class="cl">[0:00] 100.00%  2 / 2 snapshots
</span></span><span class="line"><span class="cl">no errors were found</span></span></code></pre></div>
</div>
<p>That checks the metadata, but not the actual data. So regularly read part of the real
data along with it – e.g. 10% per run, i.e. everything over the course of weeks:</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">restic check --read-data-subset<span class="o">=</span>10%</span></span></code></pre></div>
</div>
<h3 id="step-6-prune-with-a-retention-policy">Step 6: Prune with a retention policy</h3>
<p>Without pruning, the repository grows forever. <code>forget</code> applies a <strong>retention policy</strong>,
<code>--prune</code> frees the storage of the removed snapshots:</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">restic forget --keep-daily <span class="m">7</span> --keep-weekly <span class="m">4</span> --keep-monthly <span class="m">6</span> --prune</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">Applying Policy: keep 7 daily, 4 weekly, 6 monthly snapshots
</span></span><span class="line"><span class="cl">keep 2 snapshots:
</span></span><span class="line"><span class="cl">2 snapshots</span></span></code></pre></div>
</div>
<p>This keeps the last 7 daily, 4 weekly and 6 monthly snapshots – a good default. The
tiers interlock: fine resolution for the recent past (one per day), coarser the further
back (one per week, then one per month). So, if in doubt, you can get close to the
desired state without the repository growing unbounded. Older snapshots drop off
automatically. <strong><code>--prune</code> is the expensive part</strong> (it repacks data); on large
repositories you often run it only once or twice a week, not on every backup.</p>
<h3 id="step-7-automate-everything-systemd-timer">Step 7: Automate everything (systemd timer)</h3>
<p>A backup you have to start by hand is one you forget. Put the credentials in an
environment file (so the service knows them):</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"># /etc/restic/restic.env</span>
</span></span><span class="line"><span class="cl"><span class="nv">RESTIC_REPOSITORY</span><span class="o">=</span>sftp:BACKUP_USER@YOUR_BACKUP_HOST:/backups/YOUR_SERVER
</span></span><span class="line"><span class="cl"><span class="nv">RESTIC_PASSWORD_FILE</span><span class="o">=</span>/etc/restic/password</span></span></code></pre></div>
</div>
<p>Then a wrapper script <code>/usr/local/bin/restic-backup.sh</code> – it first dumps the database
(PostgreSQL example), backs up, prunes and finally reports success to
<a href="/en/tutorials/uptime-kuma-monitoring/">Uptime Kuma</a> (the push monitor as a &ldquo;dead man&rsquo;s
switch&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="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl"><span class="c1"># 0) Ensure the target directory for the dump (otherwise the first run aborts)</span>
</span></span><span class="line"><span class="cl">mkdir -p /opt/dumps
</span></span><span class="line"><span class="cl"><span class="c1"># 1) Dump the database consistently (never back up the live DB file)</span>
</span></span><span class="line"><span class="cl">docker <span class="nb">exec</span> YOUR_DB_CONTAINER pg_dumpall -U postgres &gt; /opt/dumps/db.sql
</span></span><span class="line"><span class="cl"><span class="c1"># 2) Backup</span>
</span></span><span class="line"><span class="cl">restic backup /opt/apps /opt/dumps --tag auto
</span></span><span class="line"><span class="cl"><span class="c1"># 3) Prune (retention)</span>
</span></span><span class="line"><span class="cl">restic forget --keep-daily <span class="m">7</span> --keep-weekly <span class="m">4</span> --keep-monthly <span class="m">6</span> --prune
</span></span><span class="line"><span class="cl"><span class="c1"># 4) Report success to Uptime Kuma</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=restic+OK&#34;</span> &gt; /dev/null</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">sudo chmod <span class="m">700</span> /usr/local/bin/restic-backup.sh</span></span></code></pre></div>
</div>
<p>The service loads the environment file and runs the script –
<code>/etc/systemd/system/restic-backup.service</code>:</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="k">[Service]</span>
</span></span><span class="line"><span class="cl"><span class="na">Type</span><span class="o">=</span><span class="s">oneshot</span>
</span></span><span class="line"><span class="cl"><span class="na">EnvironmentFile</span><span class="o">=</span><span class="s">/etc/restic/restic.env</span>
</span></span><span class="line"><span class="cl"><span class="na">ExecStart</span><span class="o">=</span><span class="s">/usr/local/bin/restic-backup.sh</span></span></span></code></pre></div>
</div>
<p>And the timer <code>/etc/systemd/system/restic-backup.timer</code> starts it nightly:</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="k">[Timer]</span>
</span></span><span class="line"><span class="cl"><span class="na">OnCalendar</span><span class="o">=</span><span class="s">*-*-* 02:30:00</span>
</span></span><span class="line"><span class="cl"><span class="na">Persistent</span><span class="o">=</span><span class="s">true</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[Install]</span>
</span></span><span class="line"><span class="cl"><span class="na">WantedBy</span><span class="o">=</span><span class="s">timers.target</span></span></span></code></pre></div>
</div>
<p><code>Persistent=true</code> catches up a missed run (e.g. because the server was off at night) on
the next start. Enable 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">sudo systemctl daemon-reload
</span></span><span class="line"><span class="cl">sudo systemctl <span class="nb">enable</span> --now restic-backup.timer</span></span></code></pre></div>
</div>
<p>Check with <code>systemctl list-timers restic-backup.timer</code> when the next run is due.</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 let backup time collide with the automatic reboot
  </p>
  <div class="prose-kitchen text-sm">If the server runs an <a href="/en/tutorials/unattended-upgrades-automatic-updates/">automatic reboot via
unattended-upgrades</a> (in that
example <code>04:00</code>), the backup must be done <strong>before</strong> then. If the running backup run
(<code>restic backup</code> or the subsequent <code>restic forget --prune</code>) is cut off mid-operation by
the reboot, a <strong>stale lock</strong> remains in the repository (<code>repository is already locked</code>,
see below). That&rsquo;s why the timer here starts at <code>02:30</code> – enough headroom for a larger
backup plus pruning. If you adjust either time, deliberately keep the gap large.</div>
</div>
<h3 id="step-8-rehearse-the-worst-case-once">Step 8: Rehearse the worst case once</h3>
<p>A backup you&rsquo;ve never restored is just a hope. So deliberately run through a complete
restore once – ideally on a <strong>different</strong> or freshly set-up server, so you also need the
credentials (repository address <strong>and</strong> password) under real conditions:</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">export</span> <span class="nv">RESTIC_REPOSITORY</span><span class="o">=</span><span class="s2">&#34;sftp:BACKUP_USER@YOUR_BACKUP_HOST:/backups/YOUR_SERVER&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">RESTIC_PASSWORD_FILE</span><span class="o">=</span><span class="s2">&#34;/etc/restic/password&#34;</span>
</span></span><span class="line"><span class="cl">restic snapshots
</span></span><span class="line"><span class="cl">restic restore latest --target /tmp/dr-test</span></span></code></pre></div>
</div>
<p>Then compare samples with the original (<code>diff</code>, file hashes) and boot the app from the
restored data as a test. Only once that works do you know your backup holds up in an
emergency – and that you&rsquo;ll remember the procedure when it really counts.</p>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong><code>subprocess ssh: Host key verification failed</code> with the SFTP target.</strong> Restic can&rsquo;t
confirm the SSH host key of the backup server because it isn&rsquo;t known yet. Connect
<strong>once manually</strong> (<code>ssh BACKUP_USER@YOUR_BACKUP_HOST</code>) and confirm the fingerprint – or
store it with <code>ssh-keyscan YOUR_BACKUP_HOST &gt;&gt; ~/.ssh/known_hosts</code>. After that Restic
runs through.</p>
<p><strong><code>wrong password</code> or <code>repository does not exist</code>.</strong> <code>RESTIC_REPOSITORY</code> or
<code>RESTIC_PASSWORD_FILE</code> isn&rsquo;t set or points nowhere – so check the <code>EnvironmentFile</code> in
the systemd service. A manually set <code>export</code> only applies in the current shell.</p>
<p><strong><code>repository is already locked</code>.</strong> An aborted run left a lock. Check that <strong>really
no</strong> backup is running anymore, then <code>restic unlock</code>. Never unlock blindly while a run
is active in parallel.</p>
<p><strong>The backup gets huge / backs up nonsense.</strong> Restrict it with
<code>--exclude</code>/<code>--exclude-file</code> (caches, logs, temporary files) and use
<code>--one-file-system</code> so Restic doesn&rsquo;t dive into mounted foreign filesystems.</p>
<p><strong><code>prune</code> takes forever or was aborted.</strong> No reason to panic – <code>prune</code> is resumable and
the repository stays valid. Start it again and afterwards run <code>restic check</code> once.
Separate <code>--prune</code> from the daily backup on large repos (see step 6).</p>
<p><strong>The timer runs, but a success message never arrives in Uptime Kuma.</strong> Look at the
last run with <code>journalctl -u restic-backup.service</code>. Usually the script aborts
<strong>before</strong> the <code>curl</code> call (e.g. the DB dump failed) – thanks to <code>set -euo pipefail</code> it
then stops cleanly instead of reporting a broken backup as success. So the missing push
isn&rsquo;t a bug, but exactly the alarm signal you want.</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<ul>
<li><strong>Test restores regularly</strong> – that&rsquo;s the single most important point. A backup you&rsquo;ve
never restored is not a backup but a hope. Schedule a test restore into an empty
directory <strong>monthly</strong>.</li>
<li><strong>3-2-1 rule:</strong> at least 3 copies, on 2 different media, of which 1 is <strong>off-site</strong>.
Restic covers the off-site piece – but &ldquo;off-site&rdquo; really means a <strong>different
provider/location</strong>, not the same data center.</li>
<li><strong>Monitor backups:</strong> without monitoring, you only notice a dead backup in an
emergency. The push call from step 7 to
<a href="/en/tutorials/uptime-kuma-monitoring/">Uptime Kuma</a> raises the alarm when a run fails
to happen.</li>
<li><strong>Check integrity:</strong> run <code>restic check --read-data-subset</code> regularly so silent bit rot
is caught before you need the data.</li>
<li><strong>Password/key management:</strong> keep the repository password separate from the server.
For individual clients you can assign additional passwords with <code>restic key add</code>
without sharing the main password.</li>
</ul>
]]></content:encoded></item><item><title>Setting up Fail2ban: block brute-force attacks automatically</title><link>https://serverkueche.de/en/tutorials/fail2ban-setup/</link><pubDate>Sat, 18 Jul 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/fail2ban-setup/</guid><description>Fail2ban watches your logs and bans IPs after too many failed attempts – with a safe whitelist for your own address so you don't lock yourself out.</description><content:encoded><![CDATA[<p>After a few days on the internet, take a look at <code>journalctl -u ssh</code>: hundreds of
login attempts from foreign IPs, one every second. As long as your SSH is
<a href="/en/tutorials/harden-ssh/">switched to key login</a>, none of them gets in – but it
clutters the log and eats resources. <strong>Fail2ban</strong> reads along with these logs and
<strong>bans automatically</strong> whoever tries too often without success.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>By the end, <strong>Fail2ban 1.1</strong> on your <strong>Debian 13</strong> monitors the SSH logins and, after
too many failed attempts, bans the attacking IP for a defined time – via a firewall
rule. <strong>Your own IP is on a whitelist</strong> so this never hits you. You can view bans, set
them manually and lift them again.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>A <a href="/en/tutorials/harden-ssh/">hardened server</a> (key login) with an
<a href="/en/tutorials/firewall-ufw-setup/">active firewall</a></li>
<li>Your <strong>own public IP</strong> – find it e.g. via
<a href="https://www.wieistmeineip.de">wieistmeineip.de</a> or <code>curl -s ifconfig.me</code> from your
local machine</li>
</ul>
<h2 id="step-by-step">Step by step</h2>
<p>Before we configure, three terms that explain the whole tool:</p>
<ul>
<li><strong>Filter</strong> – a pattern that detects a &ldquo;failed login&rdquo; in the log (for SSH, Fail2ban
brings this ready-made).</li>
<li><strong>Jail</strong> – connects a filter with a log source and the rules &ldquo;how often in what time
frame&rdquo;. The <code>sshd</code> jail monitors the SSH logins.</li>
<li><strong>Action</strong> – what happens when it&rsquo;s exceeded: by default a <strong>firewall rule</strong> that
blocks the IP for <code>bantime</code>.</li>
</ul>
<p>In short: filter detects failed attempts → jail counts them → action bans. Everything
you configure below is one of these three knobs.</p>
<h3 id="step-1-install-fail2ban">Step 1: Install Fail2ban</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">sudo apt update
</span></span><span class="line"><span class="cl">sudo apt install -y fail2ban</span></span></code></pre></div>
</div>
<p>Check the version:</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">fail2ban-client --version</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">Fail2Ban v1.1.0</span></span></code></pre></div>
</div>
<h3 id="step-2-your-own-configuration-in-jaillocal">Step 2: Your own configuration in jail.local</h3>
<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 edit jail.conf
  </p>
  <div class="prose-kitchen text-sm">The shipped <code>/etc/fail2ban/jail.conf</code> is overwritten on updates. Your own settings
<strong>always</strong> belong in <code>/etc/fail2ban/jail.local</code> – this file is preserved and overrides
the defaults.</div>
</div>
<p>Create the 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">sudo nano /etc/fail2ban/jail.local</span></span></code></pre></div>
</div>
<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="k">[DEFAULT]</span>
</span></span><span class="line"><span class="cl"><span class="c1"># How long the ban lasts</span>
</span></span><span class="line"><span class="cl"><span class="na">bantime</span> <span class="o">=</span> <span class="s">1h</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Time window in which the failed attempts count</span>
</span></span><span class="line"><span class="cl"><span class="na">findtime</span> <span class="o">=</span> <span class="s">10m</span>
</span></span><span class="line"><span class="cl"><span class="c1"># This many failed attempts are allowed, then a ban</span>
</span></span><span class="line"><span class="cl"><span class="na">maxretry</span> <span class="o">=</span> <span class="s">5</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Your own IP(s) – will NEVER be banned</span>
</span></span><span class="line"><span class="cl"><span class="na">ignoreip</span> <span class="o">=</span> <span class="s">127.0.0.1/8 ::1 YOUR_OWN_IP</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[sshd]</span>
</span></span><span class="line"><span class="cl"><span class="na">enabled</span> <span class="o">=</span> <span class="s">true</span></span></span></code></pre></div>
</div>
<p>Line by line:</p>
<ul>
<li><strong><code>bantime = 1h</code></strong> – ban duration. For stubborn cases, see the escalation in step 5.</li>
<li><strong><code>findtime</code> + <code>maxretry</code></strong> – &ldquo;5 failed attempts within 10 minutes → ban&rdquo;.</li>
<li><strong><code>ignoreip</code></strong> – the most important line: enter <strong>your own IP</strong> here so you don&rsquo;t lock
yourself out. If you have a changing IP at home, better use access via a fixed point
(a VPN later) instead of a broad allowance.</li>
<li><strong><code>[sshd] enabled = true</code></strong> – activates the SSH jail.</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>Enter your own IP first
  </p>
  <div class="prose-kitchen text-sm">Set your own IP in <code>ignoreip</code> <strong>before</strong> you start Fail2ban – otherwise even your own
typo on login could lock you out. The address you&rsquo;re currently connected from is shown
by the first field of <code>echo &quot;$SSH_CLIENT&quot;</code>.</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>Debian 13 reads the systemd journal
  </p>
  <div class="prose-kitchen text-sm">On Debian 13, Fail2ban uses the <strong>systemd journal</strong> as its source by default – you
don&rsquo;t need to specify a <code>logpath</code> (like <code>/var/log/auth.log</code>). On modern systems that
file often no longer exists at all; the journal is the right source and works without
extra configuration.</div>
</div>
<h3 id="step-3-start-and-enable-the-service">Step 3: Start and enable the service</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">sudo systemctl <span class="nb">enable</span> --now fail2ban</span></span></code></pre></div>
</div>
<p><code>enable --now</code> starts the service immediately <strong>and</strong> ensures it runs again
automatically after a reboot. Check that it runs cleanly:</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 fail2ban</span></span></code></pre></div>
</div>
<p>You should see <code>active (running)</code>. After every change to <code>jail.local</code>, reload the
configuration with <code>sudo systemctl reload fail2ban</code>.</p>
<h3 id="step-4-check-the-status">Step 4: Check the status</h3>
<p>This is how you see which jails are active:</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 fail2ban-client 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">Status
</span></span><span class="line"><span class="cl">|- Number of jail:	1
</span></span><span class="line"><span class="cl">`- Jail list:	sshd</span></span></code></pre></div>
</div>
<p>And the details of the SSH jail:</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 fail2ban-client status sshd</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">Status for the jail: sshd
</span></span><span class="line"><span class="cl">|- Filter
</span></span><span class="line"><span class="cl">|  |- Currently failed:	0
</span></span><span class="line"><span class="cl">|  |- Total failed:	0
</span></span><span class="line"><span class="cl">|  `- Journal matches:	_SYSTEMD_UNIT=ssh.service + _COMM=sshd
</span></span><span class="line"><span class="cl">`- Actions
</span></span><span class="line"><span class="cl">   |- Currently banned:	0
</span></span><span class="line"><span class="cl">   |- Total banned:	0
</span></span><span class="line"><span class="cl">   `- Banned IP list:</span></span></code></pre></div>
</div>
<p>The <code>Journal matches</code> line confirms that Fail2ban reads the systemd journal.
<code>Currently banned</code> rises as soon as someone gets it wrong too often.</p>
<h3 id="step-5-manage-bans--and-escalate-them">Step 5: Manage bans – and escalate them</h3>
<p>Manually unban an IP (e.g. when a colleague mistyped):</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 fail2ban-client <span class="nb">set</span> sshd unbanip 203.0.113.45</span></span></code></pre></div>
</div>
<p>Ban an IP immediately:</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 fail2ban-client <span class="nb">set</span> sshd banip 203.0.113.45</span></span></code></pre></div>
</div>
<p>Against especially stubborn attackers, an <strong>escalating ban time</strong> pays off: whoever
comes back is banned for longer. Add to the <code>[DEFAULT]</code> block:</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">bantime.increment</span> <span class="o">=</span> <span class="s">true</span>
</span></span><span class="line"><span class="cl"><span class="na">bantime.maxtime</span> <span class="o">=</span> <span class="s">1w</span></span></span></code></pre></div>
</div>
<p>With that, Fail2ban doubles the ban time each time the same IP reoffends – up to a
maximum of one week. After the change, <code>sudo systemctl reload fail2ban</code>.</p>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong>You locked yourself out.</strong> <code>ignoreip</code> was missing or contained the wrong IP. Connect
via the <strong>console in the netcup SCP</strong> (independent of SSH) and unban yourself with
<code>sudo fail2ban-client set sshd unbanip YOUR_OWN_IP</code>. Then enter your IP in <code>ignoreip</code>
and reload. That&rsquo;s why the whitelist comes first in step 2.</p>
<p><strong><code>fail2ban.service</code> won&rsquo;t start (<code>systemctl status</code> shows &ldquo;failed&rdquo;).</strong> Almost always
a typo in <code>jail.local</code>. Check the syntax with <code>sudo fail2ban-client -t</code> (test mode) –
the command names the faulty line.</p>
<p><strong>No one is ever banned, even though the log is full of failed attempts.</strong> Check with
<code>sudo fail2ban-client status sshd</code> whether <code>Total failed</code> rises at all. If it stays at
0, the filter isn&rsquo;t finding the entries – usually because an outdated guide set a
<code>logpath</code> to a non-existent file. On Debian 13, <strong>remove</strong> the <code>logpath</code> from
<code>jail.local</code> and use the journal (step 2).</p>
<p><strong>Bans &ldquo;don&rsquo;t work&rdquo; – the IP keeps connecting.</strong> Fail2ban writes its block rules into
the same firewall (nftables/iptables) as UFW. Check with <code>sudo nft list ruleset | grep f2b</code> (or <code>sudo iptables -L -n | grep f2b</code>) whether the <code>f2b</code> chains exist. If they&rsquo;re
missing, the interplay with the firewall is stuck – restart the service and look at
the logs.</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<ul>
<li><strong>Keep an eye on bans:</strong> <code>sudo fail2ban-client status sshd</code> shows at any time how
many IPs are currently banned. A look at the journal (<code>journalctl -u fail2ban</code>) shows
the history.</li>
<li><strong>More jails:</strong> as soon as services with their own login are added (e.g. a web app),
you can activate matching jails – following the same pattern as <code>[sshd]</code>.</li>
<li><strong>Back up <code>jail.local</code>:</strong> your configuration is quickly restored but belongs in the
backup of your server configuration.</li>
<li><strong>Outlook CrowdSec:</strong> Fail2ban protects your host based on <em>your</em> logs. The modern
successor <strong>CrowdSec</strong> later adds <em>collaborative</em> intrusion prevention (shared block
lists) to this – we build it in a dedicated recipe once the reverse proxy is up. To
get started, Fail2ban is exactly right.</li>
</ul>
<p>With that, the security foundation is complete: key login, two firewall layers
(<a href="/en/tutorials/firewall-ufw-setup/">UFW</a> +
<a href="/en/tutorials/netcup-firewall-setup/">netcup firewall</a>),
<a href="/en/tutorials/unattended-upgrades-automatic-updates/">automatic updates</a> and active
brute-force protection. Your server is now not just set up, but solidly secured.</p>
]]></content:encoded></item><item><title>Setting up the netcup firewall in the SCP (with the stateless-UDP trick)</title><link>https://serverkueche.de/en/tutorials/netcup-firewall-setup/</link><pubDate>Fri, 17 Jul 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/netcup-firewall-setup/</guid><description>Set up the netcup firewall as network protection in front of the server: composable policy templates in the SCP – with the trick for stateless UDP (DNS &amp; NTP).</description><content:encoded><![CDATA[<p>netcup comes with a <strong>firewall in front of your server</strong> – in the Server Control Panel,
before a packet even reaches the operating system. Built correctly, it&rsquo;s a strong shield.
But there&rsquo;s a trick many people fail at: the netcup firewall is <strong>stateless for UDP</strong>. This
tutorial shows you a clean, reusable firewall – and why DNS and NTP otherwise suddenly get
stuck.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>In the <strong>netcup SCP</strong> we build a network firewall from <strong>composable policy templates</strong>: a
<strong>base</strong> template (that every server needs), one for <strong>SSH</strong>, and one for <strong>web
(HTTP/HTTPS)</strong>. You assign these building blocks to your server as needed – a web server
gets base + SSH + web, a server without a website only base + SSH. By the end, everything
inbound is blocked except what you deliberately allow.</p>
<p>A netcup-specific detail makes the difference: for <strong>UDP the firewall works statelessly</strong> –
which is why services like DNS and NTP need their own inbound rule, otherwise they suddenly
get stuck. Why that is and how the base template solves it is shown in step 2.</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>Network firewall ≠ host firewall
  </p>
  <div class="prose-kitchen text-sm">The netcup firewall works in the <strong>network in front of the server</strong> and does <strong>not</strong>
replace the firewall on the server itself (<a href="/en/tutorials/firewall-ufw-setup/">UFW</a>). Both
together are &ldquo;defense in depth&rdquo;: if one layer fails or is misconfigured, the other kicks
in. Feel free to set up both.</div>
</div>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>A netcup server, e.g. your <a href="/en/tutorials/first-steps-netcup-vps/">first VPS</a>.</li>
<li>Your <strong>SCP credentials</strong> (customer number + SCP password from the netcup welcome email –
different from your SSH login).</li>
<li>Clarity about your <strong>SSH port</strong>: the default is <code>22</code>, and it stays that way when
<a href="/en/tutorials/harden-ssh/">hardening SSH</a>. If you changed it elsewhere, use your real
port – otherwise you lock yourself out when activating.</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 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 firewall is included in the SCP with every netcup server.</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-open-firewall-policies-in-the-scp">Step 1: Open Firewall Policies in the SCP</h3>
<p>Log into the <a href="https://www.servercontrolpanel.de/">Server Control Panel</a> and open the
<strong>Firewall Policies</strong> menu item at the top. Here you create reusable rule sets
(&ldquo;policies&rdquo;) – independent of the individual server. You only assign them later.</p>
<h3 id="step-2-build-the-base-template">Step 2: Build the base template</h3>
<p>Click <strong>Create firewall policy</strong>, give it the name <code>Serverküche Base</code> and, via <strong>Add
rule</strong>, create these four rules – all <strong>INBOUND</strong> and <strong>ACCEPT</strong>:</p>
<table>
	<thead>
			<tr>
					<th>Description</th>
					<th>Protocol</th>
					<th>Source port (Src)</th>
					<th>Destination port (Dst)</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>DNS replies (stateless)</td>
					<td>UDP</td>
					<td><strong>53</strong></td>
					<td>any</td>
			</tr>
			<tr>
					<td>NTP replies (stateless)</td>
					<td>UDP</td>
					<td><strong>123</strong></td>
					<td>any</td>
			</tr>
			<tr>
					<td>ICMP (ping/PMTU)</td>
					<td>ICMP</td>
					<td>–</td>
					<td>–</td>
			</tr>
			<tr>
					<td>ICMPv6 (Neighbor Discovery)</td>
					<td>ICMPv6</td>
					<td>–</td>
					<td>–</td>
			</tr>
	</tbody>
</table>
<p><figure class="my-6"><img src="/en/tutorials/netcup-firewall-setup/netcup-fw-basis-regeln_hu_9e99585f9ae8a8a2.webp" srcset="/en/tutorials/netcup-firewall-setup/netcup-fw-basis-regeln_hu_940a2c430e5ad078.webp 480w, /en/tutorials/netcup-firewall-setup/netcup-fw-basis-regeln_hu_9e99585f9ae8a8a2.webp 768w, /en/tutorials/netcup-firewall-setup/netcup-fw-basis-regeln_hu_9b56e056028b7396.webp 1200w, /en/tutorials/netcup-firewall-setup/netcup-fw-basis-regeln_hu_a4a5b78ddd1de01e.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/netcup-firewall-setup/netcup-fw-basis-regeln_hu_191c700aae76cc67.webp"
    alt="The base template in the netcup SCP with four inbound ACCEPT rules: DNS and NTP via the source port, plus ICMP and ICMPv6" title="The base template: DNS/NTP replies come in via source port 53/123"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The base template: DNS/NTP replies come in via source port 53/123</figcaption></figure></p>
<p>Two things that make the difference here:</p>
<ul>
<li><strong>DNS/NTP via the <code>Src Port</code>, not the destination port.</strong> Your server <em>queries</em> DNS/NTP
(outbound); the <strong>reply</strong> comes back from port 53 or 123. Because UDP is stateless, you
need this inbound rule – otherwise there&rsquo;s no name resolution and no time
synchronization, even though &ldquo;everything else&rdquo; seems to work.</li>
<li><strong>Never forget ICMPv6.</strong> Without Neighbor Discovery, IPv6 breaks completely. ICMP (v4) on
top is useful for ping and path MTU discovery. netcup&rsquo;s default policy &ldquo;Ping allow&rdquo; does
already cover ICMP – it&rsquo;s deliberately in the base template again so your foundation stays
complete even if you ever remove the netcup defaults.</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>As soon as one rule exists, netcup blocks the rest automatically
  </p>
  <div class="prose-kitchen text-sm">You do <strong>not</strong> need to create a final &ldquo;block everything&rdquo;. <strong>Without</strong> an assigned policy,
everything inbound is allowed. <strong>But as soon as you assign the server at least one of your
own policies, netcup switches the default to &ldquo;block all&rdquo;</strong> – from then on everything
inbound is blocked that none of your rules explicitly allows. So the whitelist arises on
its own; outbound stays open.</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>Does your server use DHCP?
  </p>
  <div class="prose-kitchen text-sm">netcup VPS are usually configured <strong>statically</strong> (<code>iface … inet static</code>) – then there&rsquo;s
nothing to do here. netcup&rsquo;s network does run a DHCP server, but because UDP is stateless,
the DHCP <strong>reply</strong> (source port <strong>67</strong>) is discarded by the whitelist. If your server is
exceptionally configured via DHCP, add <code>INBOUND · UDP · ACCEPT · Src 67</code> to the base
template (for DHCPv6 additionally <code>Src 547</code>) – otherwise it loses its IP at the next lease
renewal.</div>
</div>
<h3 id="step-3-small-building-blocks-for-ssh-and-web">Step 3: Small building blocks for SSH and web</h3>
<p>Instead of one big rule monolith, you create <strong>atomic templates</strong> that you freely combine.
Create two more policies following the same pattern:</p>
<ul>
<li><strong><code>Serverküche SSH</code></strong> – one rule: INBOUND, TCP, ACCEPT, destination port <strong>22</strong>.</li>
<li><strong><code>Serverküche Web</code></strong> – two rules: INBOUND, TCP, ACCEPT, destination port <strong>80</strong> and
<strong>443</strong>.</li>
</ul>
<p><figure class="my-6"><img src="/en/tutorials/netcup-firewall-setup/netcup-fw-policys_hu_8300974e73f572e.webp" srcset="/en/tutorials/netcup-firewall-setup/netcup-fw-policys_hu_37f1808047787952.webp 480w, /en/tutorials/netcup-firewall-setup/netcup-fw-policys_hu_8300974e73f572e.webp 768w, /en/tutorials/netcup-firewall-setup/netcup-fw-policys_hu_4fba52dd0cbe8f8e.webp 1200w, /en/tutorials/netcup-firewall-setup/netcup-fw-policys_hu_490c02b761af2a0d.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/netcup-firewall-setup/netcup-fw-policys_hu_113b718980b0626c.webp"
    alt="The three composable firewall templates in the netcup SCP overview: base, SSH and web" title="Three small, reusable building blocks instead of one monolith"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Three small, reusable building blocks instead of one monolith</figcaption></figure></p>
<p>The gain: the base rules appear only <strong>once</strong>, not duplicated in every policy. If a service
is added later (e.g. a mail server or WireGuard), you build another small template for it
and plug it in – without touching the existing ones.</p>
<h3 id="step-4-assign-templates-to-the-server">Step 4: Assign templates to the server</h3>
<p>Switch to <strong>Server → your server → &ldquo;Firewall&rdquo; tab</strong> and click <strong>Edit firewall policies</strong>.
Move the appropriate ones from <strong>Available firewall policies</strong> to <strong>Selected</strong>: for a web
server <code>Serverküche Base</code> + <code>Serverküche SSH</code> + <code>Serverküche Web</code>.</p>
<p><figure class="my-6"><img src="/en/tutorials/netcup-firewall-setup/netcup-fw-zuweisen_hu_b5de2471022cffb5.webp" srcset="/en/tutorials/netcup-firewall-setup/netcup-fw-zuweisen_hu_916fa5dee97d09.webp 480w, /en/tutorials/netcup-firewall-setup/netcup-fw-zuweisen_hu_b5de2471022cffb5.webp 768w, /en/tutorials/netcup-firewall-setup/netcup-fw-zuweisen_hu_2f19ef31e0b0bfcc.webp 1200w, /en/tutorials/netcup-firewall-setup/netcup-fw-zuweisen_hu_1ec7e51f2b6aaab8.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/netcup-firewall-setup/netcup-fw-zuweisen_hu_d021fbd03a3210d1.webp"
    alt="The assignment dialog in the netcup SCP: on the left the available templates, on the right the ones selected for this server" title="Assign = plug together: base &#43; SSH &#43; web on a web server"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Assign = plug together: base &#43; SSH &#43; web on a web server</figcaption></figure></p>
<p>Confirm with <strong>Apply</strong> and then <strong>Save</strong>. Check that the <strong>&ldquo;Firewall active&rdquo;</strong> switch is
turned on. The assigned rules now appear in the list – together with netcup&rsquo;s <strong>default
policies</strong> (more on those below).</p>
<p><figure class="my-6"><img src="/en/tutorials/netcup-firewall-setup/netcup-fw-server_hu_1b203778eff9dee8.webp" srcset="/en/tutorials/netcup-firewall-setup/netcup-fw-server_hu_8c4daef82bf14989.webp 480w, /en/tutorials/netcup-firewall-setup/netcup-fw-server_hu_1b203778eff9dee8.webp 768w, /en/tutorials/netcup-firewall-setup/netcup-fw-server_hu_aec528535f9a750b.webp 1200w, /en/tutorials/netcup-firewall-setup/netcup-fw-server_hu_dbeac9995b0b9626.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/netcup-firewall-setup/netcup-fw-server_hu_d32ec044e4959b65.webp"
    alt="The server&rsquo;s firewall tab with an active firewall and the assigned rules" title="Firewall active – rules are evaluated top to bottom, the first match wins"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Firewall active – rules are evaluated top to bottom, the first match wins</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>Don&#39;t lock yourself out
  </p>
  <div class="prose-kitchen text-sm">Only activate the firewall if the <strong>SSH rule</strong> (port 22 or your real port) is in it. During
the switch, keep a <strong>second SSH session open</strong> and open a new connection in parallel to test
access – only once that works do you close the old session. If you can&rsquo;t get in at all, the
VNC console under <strong>Display</strong> in the SCP helps.</div>
</div>
<h3 id="step-5-ready-made-templates-for-common-services">Step 5: Ready-made templates for common services</h3>
<p>Exactly following the pattern from step 3, here are ready-made templates for the most
common self-hosting services. Each is its <strong>own small template</strong>, all rules are <strong>INBOUND</strong>
and <strong>ACCEPT</strong>. Build only the ones you really need and plug them, as in step 4, into
<code>Serverküche Base</code> + <code>Serverküche SSH</code>.</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>For UDP services: destination port, not source port
  </p>
  <div class="prose-kitchen text-sm">For UDP services you <strong>offer yourself</strong> (VPN, TURN, your own DNS server), the port is in the
<strong>destination port (Dst)</strong> – because here clients connect <em>to your server</em>. That&rsquo;s the
<strong>opposite</strong> of the DNS/NTP rules from the base template: there the port is in the <strong>source
port (Src)</strong>, because your server is the <em>querying client</em> whose reply comes back. TCP
replies aren&rsquo;t affected by this (the firewall state only matters for UDP); outbound is
allowed anyway.</div>
</div>
<h4 id="mail-server-mailcow--co">Mail server (mailcow &amp; co.)</h4>
<table>
	<thead>
			<tr>
					<th>Description</th>
					<th>Protocol</th>
					<th>Source port (Src)</th>
					<th>Destination port (Dst)</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>SMTP (mail acceptance from others)</td>
					<td>TCP</td>
					<td>–</td>
					<td><strong>25</strong></td>
			</tr>
			<tr>
					<td>Submission (STARTTLS)</td>
					<td>TCP</td>
					<td>–</td>
					<td><strong>587</strong></td>
			</tr>
			<tr>
					<td>Submission (implicit TLS)</td>
					<td>TCP</td>
					<td>–</td>
					<td><strong>465</strong></td>
			</tr>
			<tr>
					<td>IMAP (STARTTLS)</td>
					<td>TCP</td>
					<td>–</td>
					<td><strong>143</strong></td>
			</tr>
			<tr>
					<td>IMAPS</td>
					<td>TCP</td>
					<td>–</td>
					<td><strong>993</strong></td>
			</tr>
			<tr>
					<td>POP3 (STARTTLS)</td>
					<td>TCP</td>
					<td>–</td>
					<td><strong>110</strong></td>
			</tr>
			<tr>
					<td>POP3S</td>
					<td>TCP</td>
					<td>–</td>
					<td><strong>995</strong></td>
			</tr>
			<tr>
					<td>ManageSieve (filter rules)</td>
					<td>TCP</td>
					<td>–</td>
					<td><strong>4190</strong></td>
			</tr>
	</tbody>
</table>
<p>Two things that <strong>additionally</strong> belong to a mail server:</p>
<ul>
<li><strong>Web UI and certificates:</strong> mailcow needs <code>80</code>/<code>443</code> for the web interface and the
Let&rsquo;s Encrypt retrieval – just assign the <code>Serverküche Web</code> template along with it.</li>
<li><strong>Enable outbound SMTP:</strong> a mail server must be able to deliver on <strong>port 25 outbound</strong>.
netcup blocks that with the default policy &ldquo;netcup Mail block&rdquo; – you have to <strong>remove</strong> it
on the server (see &ldquo;When things go wrong&rdquo;). And without a correct
<a href="/en/tutorials/connect-domain-to-server/">PTR/reverse-DNS entry</a> your mails land in spam.</li>
</ul>
<h4 id="wireguard-vpn">WireGuard VPN</h4>
<table>
	<thead>
			<tr>
					<th>Description</th>
					<th>Protocol</th>
					<th>Source port (Src)</th>
					<th>Destination port (Dst)</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>WireGuard</td>
					<td>UDP</td>
					<td>–</td>
					<td><strong>51820</strong></td>
			</tr>
	</tbody>
</table>
<p><code>51820</code> is the usual default – use the value from your <code>ListenPort</code>. Only this one inbound
rule is needed; the replies to the clients go out outbound.</p>
<h4 id="openvpn">OpenVPN</h4>
<table>
	<thead>
			<tr>
					<th>Description</th>
					<th>Protocol</th>
					<th>Source port (Src)</th>
					<th>Destination port (Dst)</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>OpenVPN (UDP, default)</td>
					<td>UDP</td>
					<td>–</td>
					<td><strong>1194</strong></td>
			</tr>
			<tr>
					<td>OpenVPN (TCP fallback)</td>
					<td>TCP</td>
					<td>–</td>
					<td><strong>1194</strong></td>
			</tr>
	</tbody>
</table>
<p>The default is <strong>UDP 1194</strong>. The TCP rule only if you deliberately run OpenVPN over TCP
(some additionally put it on <code>TCP 443</code> to get through restrictive foreign networks) –
otherwise leave it out.</p>
<h4 id="zabbix-monitoring">Zabbix monitoring</h4>
<p>Here it depends on the server&rsquo;s role:</p>
<table>
	<thead>
			<tr>
					<th>Role</th>
					<th>Description</th>
					<th>Protocol</th>
					<th>Destination port (Dst)</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><strong>Monitored host</strong> (Zabbix agent)</td>
					<td>passive checks from the server</td>
					<td>TCP</td>
					<td><strong>10050</strong></td>
			</tr>
			<tr>
					<td><strong>Zabbix server</strong></td>
					<td>trapper (active agents/proxies)</td>
					<td>TCP</td>
					<td><strong>10051</strong></td>
			</tr>
	</tbody>
</table>
<p>The Zabbix server additionally needs <code>80</code>/<code>443</code> for the web frontend (<code>Serverküche Web</code>). If
you run <strong>active</strong> checks, the agent connects outbound to the server on <code>10051</code> – for that,
<strong>no</strong> inbound rule is needed on the agent host.</p>
<h4 id="checkmk">Checkmk</h4>
<table>
	<thead>
			<tr>
					<th>Role</th>
					<th>Description</th>
					<th>Protocol</th>
					<th>Destination port (Dst)</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><strong>Monitored host</strong> (agent)</td>
					<td>agent controller, pull mode</td>
					<td>TCP</td>
					<td><strong>6556</strong></td>
			</tr>
			<tr>
					<td><strong>Checkmk server</strong> (optional)</td>
					<td>agent receiver (push/registration)</td>
					<td>TCP</td>
					<td><strong>8000</strong></td>
			</tr>
			<tr>
					<td><strong>Checkmk server</strong> (optional)</td>
					<td>Livestatus (distributed monitoring)</td>
					<td>TCP</td>
					<td><strong>6557</strong></td>
			</tr>
	</tbody>
</table>
<p>In the <strong>default pull mode</strong>, the Checkmk server fetches the data actively – so it connects
outbound to <code>6556</code> of the agents. Inbound, therefore, only the <strong>monitored host</strong> needs port
<code>6556</code>. The Checkmk server itself gets by with <code>80</code>/<code>443</code> (<code>Serverküche Web</code>); <code>8000</code> and
<code>6557</code> only if you use push mode or distributed monitoring.</p>
<h4 id="coturn--turn-server-nextcloud-talk-matrix">Coturn / TURN server (Nextcloud Talk, Matrix)</h4>
<table>
	<thead>
			<tr>
					<th>Description</th>
					<th>Protocol</th>
					<th>Source port (Src)</th>
					<th>Destination port (Dst)</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>STUN/TURN</td>
					<td>TCP</td>
					<td>–</td>
					<td><strong>3478</strong></td>
			</tr>
			<tr>
					<td>STUN/TURN</td>
					<td>UDP</td>
					<td>–</td>
					<td><strong>3478</strong></td>
			</tr>
			<tr>
					<td>STUN/TURN over TLS</td>
					<td>TCP</td>
					<td>–</td>
					<td><strong>5349</strong></td>
			</tr>
			<tr>
					<td>STUN/TURN over TLS</td>
					<td>UDP</td>
					<td>–</td>
					<td><strong>5349</strong></td>
			</tr>
			<tr>
					<td>Media relay (audio/video)</td>
					<td>UDP</td>
					<td>–</td>
					<td><strong>49152–65535</strong></td>
			</tr>
	</tbody>
</table>
<p>The large UDP relay range is the default – the actual audio/video streams run over it. You
can narrow it in the coturn configuration (<code>min-port</code>/<code>max-port</code>) and then set the firewall
rule to the same, smaller range.</p>
<h4 id="your-own-dns-server-adguard-home-pi-hole-unbound">Your own DNS server (AdGuard Home, Pi-hole, Unbound)</h4>
<table>
	<thead>
			<tr>
					<th>Description</th>
					<th>Protocol</th>
					<th>Source port (Src)</th>
					<th>Destination port (Dst)</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>DNS queries</td>
					<td>UDP</td>
					<td>–</td>
					<td><strong>53</strong></td>
			</tr>
			<tr>
					<td>DNS queries (large replies/TCP)</td>
					<td>TCP</td>
					<td>–</td>
					<td><strong>53</strong></td>
			</tr>
			<tr>
					<td>DNS-over-TLS (DoT)</td>
					<td>TCP</td>
					<td>–</td>
					<td><strong>853</strong></td>
			</tr>
	</tbody>
</table>
<p>Careful, this is exactly the <strong>opposite direction</strong> to the base template: there <code>Src 53</code>
allows the <em>replies</em> to your own DNS queries; here <code>Dst 53</code> allows the <em>queries of foreign
clients</em> to your DNS server. Both exist side by side without problems.</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>No open resolver
  </p>
  <div class="prose-kitchen text-sm">A globally open recursive DNS server is abused for <strong>DNS amplification attacks</strong>. Restrict
access to your own networks (set the source address in the rule) or offer DNS exclusively
encrypted (DoT/DoH) for registered clients – never open <code>53</code> &ldquo;just quickly&rdquo; for the whole
world.</div>
</div>
<h4 id="remote-database-access-emergency-only">Remote database access (emergency only)</h4>
<table>
	<thead>
			<tr>
					<th>Description</th>
					<th>Protocol</th>
					<th>Source port (Src)</th>
					<th>Destination port (Dst)</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>PostgreSQL</td>
					<td>TCP</td>
					<td>–</td>
					<td><strong>5432</strong></td>
			</tr>
			<tr>
					<td>MySQL / MariaDB</td>
					<td>TCP</td>
					<td>–</td>
					<td><strong>3306</strong></td>
			</tr>
	</tbody>
</table>
<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>Databases don&#39;t belong on the open internet
  </p>
  <div class="prose-kitchen text-sm">A directly reachable database is a preferred attack target. The <strong>right</strong> way is to <strong>not</strong>
open the port at all and instead access it via a <a href="/en/#wireguard-vpn">WireGuard tunnel</a> or an
SSH tunnel (<code>ssh -L 5432:localhost:5432 …</code>). If you must open the port anyway, then <strong>only</strong>
with a source address restricted to your fixed admin IP in the netcup rule – never for
everyone.</div>
</div>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong>After activating, name resolution stops working (<code>apt update</code> hangs, <code>ping domain.de</code>
fails, but <code>ping 1.1.1.1</code> works).</strong> The DNS <strong>replies</strong> are being blocked. Check the rule in
the base template <em>INBOUND UDP ACCEPT, Src port 53</em>. Important: <strong>source</strong> port, not
destination port.</p>
<p><strong>The clock drifts, or TLS certificates are rejected due to the wrong time.</strong> The NTP
replies are missing. Add <em>INBOUND UDP ACCEPT, Src port 123</em>.</p>
<p><strong>IPv6 no longer works (v4 does).</strong> The <strong>ICMPv6</strong> rule is missing. Without Neighbor
Discovery, the server can&rsquo;t even find its neighbor (router) over IPv6.</p>
<p><strong>After activating, you can no longer get in via SSH.</strong> The SSH rule is missing or names the
wrong port. Via the <strong>VNC console</strong> (SCP → Display) you can still reach the server; correct
the policy and save again. That&rsquo;s exactly what the &ldquo;keep a second session open&rdquo; rule above
helps against.</p>
<p><strong>The server can&rsquo;t send emails (outbound port 25 doesn&rsquo;t work).</strong> That&rsquo;s <strong>not</strong> an error in
your policy, but netcup&rsquo;s <strong>default policy &ldquo;netcup Mail block&rdquo;</strong> – it blocks outbound SMTP
(ports 25/465/587) as spam protection. For real mail sending you have to disable this netcup
template on the server.</p>
<p><strong>The server initially runs normally and is suddenly offline after one or two weeks.</strong> If
the server uses DHCP, the <strong>lease renewal</strong> is blocked by the whitelist – the DHCP reply
comes from UDP source port <strong>67</strong>, which no rule allows, and UDP is stateless. Add <code>INBOUND · UDP · ACCEPT · Src 67</code> to the base template. Statically configured netcup VPS (the
default) aren&rsquo;t affected.</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<ul>
<li><strong>Test access after every change.</strong> Firewall rules are the classic way to lock yourself
out. Keep a second session open, check a new connection, only then finish.</li>
<li><strong>New services = new template.</strong> If you later need another port (game server, WireGuard
VPN over UDP, database), create a small template of your own and assign it additionally.
The existing building blocks stay untouched.</li>
<li><strong>Mind the order.</strong> netcup evaluates the rules <strong>top to bottom</strong>; the <strong>first</strong> matching
rule wins. For pure ACCEPT rules that doesn&rsquo;t matter – but as soon as you use your own DROP
rules, watch the order relative to the ACCEPTs.</li>
<li><strong>Know netcup&rsquo;s default policies.</strong> &ldquo;netcup Mail block&rdquo; (outbound SMTP on ports 25/465/587
blocked) and &ldquo;netcup Ping allow&rdquo; are predefined. Via <strong>Restore default policies</strong> you can,
in an emergency, get back to a known, working baseline if you locked yourself out with your
own rules – together with the VNC console, your second safety net.</li>
</ul>
]]></content:encoded></item></channel></rss>