<?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/categories/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/categories/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>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>unattended-upgrades: automatic security updates for Debian</title><link>https://serverkueche.de/en/tutorials/unattended-upgrades-automatic-updates/</link><pubDate>Sat, 18 Jul 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/unattended-upgrades-automatic-updates/</guid><description>Debian installs security updates automatically: set up unattended-upgrades, control the reboot, and verify it really runs.</description><content:encoded><![CDATA[<p>&ldquo;Remember to install updates weekly&rdquo; – this sentence from the maintenance sections of
the other tutorials is the first one people forget. That&rsquo;s exactly why the server now
takes it over itself: <strong>unattended-upgrades</strong> installs security updates automatically.
That closes the most dangerous gap in self-hosting – the server that runs unpatched for
months.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>By the end, your <strong>Debian 13</strong> automatically pulls <strong>security updates</strong> daily and
installs them without your involvement. You decide whether and when the server reboots
for necessary kernel updates, and you know how to verify that the automation really
kicks in. What gets automated are security updates and the conservative stable point
releases; larger upgrades of your other software stay deliberately in your hands.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>A <a href="/en/tutorials/harden-ssh/">hardened server</a> with an
<a href="/en/tutorials/firewall-ufw-setup/">active firewall</a></li>
</ul>
<h2 id="step-by-step">Step by step</h2>
<h3 id="step-1-install-the-package">Step 1: Install the package</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 unattended-upgrades apt-listchanges</span></span></code></pre></div>
</div>
<p><code>apt-listchanges</code> shows the changelogs on updates – useful if you later do check
manually what changed.</p>
<h3 id="step-2-enable-the-automation">Step 2: Enable the automation</h3>
<p>The simplest way to switch on the daily run:</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 dpkg-reconfigure -plow unattended-upgrades</span></span></code></pre></div>
</div>
<p>Choose <strong>Yes</strong> in the dialog. That creates the file
<code>/etc/apt/apt.conf.d/20auto-upgrades</code> with this content:</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">APT::Periodic::Update-Package-Lists &#34;1&#34;;</span>
</span></span><span class="line"><span class="cl"><span class="na">APT::Periodic::Unattended-Upgrade &#34;1&#34;;</span></span></span></code></pre></div>
</div>
<p>The <code>1</code> means &ldquo;daily&rdquo;: update package lists <strong>and</strong> run unattended upgrades. It&rsquo;s
triggered via the systemd timers <code>apt-daily.timer</code> (package lists) and
<code>apt-daily-upgrade.timer</code> (upgrade run) – no separate cron job needed.</p>
<h3 id="step-3-define-what-gets-updated">Step 3: Define what gets updated</h3>
<p>Take a look at <code>/etc/apt/apt.conf.d/50unattended-upgrades</code>:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo nano /etc/apt/apt.conf.d/50unattended-upgrades</span></span></code></pre></div>
</div>
<p>On Debian 13, <strong>three</strong> sources are active in the <code>Origins-Pattern</code> block by default:</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">Unattended-Upgrade::Origins-Pattern {</span>
</span></span><span class="line"><span class="cl">  <span class="na">&#34;origin</span><span class="o">=</span><span class="s">Debian,codename=${distro_codename},label=Debian&#34;;
</span></span></span><span class="line"><span class="cl"><span class="s">  &#34;origin=Debian,codename=${distro_codename},label=Debian-Security&#34;;
</span></span></span><span class="line"><span class="cl"><span class="s">  &#34;origin=Debian,codename=${distro_codename}-security,label=Debian-Security&#34;;</span>
</span></span><span class="line"><span class="cl"><span class="na">};</span></span></span></code></pre></div>
</div>
<p>The two <code>Debian-Security</code> lines ensure <strong>timely security updates</strong> – the actual
purpose. The first line (<code>label=Debian</code>) covers the <strong>conservative stable updates</strong>
that only arrive with Debian point releases; so it&rsquo;s not a risky rolling update, but
well-seasoned. If you really want <strong>only</strong> security updates, comment out the first line
with <code>//</code>.</p>
<p>Two options are worth setting explicitly here. <strong>Clean up old kernels/packages
automatically</strong>, otherwise <code>/boot</code> eventually fills up (<code>Remove-Unused-Kernel-Packages</code>
is already the default in code, but only appears commented out in the file – set
explicitly, the decision is documented):</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">Unattended-Upgrade::Remove-Unused-Kernel-Packages &#34;true&#34;;</span>
</span></span><span class="line"><span class="cl"><span class="na">Unattended-Upgrade::Remove-Unused-Dependencies &#34;true&#34;;</span></span></span></code></pre></div>
</div>
<p>And the most important point – the <strong>automatic reboot</strong>:</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">Unattended-Upgrade::Automatic-Reboot &#34;true&#34;;</span>
</span></span><span class="line"><span class="cl"><span class="na">Unattended-Upgrade::Automatic-Reboot-Time &#34;04:00&#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>Automatic reboot – decide deliberately
  </p>
  <div class="prose-kitchen text-sm">Some security updates (especially kernel) only take effect after a reboot. With
