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

</div>

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

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<ul>
<li><strong>Updates:</strong> Docker updates along with the normal
<code>sudo apt update &amp;&amp; sudo apt upgrade</code> – one reason we use the official
repository. When the Engine is updated, running containers restart briefly (or
stop until you start them again) – plan for that.</li>
<li><strong>Cleanup:</strong> Unused images and build leftovers pile up quickly.
<code>docker system df</code> shows the usage, <code>docker system prune</code> cleans up
(<strong>careful:</strong> it also removes stopped containers).</li>
<li><strong>Backups:</strong> The actual data of your applications will later live in volumes or
bind mounts – we build the backup strategy for that with
<a href="/en/tutorials/restic-backups/">encrypted Restic backups</a> and in the individual
application tutorials.</li>
</ul>
]]></content:encoded></item><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><item><title>First steps with a netcup VPS</title><link>https://serverkueche.de/en/tutorials/first-steps-netcup-vps/</link><pubDate>Mon, 13 Jul 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/first-steps-netcup-vps/</guid><description>From a freshly ordered netcup server to a ready-to-use system: SSH login, system updates, a sudo user and the most important first steps.</description><content:encoded><![CDATA[<p>You&rsquo;ve ordered your first netcup server – congratulations! Before we fire up the
kitchen, let&rsquo;s set the system up cleanly.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>By the end of this tutorial, your VPS runs a current <strong>Debian 13</strong> with all
security updates, and you work with your own user with sudo rights instead of
permanently as <code>root</code>. That&rsquo;s the foundation for all the other recipes in the
Serverküche – from hardening SSH to your first application.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-herb-400 bg-herb-50 dark:border-herb-700 dark:bg-herb-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🧑‍🍳</span>Tip
  </p>
  <div class="prose-kitchen text-sm">All commands are written for <strong>Debian 13</strong>. On Ubuntu they work almost
identically.</div>
</div>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>An ordered netcup server with the credentials from your customer account</li>
<li>A terminal on your machine (Linux/macOS) or an SSH client like PuTTY (Windows)</li>
</ul>
<p>Not sure which server size you even need? The
<a href="/en/server-calculator/">server calculator</a> estimates the RAM and CPU you&rsquo;ll need for your
planned services and suggests a matching netcup plan.</p>
<div class="not-prose my-6 overflow-hidden rounded-xl border border-paprika-200 bg-paprika-50 dark:border-paprika-800 dark:bg-paprika-900/20">
  <div class="flex items-center justify-between border-b border-paprika-200 bg-paprika-100 px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-paprika-700 dark:border-paprika-800 dark:bg-paprika-900/40 dark:text-paprika-300">
    <span>🍳 Recommendation</span>
    <span title="Links marked with * are affiliate links.">Ad</span>
  </div>
  <div class="flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between">
    <div>
      <p class="text-lg font-bold text-slate-900 dark:text-white">VPS 1000 G12</p>
      <p class="mt-1 text-sm text-slate-600 dark:text-slate-300">4 vCores · 8 GB RAM · 256 GB NVMe</p>
      <p class="mt-1 text-sm font-semibold text-paprika-700 dark:text-paprika-400">from €10.36/month</p>
      <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">A good start for most self-hosting projects.</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-log-in-via-ssh">Step 1: Log in via SSH</h3>