<code>Automatic-Reboot &quot;true&quot;</code> the server then reboots on its own – <strong>always set an
<code>Automatic-Reboot-Time</code></strong> when you do: without it, the server reboots <strong>immediately</strong>
after the upgrade run (the code default is <code>&quot;now&quot;</code>; the <code>02:00</code> in the example file is
only a commented-out suggestion). Choose a time with low usage. If you want <strong>no</strong>
automatic reboots, leave the option at <code>&quot;false&quot;</code> and reboot yourself when
<code>/var/run/reboot-required</code> exists. Both are defensible – it just has to be a deliberate
decision.</div>
</div>
<p>For the automatic reboot to be able to trigger on Debian at all, one building block is
still missing: unattended-upgrades only reboots if the marker file
<code>/var/run/reboot-required</code> exists – and Debian 13 does <strong>not</strong> create it on kernel
updates by default (the responsible hook comes from an Ubuntu helper package that
doesn&rsquo;t exist in Debian; only individual packages like <code>dbus</code> set the file themselves).
A two-liner closes the gap:</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 tee /etc/kernel/postinst.d/zz-reboot-required &gt; /dev/null <span class="s">&lt;&lt;&#39;EOF&#39;
</span></span></span><span class="line"><span class="cl"><span class="s">#!/bin/sh
</span></span></span><span class="line"><span class="cl"><span class="s">touch /var/run/reboot-required
</span></span></span><span class="line"><span class="cl"><span class="s">EOF</span>
</span></span><span class="line"><span class="cl">sudo chmod +x /etc/kernel/postinst.d/zz-reboot-required</span></span></code></pre></div>
</div>
<p>The hook runs after every kernel installation and sets the marker file. From now on,
both <code>Automatic-Reboot</code> and the manual check of <code>/var/run/reboot-required</code> work
reliably – without the hook, the automatic reboot would simply never trigger on kernel
updates.</p>
<h3 id="step-4-do-a-dry-run">Step 4: Do a dry run</h3>
<p>Test what unattended-upgrades would do, without a real installation:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo unattended-upgrade --dry-run --debug</span></span></code></pre></div>
</div>
<p>In the debug output, this line is decisive (on a fresh Debian 13, shortened):</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">Allowed origins are: origin=Debian,codename=trixie,label=Debian, origin=Debian,codename=trixie,label=Debian-Security, origin=Debian,codename=trixie-security,label=Debian-Security
</span></span><span class="line"><span class="cl">[...]
</span></span><span class="line"><span class="cl">No packages found that can be upgraded unattended and no pending auto-removals</span></span></code></pre></div>
</div>
<p>The Debian-Security sources must appear there. If something is listed, the
configuration applies; if the run finds nothing to do (as above), there&rsquo;s simply no
security update pending right now.</p>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong>The dry run reports <code>No packages found that can be upgraded unattended</code>.</strong> Usually
perfectly normal – no security update is currently pending. Check the configuration
anyway via the <code>Allowed origins are:</code> line in the <code>--debug</code> run. If no security origins
appear there, the <code>Origins-Pattern</code> from step 3 isn&rsquo;t right.</p>
<p><strong>Updates come, but the server never reboots despite a kernel update.</strong> Either
<code>Automatic-Reboot</code> is set to <code>&quot;false&quot;</code> (default) – then set it to <code>&quot;true&quot;</code> (step 3) –
or the marker file is never set because the kernel hook
<code>/etc/kernel/postinst.d/zz-reboot-required</code> from step 3 is missing. Without it, Debian
doesn&rsquo;t create <code>/var/run/reboot-required</code> on kernel updates, and the automatic reboot
never triggers.</p>
<p><strong><code>/boot</code> fills up, updates fail.</strong> Old kernels pile up. Set
<code>Remove-Unused-Kernel-Packages &quot;true&quot;</code> (step 3); clean up once with <code>sudo apt autoremove --purge</code>.</p>
<p><strong>A package is stubbornly held back (<code>kept back</code>).</strong> unattended-upgrades doesn&rsquo;t
install updates that would remove other packages. You resolve such cases deliberately
by hand with <code>sudo apt upgrade</code> and check what happens.</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<ul>
<li><strong>Still check anyway:</strong> automation doesn&rsquo;t replace attention. Take a monthly look at
the log <code>/var/log/unattended-upgrades/unattended-upgrades.log</code> and occasionally run
<code>sudo apt update &amp;&amp; sudo apt upgrade</code> for the <strong>non</strong>-security-critical updates.</li>
<li><strong>Plan for reboots:</strong> if you use automatic reboots, make sure your services survive a
reboot cleanly (<code>restart: unless-stopped</code> in <a href="/en/tutorials/docker-compose-basics/">Docker
Compose</a> if you run containers).</li>
<li><strong>No dedicated backup needed</strong>, but note whether you enabled automatic reboots – it
later explains why the server was briefly gone at night.</li>
</ul>
]]></content:encoded></item><item><title>Setting up a firewall with UFW</title><link>https://serverkueche.de/en/tutorials/firewall-ufw-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/firewall-ufw-setup/</guid><description>Set up a host firewall in minutes with UFW: block everything except SSH, HTTP and HTTPS on your server – without ever locking yourself out.</description><content:encoded><![CDATA[<p>By default, your server accepts connections on all open ports. A <strong>firewall</strong>
turns that around: it blocks everything and only lets through what you explicitly
allow. <strong>UFW</strong> (&ldquo;Uncomplicated Firewall&rdquo;) does this with a few, readable commands –
the perfect first line of defense.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>By the end, <strong>UFW</strong> runs on your <strong>Debian 13</strong> with the base rule &ldquo;<strong>block all
incoming</strong>&rdquo;, with only <strong>SSH</strong> (your access) plus <strong>HTTP/HTTPS</strong> (ports 80/443,
which you&rsquo;ll need later for Traefik) open. Outgoing connections stay allowed. The
key part: we proceed in a way that ensures you <strong>don&rsquo;t lock yourself out</strong>.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>A <a href="/en/tutorials/harden-ssh/">hardened server</a> with a sudo user</li>
<li>You know which <strong>port your SSH</strong> runs on (default: 22)</li>
</ul>
<h2 id="step-by-step">Step by step</h2>
<h3 id="step-1-install-ufw">Step 1: Install UFW</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 ufw</span></span></code></pre></div>
</div>
<p>Check the version – UFW is <strong>inactive</strong> at first, which is intended:</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">ufw version</span></span></code></pre></div>
</div>
<h3 id="step-2-define-the-base-rules">Step 2: Define the base rules</h3>
<p>First the default direction: deny everything incoming, allow everything outgoing.</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 ufw default deny incoming
</span></span><span class="line"><span class="cl">sudo ufw default allow outgoing</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">Default incoming policy changed to &#39;deny&#39;
</span></span><span class="line"><span class="cl">(be sure to update your rules accordingly)
</span></span><span class="line"><span class="cl">Default outgoing policy changed to &#39;allow&#39;
</span></span><span class="line"><span class="cl">(be sure to update your rules accordingly)</span></span></code></pre></div>
</div>
<p>These rules don&rsquo;t take effect yet – UFW is still inactive. So the exceptions come
now, <strong>before</strong> we switch it on.</p>
<h3 id="step-3-allow-ssh--first-or-youll-lock-yourself-out">Step 3: Allow SSH – first, or you&rsquo;ll lock yourself out</h3>
<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>Allow SSH first, then activate
  </p>
  <div class="prose-kitchen text-sm">If you enable UFW with the &ldquo;deny incoming&rdquo; rule <strong>without</strong> having allowed SSH
first, the next command cuts your own connection – and you can no longer get in via
SSH. This order is non-negotiable.</div>
</div>
<p>If your SSH runs on the default port 22 (and <code>openssh-server</code> is installed), there&rsquo;s
a ready-made profile for 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 ufw allow OpenSSH</span></span></code></pre></div>
</div>
<p>If you changed the SSH port (e.g. to 2222), allow exactly that port 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">sudo ufw allow 2222/tcp</span></span></code></pre></div>
</div>
<h3 id="step-4-allow-http-and-https">Step 4: Allow HTTP and HTTPS</h3>
<p>For the later <a href="/en/tutorials/traefik-reverse-proxy/">reverse proxy with Traefik</a> you
need the web ports. Open them right away:</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 ufw allow 80/tcp
</span></span><span class="line"><span class="cl">sudo ufw allow 443/tcp</span></span></code></pre></div>
</div>
<p>UFW confirms each rule with <code>Rules updated</code> and <code>Rules updated (v6)</code> (the IPv6
variant).</p>
<h3 id="step-5-activate-the-firewall-and-check-it">Step 5: Activate the firewall and check it</h3>
<p>Now switch it on:</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 ufw enable</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">Command may disrupt existing ssh connections. Proceed with operation (y|n)? y
</span></span><span class="line"><span class="cl">Firewall is active and enabled on system startup</span></span></code></pre></div>
</div>
<p>Check the result:</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 ufw status verbose</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: active
</span></span><span class="line"><span class="cl">Logging: on (low)
</span></span><span class="line"><span class="cl">Default: deny (incoming), allow (outgoing), disabled (routed)
</span></span><span class="line"><span class="cl">New profiles: skip
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">To                         Action      From
</span></span><span class="line"><span class="cl">--                         ------      ----
</span></span><span class="line"><span class="cl">22/tcp (OpenSSH)           ALLOW IN    Anywhere
</span></span><span class="line"><span class="cl">80/tcp                     ALLOW IN    Anywhere
</span></span><span class="line"><span class="cl">443/tcp                    ALLOW IN    Anywhere
</span></span><span class="line"><span class="cl">22/tcp (OpenSSH (v6))      ALLOW IN    Anywhere (v6)
</span></span><span class="line"><span class="cl">80/tcp (v6)                ALLOW IN    Anywhere (v6)
</span></span><span class="line"><span class="cl">443/tcp (v6)               ALLOW IN    Anywhere (v6)</span></span></code></pre></div>
</div>
<p><code>Status: active</code> and your three allowances – done. <strong>To be safe, test in a second
SSH session</strong> that you can still connect before closing the first one.</p>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong>After <code>ufw enable</code> you can no longer get in via SSH.</strong> The SSH rule was missing or
targeted the wrong port. Connect via the <strong>console in the netcup SCP</strong> (VNC,
independent of SSH), allow your SSH port there (<code>sudo ufw allow …</code>) and test again.
That&rsquo;s exactly what step 3 warns about.</p>
<p><strong><code>ERROR: Could not find a profile matching 'OpenSSH'</code>.</strong> The OpenSSH profile only
exists if <code>openssh-server</code> is installed. Use the port number instead: <code>sudo ufw allow 22/tcp</code> (or your port). <code>sudo ufw app list</code> shows the available profiles.</p>
<p><strong>A Docker container is reachable from outside even though UFW doesn&rsquo;t open the
port.</strong> Not your mistake – <strong>Docker bypasses UFW</strong>. Docker writes its rules directly
into <code>iptables</code> and inserts them ahead of the UFW chains. A published container port
(<code>ports:</code> in the compose.yaml) is thus open, no matter what UFW says. The clean
solution is a second firewall layer <strong>in front of</strong> the server – at netcup the
<a href="/en/tutorials/netcup-firewall-setup/">firewall in the SCP</a> as an upstream
perimeter. On the host it also helps to bind container ports only to <code>127.0.0.1</code>
instead of <code>0.0.0.0</code>.</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<ul>
<li><strong>View and delete rules:</strong> <code>sudo ufw status numbered</code> numbers all rules;
<code>sudo ufw delete 3</code> removes rule 3. That&rsquo;s how you tidy up when a service goes
away.</li>
<li><strong>New services:</strong> For every additional public port, one targeted rule – never
&ldquo;just open everything&rdquo;. What doesn&rsquo;t need to be open stays closed.</li>
<li><strong>No backup needed,</strong> but note down your allowances (or keep them in the same
document as your DNS records). The rule set is retyped in seconds.</li>
<li><strong>Know the limits:</strong> UFW protects the host, but not against the Docker gap above.
An upstream perimeter firewall like the
<a href="/en/tutorials/netcup-firewall-setup/">netcup firewall in the SCP</a> complements UFW
into two layers that secure each other – ideal once containers with open ports are
running.</li>
</ul>
]]></content:encoded></item><item><title>Hardening SSH access</title><link>https://serverkueche.de/en/tutorials/harden-ssh/</link><pubDate>Tue, 14 Jul 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/harden-ssh/</guid><description>Key-based login instead of passwords and no root login: the most important moves to harden the front door to your server.</description><content:encoded><![CDATA[<p>After the <a href="/en/tutorials/first-steps-netcup-vps/">initial setup</a> we harden the SSH
access – the most important door to your server.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>By the end you log in with an <strong>SSH key</strong> instead of a password, and neither root
login nor password login is possible from outside. This makes the automated
password-guessing attacks that hit every publicly reachable server around the
clock run completely into the void. Tested with <strong>OpenSSH on Debian 13</strong>.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>A <a href="/en/tutorials/first-steps-netcup-vps/">set-up server</a> with a sudo user</li>
<li>Access to the VNC console in the netcup SCP as a safety line in case you lock yourself out</li>
</ul>
<h2 id="step-by-step">Step by step</h2>
<h3 id="step-1-generate-an-ssh-key-pair">Step 1: Generate an SSH key pair</h3>
<p>If you don&rsquo;t have a key yet, generate an Ed25519 key pair <strong>on your own machine</strong>
(not on the server!):</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">ssh-keygen -t ed25519</span></span></code></pre></div>
</div>
<p>You can accept the suggested storage location with Enter. Set a <strong>passphrase</strong> –
it protects the key if your machine falls into the wrong hands. Two files are
created: <code>~/.ssh/id_ed25519</code> (private, stays with you) and
<code>~/.ssh/id_ed25519.pub</code> (public, goes on the server).</p>
<h3 id="step-2-transfer-the-public-key">Step 2: Transfer the public key</h3>
<p><code>ssh-copy-id</code> appends your public key to the <code>~/.ssh/authorized_keys</code> file of your
server user – with the correct file permissions:</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">ssh-copy-id koch@YOUR_SERVER_IP</span></span></code></pre></div>
</div>
<p>Test right afterwards that key login works:</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">ssh koch@YOUR_SERVER_IP</span></span></code></pre></div>
</div>
<p>You should now be logged in <strong>without</strong> a server password (only the passphrase of
your key may be requested). Only once that works do we continue – in the next step
we disable password login.</p>
<h3 id="step-3-disable-password-login-and-root-login">Step 3: Disable password login and root login</h3>
<p>Open the SSH server configuration:</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/ssh/sshd_config</span></span></code></pre></div>
</div>
<p>Set (or uncomment) these two lines:</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">PermitRootLogin no
</span></span><span class="line"><span class="cl">PasswordAuthentication no</span></span></code></pre></div>
</div>
<p><code>PermitRootLogin no</code> blocks direct root login completely,
<code>PasswordAuthentication no</code> allows only key logins.</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>Warning
  </p>
  <div class="prose-kitchen text-sm">On cloud images there are often extra files under <code>/etc/ssh/sshd_config.d/</code> that