<p>Log in with your server&rsquo;s IP address as <code>root</code>. You&rsquo;ll find the IP and the
password in the credentials from your customer account:</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 root@YOUR_SERVER_IP</span></span></code></pre></div>
</div>
<p>On the very first login, SSH asks whether you want to trust the server:</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">The authenticity of host &#39;YOUR_SERVER_IP&#39; can&#39;t be established.
</span></span><span class="line"><span class="cl">...
</span></span><span class="line"><span class="cl">Are you sure you want to continue connecting (yes/no/[fingerprint])?</span></span></code></pre></div>
</div>
<p>Answer <code>yes</code> – your machine remembers the server from now on. After that you land
on a prompt like <code>root@v12345:~#</code>.</p>
<h3 id="step-2-update-the-system">Step 2: Update the system</h3>
<p>Freshly delivered images are rarely up to date. So the first thing to do is fetch
all updates – security updates in particular should never wait:</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">apt update <span class="o">&amp;&amp;</span> apt upgrade -y</span></span></code></pre></div>
</div>
<p><code>apt update</code> fetches the current package lists, <code>apt upgrade -y</code> installs all
available updates without prompting.</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">Reboot the server after a kernel update (<code>reboot</code>) so the changes take effect. On
a fresh Debian there&rsquo;s no automation for this yet – the safest bet is a reboot
right after the first big update. Debian does <strong>not</strong> create the marker file
<code>/var/run/reboot-required</code> (which indicates a pending reboot) on kernel updates by
default – you&rsquo;ll set up the matching hook later together with
<a href="/en/tutorials/unattended-upgrades-automatic-updates/">automatic updates</a>.</div>
</div>
<h3 id="step-3-create-a-sudo-user">Step 3: Create a sudo user</h3>
<p>Don&rsquo;t work as <code>root</code> permanently: a typo with full rights can wreck the entire
system, and having your own user is the prerequisite for
<a href="/en/tutorials/harden-ssh/">disabling root login completely</a> later. Create a
user – we&rsquo;ll call it <code>koch</code> (&ldquo;cook&rdquo;) here, but you can pick any name:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">adduser koch</span></span></code></pre></div>
</div>
<p>You&rsquo;ll be asked for a password and a few optional details (you can skip the
details with Enter). After that, give the user sudo rights:</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">usermod -aG sudo koch</span></span></code></pre></div>
</div>
<h3 id="step-4-test-the-new-user">Step 4: Test the new user</h3>
<p>Check <strong>in a new terminal session</strong> (keep the root session open!) that login and
sudo work:</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><span class="line"><span class="cl">sudo whoami</span></span></code></pre></div>
</div>
<p>After entering your password, <code>sudo whoami</code> should return <code>root</code>. From now on you
continue working with this user.</p>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong><code>ssh: connect to host … port 22: Connection timed out</code>.</strong> Usually the IP was
mistyped or the server isn&rsquo;t fully provisioned yet. Check the IP in your customer
account and whether the server is shown as &ldquo;online&rdquo; there. Wait a few minutes
after ordering.</p>
<p><strong><code>Permission denied, please try again</code> on root login.</strong> Wrong password – often a
copy-paste issue with invisible trailing spaces, or a different keyboard layout.
Copy the password without surrounding spaces directly from the credentials.</p>
<p><strong><code>WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!</code>.</strong> The server was reinstalled
and has a new host key – SSH rightly raises the alarm. If <strong>you</strong> reinstalled it,
remove the old entry with <code>ssh-keygen -R YOUR_SERVER_IP</code> and reconnect. If you
didn&rsquo;t reinstall, investigate before you connect.</p>
<p><strong><code>sudo: command not found</code> as the new user.</strong> On minimal images the package is
sometimes missing. Install it as <code>root</code>: <code>apt install sudo</code>. Then check that the
user is in the group: <code>groups koch</code> must contain <code>sudo</code> – otherwise repeat step 3
and log out and back in once.</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<ul>
<li><strong>Updates:</strong> You should schedule <code>sudo apt update &amp;&amp; sudo apt upgrade</code> at least
<strong>weekly</strong> – or
<a href="/en/tutorials/unattended-upgrades-automatic-updates/">automate security updates</a>
with <code>unattended-upgrades</code>.</li>
<li><strong>Snapshots:</strong> Create a snapshot in the
<a href="/en/tutorials/netcup-snapshots-scp/">netcup Server Control Panel (SCP)</a> before
bigger changes. It&rsquo;s your safety line while you don&rsquo;t have a proper backup
strategy yet – but a snapshot does <strong>not replace</strong> a backup outside the server.</li>
<li><strong>Credentials:</strong> Keep the root password safe (password manager). Via the VNC
console in the SCP you can still reach the server even when SSH is stuck.</li>
</ul>
<p>You&rsquo;ll find both in the server overview of the SCP: the <strong>VNC console</strong> is under
the <strong>&ldquo;Bildschirm&rdquo; (Display)</strong> tab, and the remaining <strong>snapshot quota</strong> is shown
in the status block.</p>
<p><figure class="my-6"><img src="/en/tutorials/first-steps-netcup-vps/netcup-scp-server_hu_8973a5aa80175a41.webp" srcset="/en/tutorials/first-steps-netcup-vps/netcup-scp-server_hu_ddb4950e5055f484.webp 480w, /en/tutorials/first-steps-netcup-vps/netcup-scp-server_hu_8973a5aa80175a41.webp 768w, /en/tutorials/first-steps-netcup-vps/netcup-scp-server_hu_18e42b31ae963ce5.webp 1200w, /en/tutorials/first-steps-netcup-vps/netcup-scp-server_hu_47fe22d828e0f3e5.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/first-steps-netcup-vps/netcup-scp-server_hu_7cd6ca3ca4c57df9.webp"
    alt="The server overview in the netcup Server Control Panel: tabs including &ldquo;Display&rdquo; (VNC console), status with snapshot quota, and the hardware specs" title="Server overview in the netcup SCP – the Display tab is the VNC console"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Server overview in the netcup SCP – the Display tab is the VNC console</figcaption></figure></p>
]]></content:encoded></item></channel></rss>