<strong>override</strong> these settings. Check with
<code>grep -r PasswordAuthentication /etc/ssh/sshd_config.d/</code> and adjust any matches as
well.</div>
</div>
<h3 id="step-4-test-the-configuration-and-restart-ssh">Step 4: Test the configuration and restart SSH</h3>
<p>Check the configuration for syntax errors <strong>before</strong> you restart – a typo would
otherwise take the SSH service down:</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 sshd -t</span></span></code></pre></div>
</div>
<p>No output means: all good. Then apply 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 restart ssh</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>Caution
  </p>
  <div class="prose-kitchen text-sm">Test key login now in a <strong>second</strong> terminal session before you close the first –
otherwise you might lock yourself out.</div>
</div>
<p>To verify: <code>sudo systemctl status ssh</code> should show <code>active (running)</code>, and a login
attempt as root must now be rejected with <code>Permission denied (publickey)</code>.</p>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong>SSH keeps asking for the password despite <code>ssh-copy-id</code>.</strong> Usually the file
permissions on the server are wrong. SSH ignores <code>authorized_keys</code> if the
permissions are too open: <code>chmod 700 ~/.ssh &amp;&amp; chmod 600 ~/.ssh/authorized_keys</code>.
Also check that you&rsquo;re connecting with the right user (<code>koch@…</code>, not <code>root@…</code>).</p>
<p><strong><code>Permission denied (publickey)</code> – and you can&rsquo;t get in at all anymore.</strong>
Password login was disabled before the key worked. No drama: open the <strong>VNC
console in the netcup SCP</strong>, log in locally there, set <code>PasswordAuthentication yes</code>, restart SSH and start again at step 2.</p>
<p><strong>After the restart the SSH service no longer starts.</strong> Syntax error in the
<code>sshd_config</code> (that&rsquo;s why you always run <code>sshd -t</code> before restarting). Log in via
the VNC console; <code>sudo sshd -t</code> shows you the file and line number of the error.</p>
<p><strong>The settings seem to take effect, but password login still works.</strong> A file under
<code>/etc/ssh/sshd_config.d/</code> (often <code>50-cloud-init.conf</code>) overrides your values.
<code>sudo sshd -T | grep -i passwordauthentication</code> shows the actually effective
setting – adjust matches in the extra files and restart SSH.</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<ul>
<li><strong>Back up the private key:</strong> Without <code>~/.ssh/id_ed25519</code> you can only reach the
server via the VNC console. Back up the key encrypted (e.g. in a password
manager) or add a second key from another device to <code>authorized_keys</code>.</li>
<li><strong>Keep an eye on logins:</strong> <code>sudo journalctl -u ssh --since today</code> shows you all
login attempts. Failed attempts on port 22 are normal and, thanks to the
key requirement, harmless – you can automatically ban such bots later with
<a href="/en/tutorials/fail2ban-setup/">Fail2ban</a>.</li>
<li><strong>Updates:</strong> OpenSSH gets its security updates through the normal <code>apt upgrade</code> –
so keep the server up to date, ideally
<a href="/en/tutorials/unattended-upgrades-automatic-updates/">automated</a>.</li>
</ul>
]]></content:encoded></item></channel></rss>