<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: serverkueche.de</title>
    <description>The latest articles on DEV Community by serverkueche.de (@serverkueche).</description>
    <link>https://dev.to/serverkueche</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4026385%2Fbcade448-c7e2-49fc-8e0f-176232f13294.jpg</url>
      <title>DEV Community: serverkueche.de</title>
      <link>https://dev.to/serverkueche</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/serverkueche"/>
    <language>en</language>
    <item>
      <title>Understanding Docker Compose: services, volumes, networks</title>
      <dc:creator>serverkueche.de</dc:creator>
      <pubDate>Sat, 26 Sep 2026 06:07:52 +0000</pubDate>
      <link>https://dev.to/serverkueche/understanding-docker-compose-services-volumes-networks-5ag8</link>
      <guid>https://dev.to/serverkueche/understanding-docker-compose-services-volumes-networks-5ag8</guid>
      <description>&lt;p&gt;In the &lt;a href="https://serverkueche.de/en/tutorials/install-docker/" rel="noopener noreferrer"&gt;Docker tutorial&lt;/a&gt; you installed the Compose plugin – but we didn't explain it yet. We're catching up on that now, because almost every app recipe here describes an application as a &lt;strong&gt;&lt;code&gt;compose.yaml&lt;/code&gt;&lt;/strong&gt;. Anyone who reads this file like a shopping list can adapt every following tutorial instead of just copying it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are we building?
&lt;/h2&gt;

&lt;p&gt;We build a small stack step by step and, along the way, get to know the five building blocks that make up practically every &lt;code&gt;compose.yaml&lt;/code&gt;: &lt;strong&gt;services&lt;/strong&gt; (the containers), &lt;strong&gt;ports&lt;/strong&gt; (reachability from outside), &lt;strong&gt;volumes&lt;/strong&gt; (persistent data), &lt;strong&gt;networks&lt;/strong&gt; (containers talking to each other) and &lt;strong&gt;environment variables&lt;/strong&gt; (configuration). By the end you'll understand why your data survives a &lt;code&gt;down&lt;/code&gt; command – and when it doesn't.&lt;/p&gt;

&lt;p&gt;Tested with &lt;strong&gt;Docker Compose v5.3&lt;/strong&gt; (the &lt;code&gt;docker compose&lt;/code&gt; plugin without a hyphen – not the old &lt;code&gt;docker-compose&lt;/code&gt; v1 with a hyphen).&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;a href="https://serverkueche.de/en/tutorials/harden-ssh/" rel="noopener noreferrer"&gt;hardened server&lt;/a&gt; with &lt;a href="https://serverkueche.de/en/tutorials/install-docker/" rel="noopener noreferrer"&gt;Docker installed&lt;/a&gt; and the Compose plugin&lt;/li&gt;
&lt;li&gt;The user is in the &lt;code&gt;docker&lt;/code&gt; group (then you don't need &lt;code&gt;sudo&lt;/code&gt; before &lt;code&gt;docker&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step by step
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: The first compose.yaml
&lt;/h3&gt;

&lt;p&gt;A &lt;code&gt;compose.yaml&lt;/code&gt; describes &lt;strong&gt;declaratively&lt;/strong&gt; which containers should run – you say &lt;em&gt;what&lt;/em&gt; you want, not &lt;em&gt;how&lt;/em&gt;. Create a project folder; the folder name later becomes the prefix of all containers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/compose-demo/site &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; ~/compose-demo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create a small HTML page that we'll serve in a moment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;h1&amp;gt;Hallo aus der Serverküche&amp;lt;/h1&amp;gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; site/index.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And now the central file &lt;code&gt;compose.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx:1.31&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8080:80"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./site:/usr/share/nginx/html:ro&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Line by line:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;services:&lt;/code&gt;&lt;/strong&gt; – the top level. Every entry below it is a container.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;web:&lt;/code&gt;&lt;/strong&gt; – a freely chosen &lt;strong&gt;service name&lt;/strong&gt;. Remember it, it later also becomes the hostname on the internal network (step 3).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;image: nginx:1.31&lt;/code&gt;&lt;/strong&gt; – the container image with a &lt;strong&gt;fixed tag&lt;/strong&gt;. Never use &lt;code&gt;latest&lt;/code&gt;: &lt;code&gt;latest&lt;/code&gt; changes under you and makes errors unreproducible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ports: - "8080:80"&lt;/code&gt;&lt;/strong&gt; – format &lt;code&gt;HOST:CONTAINER&lt;/code&gt;. Port &lt;strong&gt;80 in the container&lt;/strong&gt; is mapped to &lt;strong&gt;8080 on the server&lt;/strong&gt;. The server is always on the left.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;volumes: - ./site:...:ro&lt;/code&gt;&lt;/strong&gt; – the local &lt;code&gt;site&lt;/code&gt; folder is mounted into the web root, &lt;code&gt;:ro&lt;/code&gt; = read-only. More on this in step 2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;restart: unless-stopped&lt;/code&gt;&lt;/strong&gt; – the container restarts automatically after a reboot or crash, unless you stopped it yourself. The sensible default for server services. The alternatives: &lt;code&gt;no&lt;/code&gt; (never automatically – the default), &lt;code&gt;always&lt;/code&gt; (restarts even after a manual stop, rarely wanted) and &lt;code&gt;on-failure&lt;/code&gt; (only after a crash with an error code). For the vast majority of services, &lt;code&gt;unless-stopped&lt;/code&gt; is exactly right.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Start the stack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-d&lt;/code&gt; means &lt;strong&gt;detached&lt;/strong&gt; (in the background). The first time, Docker downloads the image; after that you see at the end:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[+] up 2/2
 ✔ Network compose-demo_default Created                                     0.0s
 ✔ Container compose-demo-web-1 Started                                     0.2s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the status:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME                 IMAGE        COMMAND                  SERVICE   CREATED          STATUS          PORTS
compose-demo-web-1   nginx:1.31   "/docker-entrypoint.…"   web       10 seconds ago   Up 9 seconds    0.0.0.0:8080-&amp;gt;80/tcp, [::]:8080-&amp;gt;80/tcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The container is called &lt;code&gt;compose-demo-web-1&lt;/code&gt; – &lt;strong&gt;project folder + service + number&lt;/strong&gt;. Test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl localhost:8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;h1&amp;gt;Hallo aus der Serverküche&amp;lt;/h1&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Works. You see a service's logs with &lt;code&gt;docker compose logs web&lt;/code&gt; (or &lt;code&gt;-f&lt;/code&gt; to follow).&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Volumes – where your data really lives
&lt;/h3&gt;

&lt;p&gt;Containers are &lt;strong&gt;ephemeral&lt;/strong&gt;: if you delete a container, everything written &lt;em&gt;inside&lt;/em&gt; the container is gone. So that data survives this, there are two kinds of volumes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bind mount&lt;/strong&gt; (&lt;code&gt;./site:/usr/share/nginx/html&lt;/code&gt;): a &lt;strong&gt;folder from your server&lt;/strong&gt; is mounted into the container. Ideal for config files you edit yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Named volume&lt;/strong&gt; (&lt;code&gt;webdata:/var/lib/...&lt;/code&gt;): storage &lt;strong&gt;managed by Docker&lt;/strong&gt;. Ideal for database data – performant and cleanly separated from the host.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Which of the two to pick when – and what that means for permissions, migrations and backups – is explored in &lt;a href="https://serverkueche.de/en/tutorials/docker-volumes-vs-bind-mounts/" rel="noopener noreferrer"&gt;Docker volumes vs. bind mounts&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;In step 1 we used a bind mount. For database-like services it looks like this – change &lt;code&gt;compose.yaml&lt;/code&gt; as a test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx:1.31&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;webdata:/usr/share/nginx/html&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;webdata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Named volumes must &lt;strong&gt;additionally&lt;/strong&gt; be declared at the top level under &lt;code&gt;volumes:&lt;/code&gt;. After &lt;code&gt;docker compose up -d&lt;/code&gt; the volume appears:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker volume &lt;span class="nb"&gt;ls&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DRIVER    VOLUME NAME
local     compose-demo_webdata
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now comes the crucial point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose down
docker volume &lt;span class="nb"&gt;ls&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[+] down 2/2
 ✔ Container compose-demo-web-1 Removed                                     0.2s
 ✔ Network compose-demo_default Removed                                     0.2s
DRIVER    VOLUME NAME
local     compose-demo_webdata
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;docker compose down&lt;/code&gt; removes the container and network – &lt;strong&gt;the volume stays&lt;/strong&gt;. This is exactly why your database contents survive an update. Remember the counterpart:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;🛑 down -v deletes data&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;docker compose down -v&lt;/code&gt; also deletes the &lt;strong&gt;named volumes&lt;/strong&gt; – i.e. all the stack's persistent data. Never type the &lt;code&gt;-v&lt;/code&gt; out of reflex. For a plain restart, &lt;code&gt;docker compose down&lt;/code&gt; (without &lt;code&gt;-v&lt;/code&gt;) is enough.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 3: Networks – containers talking to each other
&lt;/h3&gt;

&lt;p&gt;Compose automatically creates &lt;strong&gt;one network per project&lt;/strong&gt; (seen above: &lt;code&gt;compose-demo_default&lt;/code&gt;). All services in it reach each other &lt;strong&gt;via their service name&lt;/strong&gt; as the hostname – no fiddling with IPs. That's the reason why, in app tutorials, the application simply reaches its database under &lt;code&gt;db&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;As proof, a second service that talks to the first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx:1.31&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./site:/usr/share/nginx/html:ro&lt;/span&gt;

  &lt;span class="na"&gt;ping&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;curlimages/curl:8.22.0&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;web&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;curl"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-s"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://web"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;depends_on: - web&lt;/code&gt;&lt;/strong&gt; – Compose starts &lt;code&gt;web&lt;/code&gt; &lt;strong&gt;before&lt;/strong&gt; &lt;code&gt;ping&lt;/code&gt;. (Careful: this only waits for the &lt;em&gt;start&lt;/em&gt;, not for "fully booted" – that's what healthchecks are for, step 4.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;command:&lt;/code&gt;&lt;/strong&gt; – overrides the image's default command. &lt;code&gt;ping&lt;/code&gt; calls &lt;code&gt;http://web&lt;/code&gt; – &lt;strong&gt;&lt;code&gt;web&lt;/code&gt; is the service name from the same file&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run only the &lt;code&gt;ping&lt;/code&gt; service once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose run &lt;span class="nt"&gt;--rm&lt;/span&gt; ping
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;h1&amp;gt;Hallo aus der Serverküche&amp;lt;/h1&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ping&lt;/code&gt; reached &lt;code&gt;web&lt;/code&gt; purely by name – without any port mapping. &lt;strong&gt;Remember: you only need &lt;code&gt;ports:&lt;/code&gt; to make a service reachable from &lt;em&gt;outside&lt;/em&gt; (the internet).&lt;/strong&gt; Containers talk to each other over the internal network – that's why we later deliberately run databases &lt;em&gt;without&lt;/em&gt; &lt;code&gt;ports:&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So far, every network lives &lt;strong&gt;inside&lt;/strong&gt; a Compose project. But sometimes containers from &lt;strong&gt;different&lt;/strong&gt; projects should talk to each other – the classic example is a &lt;strong&gt;reverse proxy&lt;/strong&gt; sitting in front of many independent app stacks. For that there's the &lt;strong&gt;external network&lt;/strong&gt;: one you create &lt;strong&gt;once by hand&lt;/strong&gt; and that several Compose projects then share:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker network create &lt;span class="nt"&gt;--ipv6&lt;/span&gt; &lt;span class="nt"&gt;--subnet&lt;/span&gt; fd00:cafe::/64 proxy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--ipv6&lt;/code&gt; belongs there as soon as a reverse proxy with a public domain sits behind the network: without IPv6 in the network, Docker replaces the source address of IPv6 visitors with the bridge gateway's, and the logs then show &lt;code&gt;172.x.x.x&lt;/code&gt; instead of the real IP. More on that in &lt;a href="https://serverkueche.de/en/tutorials/traefik-reverse-proxy/" rel="noopener noreferrer"&gt;Reverse proxy with Traefik&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;In the &lt;code&gt;compose.yaml&lt;/code&gt; you then don't create it again, but reference the already existing network with &lt;code&gt;external: true&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx:1.31&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;proxy&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;proxy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;external: true&lt;/code&gt; tells Compose: "This network already exists – do &lt;strong&gt;not&lt;/strong&gt; create it and do &lt;strong&gt;not&lt;/strong&gt; delete it on &lt;code&gt;down&lt;/code&gt;." If the network is missing, &lt;code&gt;up&lt;/code&gt; aborts with &lt;code&gt;network proxy declared as external, but could not be found&lt;/code&gt; – then you forgot the &lt;code&gt;docker network create&lt;/code&gt;. This very pattern – a shared &lt;code&gt;proxy&lt;/code&gt; network plus &lt;code&gt;external: true&lt;/code&gt; – is the basis of the &lt;a href="https://serverkueche.de/en/tutorials/traefik-reverse-proxy/" rel="noopener noreferrer"&gt;Traefik tutorial&lt;/a&gt;, with which every app later gets its domain and its HTTPS.&lt;/p&gt;

&lt;p&gt;What Compose sets up under the hood along the way – the &lt;code&gt;bridge&lt;/code&gt; driver, the built-in DNS and containers that deliberately get no internet at all – is taken apart in&lt;br&gt;
&lt;a href="https://serverkueche.de/en/tutorials/understanding-docker-networks/" rel="noopener noreferrer"&gt;Understanding Docker networks&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 4: Configuration – environment variables, .env and healthchecks
&lt;/h3&gt;

&lt;p&gt;Almost every application is configured via &lt;strong&gt;environment variables&lt;/strong&gt;. Two ways:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;beispiel/app:1.0&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;TZ=Europe/Berlin&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;APP_PORT=3000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Secrets (passwords, tokens) do &lt;strong&gt;not&lt;/strong&gt; belong in the &lt;code&gt;compose.yaml&lt;/code&gt;, but in a &lt;code&gt;.env&lt;/code&gt; file in the same folder. Compose reads it automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"DB_PASSWORD=EIN_LANGES_ZUFALLSPASSWORT"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:18&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_PASSWORD=${DB_PASSWORD}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠️ Never push &lt;code&gt;.env&lt;/code&gt; to a backup repo&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;.env&lt;/code&gt; contains plaintext secrets. Add it to a &lt;code&gt;.gitignore&lt;/code&gt; if you version your Compose files, and back it up separately (encrypted).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is how you check whether Compose understands your file &lt;strong&gt;and&lt;/strong&gt; inserts the &lt;code&gt;.env&lt;/code&gt; values correctly – without starting anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;config&lt;/code&gt; resolves all variables and prints the finished, normalized configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;compose-demo&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;EIN_LANGES_ZUFALLSPASSWORT&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:18&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;compose-demo_default&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your &lt;strong&gt;actual&lt;/strong&gt; value appears there instead of &lt;code&gt;${DB_PASSWORD}&lt;/code&gt;, the &lt;code&gt;.env&lt;/code&gt; is working. If you only need a quick syntax check without the whole output, use &lt;code&gt;docker compose config --quiet&lt;/code&gt; – if nothing comes back (exit code &lt;code&gt;0&lt;/code&gt;), the file is valid. That's also your first move for YAML errors (see below).&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;healthcheck&lt;/strong&gt; tells Docker when a service is really ready – the basis for dependent services only starting then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:18&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_PASSWORD=${DB_PASSWORD}&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;postgres"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;

  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;beispiel/app:1.0&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;condition: service_healthy&lt;/code&gt;, &lt;code&gt;app&lt;/code&gt; only starts once the healthcheck of &lt;code&gt;db&lt;/code&gt; is green – the most common "why won't my app connect to the database?" disappears with it. The four healthcheck fields mean: &lt;strong&gt;&lt;code&gt;test&lt;/code&gt;&lt;/strong&gt; is the command that runs in the container (exit code &lt;code&gt;0&lt;/code&gt; = healthy), &lt;strong&gt;&lt;code&gt;interval&lt;/code&gt;&lt;/strong&gt; the spacing between checks, &lt;strong&gt;&lt;code&gt;timeout&lt;/code&gt;&lt;/strong&gt; how long a check may take, and &lt;strong&gt;&lt;code&gt;retries&lt;/code&gt;&lt;/strong&gt; how many consecutive failures are needed before the container counts as &lt;code&gt;unhealthy&lt;/code&gt;. The current state is shown by the &lt;code&gt;STATUS&lt;/code&gt; column of &lt;code&gt;docker compose ps&lt;/code&gt; as &lt;code&gt;(healthy)&lt;/code&gt; or &lt;code&gt;(unhealthy)&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: The operations toolbox
&lt;/h3&gt;

&lt;p&gt;You need these commands daily – always run them &lt;strong&gt;in the project folder&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;        &lt;span class="c"&gt;# start / apply changes&lt;/span&gt;
docker compose ps           &lt;span class="c"&gt;# status of the services&lt;/span&gt;
docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt; web  &lt;span class="c"&gt;# follow logs live (Ctrl+C only ends the viewing)&lt;/span&gt;
docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;web sh  &lt;span class="c"&gt;# shell in the running container&lt;/span&gt;
docker compose restart web  &lt;span class="c"&gt;# restart a single service&lt;/span&gt;
docker compose stop         &lt;span class="c"&gt;# halt without removing container/network&lt;/span&gt;
docker compose pull         &lt;span class="c"&gt;# fetch new image versions&lt;/span&gt;
docker compose down         &lt;span class="c"&gt;# stop and remove the stack (volumes stay)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Almost all commands can be restricted to &lt;strong&gt;one&lt;/strong&gt; service by appending its name (&lt;code&gt;docker compose logs -f web&lt;/code&gt;, &lt;code&gt;docker compose restart web&lt;/code&gt;) – without a name they apply to the whole stack. The difference between &lt;code&gt;stop&lt;/code&gt; and &lt;code&gt;down&lt;/code&gt;: &lt;code&gt;stop&lt;/code&gt; only halts the containers (&lt;code&gt;start&lt;/code&gt; continues), &lt;code&gt;down&lt;/code&gt; removes them along with the network (the named volumes stay in both cases).&lt;/p&gt;

&lt;p&gt;An update almost always follows the same pattern: bump the tag in the &lt;code&gt;compose.yaml&lt;/code&gt; → &lt;code&gt;docker compose pull&lt;/code&gt; → &lt;code&gt;docker compose up -d&lt;/code&gt;. Compose only replaces the containers whose image has changed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6: All together – a realistic app stack
&lt;/h3&gt;

&lt;p&gt;This is the pattern you'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'll have understood 90% of every later &lt;code&gt;compose.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;beispiel/app:1.4&lt;/span&gt;          &lt;span class="c1"&gt;# fixed version, no latest&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8080:3000"&lt;/span&gt;                  &lt;span class="c1"&gt;# only the app is reachable from outside&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;TZ=Europe/Berlin&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DATABASE_URL=postgres://app:${DB_PASSWORD}@db:5432/app&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;appdata:/data&lt;/span&gt;                &lt;span class="c1"&gt;# persistent app data (named volume)&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;   &lt;span class="c1"&gt;# starts only once db is ready&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:18&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_USER=app&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_PASSWORD=${DB_PASSWORD}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_DB=app&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;dbdata:/var/lib/postgresql/data&lt;/span&gt;   &lt;span class="c1"&gt;# the actual database files&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="c1"&gt;# no ports: – the database is reachable ONLY internally via the name "db"&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;appdata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;dbdata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three design decisions worth remembering:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Only &lt;code&gt;app&lt;/code&gt; has &lt;code&gt;ports:&lt;/code&gt;.&lt;/strong&gt; The database needs no open host port – the app reaches it internally via the hostname &lt;code&gt;db&lt;/code&gt; (the &lt;code&gt;DATABASE_URL&lt;/code&gt; points exactly there). A port that isn't published is a port nobody from the internet can attack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two separate named volumes.&lt;/strong&gt; App data and database files live cleanly separated – that makes later backups and restores traceable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Password only as &lt;code&gt;${DB_PASSWORD}&lt;/code&gt;.&lt;/strong&gt; The actual value is in the &lt;code&gt;.env&lt;/code&gt;, not in this file. The same &lt;code&gt;compose.yaml&lt;/code&gt; can thus be shared safely.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This very skeleton – app facing outward, database internal only, data in named volumes, secrets in the &lt;code&gt;.env&lt;/code&gt; – repeats in Nextcloud, Vaultwarden, Paperless and most other recipes.&lt;/p&gt;

&lt;h2&gt;
  
  
  When things go wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;yaml: line 7: did not find expected key&lt;/code&gt; (or similar YAML errors).&lt;/strong&gt; YAML is &lt;strong&gt;indentation-sensitive&lt;/strong&gt; – spaces only, &lt;strong&gt;never tabs&lt;/strong&gt;, and consistent per level (common: 2 spaces). Check the file without starting it: &lt;code&gt;docker compose config&lt;/code&gt; resolves everything and complains about exactly the wrong line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Error ... address already in use&lt;/code&gt; on &lt;code&gt;up&lt;/code&gt;.&lt;/strong&gt; The host port (left in &lt;code&gt;8080:80&lt;/code&gt;) is already taken. Find the occupant with &lt;code&gt;sudo ss -tlnp | grep 8080&lt;/code&gt; or choose a different host port. Two containers cannot share the same host port.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An app can't find its database (&lt;code&gt;could not translate host name&lt;/code&gt;).&lt;/strong&gt; The hostname must be the &lt;strong&gt;service name&lt;/strong&gt; (e.g. &lt;code&gt;db&lt;/code&gt;), not &lt;code&gt;localhost&lt;/code&gt;. Inside a container, &lt;code&gt;localhost&lt;/code&gt; is the container itself, not the neighboring service. And: both services must be in the same Compose project (the same file).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After &lt;code&gt;docker compose down&lt;/code&gt; all data is gone.&lt;/strong&gt; Either the volume wasn't declared as a &lt;strong&gt;named volume&lt;/strong&gt; under &lt;code&gt;volumes:&lt;/code&gt; (then it was only the ephemeral container storage), or &lt;code&gt;down -v&lt;/code&gt; was used. Always run persistent services with a declared named volume.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;docker-compose: command not found&lt;/code&gt;.&lt;/strong&gt; That's the old Compose v1 (with a hyphen). The current one is &lt;code&gt;docker compose&lt;/code&gt; (with a space, plugin). If it's missing: &lt;code&gt;sudo apt install docker-compose-plugin&lt;/code&gt; (see &lt;a href="https://serverkueche.de/en/tutorials/install-docker/" rel="noopener noreferrer"&gt;Docker tutorial&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Maintenance &amp;amp; backups
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Maintain image tags:&lt;/strong&gt; Fixed tags (&lt;code&gt;nginx:1.31&lt;/code&gt;) mean you apply updates &lt;strong&gt;deliberately&lt;/strong&gt; by bumping the tag. That's intended – this way you decide when an update comes, instead of being surprised. Expect a &lt;strong&gt;monthly&lt;/strong&gt; look at your services' release notes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What belongs in the backup?&lt;/strong&gt; Not the containers – they can be rebuilt from the &lt;code&gt;compose.yaml&lt;/code&gt; at any time. What you must back up are &lt;strong&gt;the named volumes&lt;/strong&gt; (or bind-mount folders), the &lt;code&gt;compose.yaml&lt;/code&gt; and the &lt;code&gt;.env&lt;/code&gt;. We build a well-thought-out off-site backup of this data with &lt;a href="https://serverkueche.de/en/tutorials/restic-backups/" rel="noopener noreferrer"&gt;encrypted Restic backups&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cleanup:&lt;/strong&gt; &lt;code&gt;docker compose down&lt;/code&gt; when tearing down a stack; you remove unused images with &lt;code&gt;docker image prune&lt;/code&gt;. Named volumes are &lt;strong&gt;never&lt;/strong&gt; deleted automatically – that's by design.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This post first appeared on &lt;a href="https://serverkueche.de/en/tutorials/docker-compose-basics/" rel="noopener noreferrer"&gt;serverkueche.de&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Hardening &amp; optimizing Nextcloud: clear every warning (part 2)</title>
      <dc:creator>serverkueche.de</dc:creator>
      <pubDate>Sat, 26 Sep 2026 06:07:26 +0000</pubDate>
      <link>https://dev.to/serverkueche/hardening-optimizing-nextcloud-clear-every-warning-part-2-15jd</link>
      <guid>https://dev.to/serverkueche/hardening-optimizing-nextcloud-clear-every-warning-part-2-15jd</guid>
      <description>&lt;p&gt;Your Nextcloud is running – but the admin overview shows yellow warnings, there's no working email and no second factor protects your accounts. In this second part we turn "running" into "cleanly secured and snappy": we work through every warning until the overview is green.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are we building?
&lt;/h2&gt;

&lt;p&gt;By the end, the &lt;strong&gt;security &amp;amp; setup overview&lt;/strong&gt; of your Nextcloud shows no more warnings. Concretely, we build on the installation from &lt;a href="https://serverkueche.de/en/tutorials/self-host-nextcloud/" rel="noopener noreferrer"&gt;part 1&lt;/a&gt; and add: the missing &lt;strong&gt;HSTS security header&lt;/strong&gt; via Traefik, a configured &lt;strong&gt;maintenance window&lt;/strong&gt;, working &lt;strong&gt;email sending&lt;/strong&gt; (for password resets and notifications), &lt;strong&gt;enforced two-factor authentication&lt;/strong&gt; and &lt;strong&gt;brute-force protection that sees the real attacker IPs&lt;/strong&gt; – the basis for Fail2ban.&lt;/p&gt;

&lt;p&gt;Everything is tested against &lt;strong&gt;Nextcloud 34.0.4&lt;/strong&gt; behind &lt;strong&gt;Traefik v3.7&lt;/strong&gt; on Debian 13 with PHP 8.5. This is legwork, but exactly the legwork missing in 90% of all Nextcloud guides – and it makes the difference between "somehow online" and "properly operated".&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;running Nextcloud&lt;/strong&gt; set up as in &lt;a href="https://serverkueche.de/en/tutorials/self-host-nextcloud/" rel="noopener noreferrer"&gt;self-hosting Nextcloud (part 1)&lt;/a&gt;: classic &lt;code&gt;nextcloud&lt;/code&gt; image behind Traefik, with MariaDB, Redis and a cron sidecar. The container names from part 1 (&lt;code&gt;nc-app&lt;/code&gt;, &lt;code&gt;nc-db&lt;/code&gt;, &lt;code&gt;nc-redis&lt;/code&gt;, &lt;code&gt;nc-cron&lt;/code&gt;) and the Compose folder &lt;code&gt;~/nextcloud&lt;/code&gt; are the basis here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSH access&lt;/strong&gt; to the server and the command-line tool &lt;strong&gt;&lt;code&gt;occ&lt;/code&gt;&lt;/strong&gt; – we call it as in part 1 in the app container: &lt;code&gt;docker exec -u www-data nc-app php occ &amp;lt;command&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;Traefik reverse proxy&lt;/strong&gt; with the &lt;code&gt;proxy&lt;/code&gt; network and the resolver &lt;code&gt;le&lt;/code&gt; as in the tutorial &lt;a href="https://serverkueche.de/en/tutorials/traefik-reverse-proxy/" rel="noopener noreferrer"&gt;reverse proxy with Traefik&lt;/a&gt; – we attach the security headers as a Traefik middleware.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 Keeping occ in hand&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;occ&lt;/code&gt; is Nextcloud's admin tool. Because the containers in part 1 got fixed names (&lt;code&gt;container_name&lt;/code&gt;), you address the app container directly – no matter which directory you're in:&lt;/p&gt;


&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; www-data nc-app php occ status
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;You should see &lt;code&gt;installed: true&lt;/code&gt; and your version. We won't abbreviate this prefix (&lt;code&gt;docker exec -u www-data nc-app php occ …&lt;/code&gt;) in the following – so you can copy every command directly. Only the few &lt;code&gt;docker compose …&lt;/code&gt; commands (like the container restart in step 3) you still run from the &lt;code&gt;~/nextcloud&lt;/code&gt; folder.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step by step
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Read the admin overview as a stock-take
&lt;/h3&gt;

&lt;p&gt;Log in as administrator and open &lt;strong&gt;Administration → Overview&lt;/strong&gt;. At the top is the &lt;strong&gt;"Security &amp;amp; setup warnings"&lt;/strong&gt; section. Right after a standard installation it looks like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzdmvykem04gl7lxgwjnp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzdmvykem04gl7lxgwjnp.png" alt="The Nextcloud admin overview lists several yellow warnings about the maintenance window, MIME-type migrations and HTTP headers" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nextcloud distinguishes three levels: &lt;strong&gt;yellow warnings&lt;/strong&gt; (⚠, you should fix), &lt;strong&gt;blue notices&lt;/strong&gt; (ℹ, usually optional) and silently passed checks. On our fresh instance there are three warnings and a few notices:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;⚠ &lt;strong&gt;Maintenance window start&lt;/strong&gt; is not set (step 2)&lt;/li&gt;
&lt;li&gt;⚠ &lt;strong&gt;MIME-type migrations available&lt;/strong&gt; (step 2)&lt;/li&gt;
&lt;li&gt;⚠ &lt;strong&gt;HTTP headers&lt;/strong&gt; – the &lt;code&gt;Strict-Transport-Security&lt;/code&gt; header is missing (step 3)&lt;/li&gt;
&lt;li&gt;ℹ &lt;strong&gt;Email test&lt;/strong&gt; – no mail server configured yet (step 4)&lt;/li&gt;
&lt;li&gt;ℹ &lt;strong&gt;Two-factor configuration&lt;/strong&gt; – 2FA is available but not enforced (step 5)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You also get the same report on the command line – handy to check progress in between without clicking through the web interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; www-data nc-app php occ setupchecks
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command outputs each check with ✓, ⚠ or ✗. We now work through exactly this list from top to bottom.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Fix the two quick warnings
&lt;/h3&gt;

&lt;p&gt;Two warnings are done with one command each.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Set the maintenance window.&lt;/strong&gt; Nextcloud runs compute-intensive cleanup jobs once daily (previews, activities, calendar repeats). Without a defined time window they run &lt;em&gt;at some point&lt;/em&gt; – even in the middle of the day. Set a start time when hardly anyone works. The value is a &lt;strong&gt;full hour in UTC&lt;/strong&gt;; &lt;code&gt;1&lt;/code&gt; sets the start to 01:00 UTC – the check afterwards reports the window from 1:00 to 7:00 UTC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; www-data nc-app php occ config:system:set maintenance_window_start &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;integer &lt;span class="nt"&gt;--value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To verify, the setup check afterwards confirms:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ Maintenance window start: Maintenance window to execute heavy background jobs is between 1:00 UTC and 7:00 UTC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Run MIME-type migrations.&lt;/strong&gt; Occasionally Nextcloud learns new file types (e.g. for better icons and previews). This migration does &lt;em&gt;not&lt;/em&gt; run automatically on updates because it can take a while on large instances. On a fresh cloud it's done in seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; www-data nc-app php occ maintenance:repair &lt;span class="nt"&gt;--include-expensive&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You see a list of repair steps performed (including &lt;code&gt;Repair mime types&lt;/code&gt;). Afterwards the check reports &lt;code&gt;Mimetype migrations available: None&lt;/code&gt;. Two fewer warnings.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;ℹ️ And the blue notices?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The notices &lt;strong&gt;"AppAPI deploy daemon"&lt;/strong&gt; and &lt;strong&gt;"Server-ID configuration"&lt;/strong&gt; you can ignore on a normal single-server installation. The first concerns only the installation of external "Ex-Apps" via a Docker daemon, the second only setups spread across several PHP servers. You need neither here – that's why they stay notices and not warnings.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 3: Set the HSTS security header via Traefik
&lt;/h3&gt;

&lt;p&gt;The third warning is the most important: the &lt;strong&gt;&lt;code&gt;Strict-Transport-Security&lt;/code&gt; header&lt;/strong&gt; (HSTS) is missing. It instructs the browser to address this domain &lt;strong&gt;exclusively over HTTPS&lt;/strong&gt; – even when someone types &lt;code&gt;http://&lt;/code&gt; or an attacker tries to redirect the connection. Without HSTS, a small time window for downgrade attacks stays open.&lt;/p&gt;

&lt;p&gt;Because our Nextcloud sits behind Traefik, we set the header &lt;strong&gt;in the proxy&lt;/strong&gt;, not in Nextcloud – that's where it belongs, since Traefik terminates the TLS. In the &lt;code&gt;compose.yaml&lt;/code&gt; of your Nextcloud (from part 1), add the middleware &lt;code&gt;nc-secure&lt;/code&gt; and attach it to the router. Concretely, two places change in the &lt;code&gt;labels&lt;/code&gt; block of &lt;code&gt;nc-app&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;First, extend the router line with &lt;code&gt;nc-secure&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.nc.middlewares=nc-dav,nc-secure"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, define the header middleware (right by the other &lt;code&gt;nc-app&lt;/code&gt; labels):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.middlewares.nc-secure.headers.stsSeconds=15552000"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.middlewares.nc-secure.headers.stsIncludeSubdomains=true"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;stsSeconds=15552000&lt;/code&gt; is 180 days – the minimum value Nextcloud requires. Apply the change and reload only the app container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; nc-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After a few seconds, check from your own machine that the header is really delivered:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-I&lt;/span&gt; https://cloud.YOUR_DOMAIN/ | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; strict-transport
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see exactly this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;strict-transport-security: max-age=15552000; includeSubDomains
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the admin overview the HTTP-header warning thus turns green: &lt;em&gt;"Your server is correctly configured to send security headers."&lt;/em&gt; The remaining headers (&lt;code&gt;X-Content-Type-Options&lt;/code&gt;, &lt;code&gt;X-Frame-Options&lt;/code&gt;, &lt;code&gt;Referrer-Policy&lt;/code&gt; …) Nextcloud delivers itself – only HSTS had to come from the proxy.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠️ preload is a one-way street&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;stsIncludeSubdomains=true&lt;/code&gt; extends the HTTPS requirement to all subdomains &lt;strong&gt;of &lt;code&gt;cloud.YOUR_DOMAIN&lt;/code&gt;&lt;/strong&gt; (not to siblings like &lt;code&gt;www.YOUR_DOMAIN&lt;/code&gt; – the header always applies only to the host that delivers it). Deliberately &lt;strong&gt;not&lt;/strong&gt; set is &lt;code&gt;stsPreload=true&lt;/code&gt;: with it you'd signal that you want the domain entered into the browsers' &lt;a href="https://hstspreload.org/" rel="noopener noreferrer"&gt;HSTS preload list&lt;/a&gt; – and such an entry can only be undone with weeks of lead time. If you ever deliberately want that: the preload list only accepts &lt;code&gt;max-age&lt;/code&gt; ≥ 31536000 (1 year) – with the 15552000 above the flag would be ineffective anyway. Only set &lt;code&gt;preload&lt;/code&gt; when really &lt;em&gt;every&lt;/em&gt; service under the domain permanently speaks HTTPS; the header is fully functional without it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 4: Set up email sending
&lt;/h3&gt;

&lt;p&gt;Without working mail sending, Nextcloud can send no &lt;strong&gt;password resets&lt;/strong&gt;, no &lt;strong&gt;share notifications&lt;/strong&gt; and no &lt;strong&gt;security warnings&lt;/strong&gt; – and the "Email test" notice stays. We catch up on that.&lt;/p&gt;

&lt;p&gt;Open &lt;strong&gt;Administration → Basic settings&lt;/strong&gt; and scroll to the &lt;strong&gt;"Email server"&lt;/strong&gt; section. Enter your mail provider's credentials:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Send mode:&lt;/strong&gt; &lt;code&gt;SMTP&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encryption:&lt;/strong&gt; &lt;code&gt;SSL/TLS&lt;/code&gt; (port &lt;strong&gt;465&lt;/strong&gt;) or &lt;code&gt;STARTTLS&lt;/code&gt; (port &lt;strong&gt;587&lt;/strong&gt;) – never unencrypted&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;From address:&lt;/strong&gt; e.g. &lt;code&gt;cloud&lt;/code&gt; @ &lt;code&gt;YOUR_DOMAIN&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server address:&lt;/strong&gt; host and port of your provider&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authentication:&lt;/strong&gt; enable and store username/password&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At the top, under &lt;strong&gt;Personal information&lt;/strong&gt;, enter an email address for your admin account (the test mail goes there). Then click &lt;strong&gt;"Send test email"&lt;/strong&gt;. If the mail arrives, Nextcloud confirms it with a green message:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsen37qgbpaceolzrmu02.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsen37qgbpaceolzrmu02.png" alt="The email server is configured with SMTP, a test send is confirmed at the top right with " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 App password instead of account password&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If your mail provider itself uses two-factor authentication (Gmail, Mailbox.org, many others), your normal login password does &lt;strong&gt;not&lt;/strong&gt; work here. Create a dedicated &lt;strong&gt;app password&lt;/strong&gt; in your provider's account and enter that. It can also be revoked specifically without changing your main password.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 5: Enforce two-factor authentication
&lt;/h3&gt;

&lt;p&gt;A stolen password is the most common way accounts get taken over. A &lt;strong&gt;second factor&lt;/strong&gt; (a time-based code from an authenticator app, TOTP) makes the password alone worthless. Nextcloud already brings the matching provider – we just have to enable it and make it mandatory.&lt;/p&gt;

&lt;p&gt;First enable the TOTP app (it's included):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; www-data nc-app php occ app:enable twofactor_totp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On current images it is active out of the box, in which case the command only answers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;twofactor_totp already enabled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's not an error – "available" simply isn't "mandatory" yet. That's exactly why the overview keeps showing the notice until we switch on the requirement in a moment.&lt;/p&gt;

&lt;p&gt;Each user then sets up their second factor themselves – under &lt;strong&gt;Personal settings → Security → "TOTP (Authenticator app)"&lt;/strong&gt;. On enabling, Nextcloud shows a QR code you scan with an app like Aegis, andOTP or Google Authenticator. Additionally, the app &lt;strong&gt;"Two-Factor Backup Codes"&lt;/strong&gt; is recommended – the one-time codes save you if the phone is lost.&lt;/p&gt;

&lt;p&gt;So that nobody "forgets" the safeguard, you enforce 2FA for the administrators. On the web you find that under &lt;strong&gt;Administration → Security → "Enforce two-factor authentication"&lt;/strong&gt;, on the command line it works like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; www-data nc-app php occ twofactorauth:enforce &lt;span class="nt"&gt;--on&lt;/span&gt; &lt;span class="nt"&gt;--group&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;admin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/nc-zweifaktor.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/nc-zweifaktor.png" title="Two-factor requirement for the \" alt="The security settings show enforced two-factor authentication for the " width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;🛑 Don't lock yourself out&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;As soon as enforcement is active, every member of the group &lt;strong&gt;must&lt;/strong&gt; set up a second factor on the next login. So set up &lt;strong&gt;your own&lt;/strong&gt; second factor first and keep the backup codes safe before you arm the requirement. If something does go wrong, you lift the enforcement again via the command line: &lt;code&gt;docker exec -u www-data nc-app php occ twofactorauth:enforce --off&lt;/code&gt;. A single stuck factor you remove with &lt;code&gt;… php occ twofactorauth:disable USER totp&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 6: Brute-force protection with real IPs – basis for Fail2ban
&lt;/h3&gt;

&lt;p&gt;Nextcloud throttles failed logins out of the box: after several failed attempts it delays further requests from &lt;strong&gt;the same IP&lt;/strong&gt;. But this protection stands and falls with Nextcloud seeing the &lt;strong&gt;real client IP&lt;/strong&gt; – and not the internal IP of Traefik. That's exactly what the &lt;code&gt;TRUSTED_PROXIES&lt;/code&gt; setting from part 1 does.&lt;/p&gt;

&lt;p&gt;Whether it works you see under &lt;strong&gt;Administration → Security&lt;/strong&gt; in the "brute-force allow list" box (in the screenshot above). There stands your current, correctly recognized public IP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Your current IP address is recognized as "YOUR_IP". This address is currently not throttled.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a &lt;code&gt;172.x.x.x&lt;/code&gt; address from the Docker network appears there instead, &lt;code&gt;TRUSTED_PROXIES&lt;/code&gt; isn't taking effect – then the brute-force protection would lump all users together and, in doubt, ban Traefik itself. In that case, back to part 1, step 2/3.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hard bans with Fail2ban.&lt;/strong&gt; The built-in throttling only delays; whoever wants to really ban attackers combines Nextcloud with &lt;a href="https://serverkueche.de/en/tutorials/fail2ban-setup/" rel="noopener noreferrer"&gt;Fail2ban&lt;/a&gt;. Nextcloud logs every failed attempt in the JSON log – and thanks to &lt;code&gt;TRUSTED_PROXIES&lt;/code&gt; with the real IP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"level":2,"time":"2026-07-18T08:14:05+00:00","remoteAddr":"203.0.113.47","user":"--","app":"no app in context","method":"POST","url":"/login","message":"Login failed: USER (Remote IP: 203.0.113.47)"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This log lives in the Nextcloud volume. You find the path on the host like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker inspect &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s1"&gt;'{{ range .Mounts }}{{ if eq .Destination "/var/www/html" }}{{ .Source }}{{ end }}{{ end }}'&lt;/span&gt; nc-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Below it is the file &lt;code&gt;data/nextcloud.log&lt;/code&gt;. At that you point a Fail2ban filter that matches on &lt;code&gt;remoteAddr&lt;/code&gt;, plus a jail following the pattern from the &lt;a href="https://serverkueche.de/en/tutorials/fail2ban-setup/" rel="noopener noreferrer"&gt;Fail2ban tutorial&lt;/a&gt;. This way, after a few failed attempts, the attacker IP goes straight into the firewall – before the request even reaches Nextcloud.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 7: Check caching &amp;amp; PHP – the performance foundation
&lt;/h3&gt;

&lt;p&gt;Finally, we make sure the performance basis is in place. Much of this part 1 already set up automatically via the &lt;code&gt;REDIS_HOST&lt;/code&gt; variable – checking doesn't hurt anyway, because this is exactly where a Nextcloud gets sluggish or snappy.&lt;/p&gt;

&lt;p&gt;Nextcloud uses &lt;strong&gt;three&lt;/strong&gt; cache roles. Check all three:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; www-data nc-app php occ config:system:get memcache.local
docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; www-data nc-app php occ config:system:get memcache.distributed
docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; www-data nc-app php occ config:system:get memcache.locking
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;\OC\Memcache\APCu
\OC\Memcache\Redis
\OC\Memcache\Redis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Local cache (APCu):&lt;/strong&gt; keeps frequently used data in the RAM of the PHP process – the most noticeable accelerator in everyday use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distributed cache (Redis):&lt;/strong&gt; shares cache data across processes, important once the app and cron containers interact.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File locking (Redis):&lt;/strong&gt; prevents two accesses from changing the same file simultaneously and corrupting it. The reason we built in Redis at all in part 1.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the interplay fits, &lt;code&gt;docker exec -u www-data nc-app php occ setupchecks&lt;/code&gt; reports the line &lt;code&gt;✓ Memcache: Configured&lt;/code&gt;. (Passed checks aren't shown by the web overview – only warnings and notices appear there, which is why you check this via CLI.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OPcache&lt;/strong&gt; – PHP's bytecode cache – is already sensibly preconfigured in the official &lt;code&gt;nextcloud&lt;/code&gt; image (including &lt;code&gt;opcache.interned_strings_buffer=32&lt;/code&gt;). So you don't have to adjust anything here; the formerly notorious OPcache warning no longer appears at all with current images. You can check it if needed like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; www-data nc-app php &lt;span class="nt"&gt;-i&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;opcache.interned_strings_buffer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that, the overview is warning-free:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faod122f02nnm6g6kt0k1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faod122f02nnm6g6kt0k1.png" alt="The Nextcloud admin overview shows no more yellow warnings, only two optional notices, and reports the current version" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 Generate previews proactively&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If your users store many photos, the app &lt;strong&gt;"Preview Generator"&lt;/strong&gt; is worth it: an &lt;code&gt;occ&lt;/code&gt; cron job generates thumbnails in advance instead of computing them slowly on the fly on first open. If you add it, limit the maximum preview size via &lt;code&gt;occ config:system:set preview_max_x --value=2048&lt;/code&gt; (and &lt;code&gt;preview_max_y&lt;/code&gt;), otherwise the storage demand grows quickly.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  When things go wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;After the Traefik restart, the HSTS header doesn't appear in the &lt;code&gt;curl&lt;/code&gt; output.&lt;/strong&gt; Usually the middleware isn't on the router. Check that the line &lt;code&gt;traefik.http.routers.nc.middlewares&lt;/code&gt; names &lt;strong&gt;both&lt;/strong&gt; middlewares (&lt;code&gt;nc-dav,nc-secure&lt;/code&gt;) and that the two &lt;code&gt;nc-secure&lt;/code&gt; header labels are in the &lt;code&gt;nc-app&lt;/code&gt; block. Then &lt;code&gt;docker compose up -d nc-app&lt;/code&gt;. Traefik only picks up changed labels when recreating the container.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The email test fails ("There was a problem sending the email").&lt;/strong&gt; Almost always port/encryption or authentication. Combine &lt;code&gt;SSL/TLS&lt;/code&gt; with port &lt;strong&gt;465&lt;/strong&gt; or &lt;code&gt;STARTTLS&lt;/code&gt; with port &lt;strong&gt;587&lt;/strong&gt; – not crosswise. If your provider uses 2FA, you need an &lt;strong&gt;app password&lt;/strong&gt; (see step 4). Details are in the Nextcloud log: &lt;code&gt;docker exec -u www-data nc-app php occ log:tail 20&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You can't get in yourself after enforcing 2FA.&lt;/strong&gt; You hadn't set up a second factor yet. Lift the requirement via the command line, set up your factor calmly and arm it again afterwards: &lt;code&gt;docker exec -u www-data nc-app php occ twofactorauth:enforce --off&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In the brute-force box a &lt;code&gt;172.x.x.x&lt;/code&gt; address appears instead of your real IP.&lt;/strong&gt; &lt;code&gt;TRUSTED_PROXIES&lt;/code&gt; doesn't match the actual &lt;code&gt;proxy&lt;/code&gt; subnet. Determine it with &lt;code&gt;docker network inspect proxy -f '{{(index .IPAM.Config 0).Subnet}}'&lt;/code&gt; and enter the value in the &lt;code&gt;nc-app&lt;/code&gt; environment (part 1, step 2). Otherwise the brute-force protection bans the proxy in an emergency and thus all users.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After a reboot, Nextcloud reports "Redis went away" or gets very slow.&lt;/strong&gt; The app container started before Redis. Make sure &lt;code&gt;nc-redis&lt;/code&gt; is in &lt;code&gt;depends_on&lt;/code&gt; (part 1) and runs with &lt;code&gt;restart: unless-stopped&lt;/code&gt; – then Docker catches the start-order case itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Maintenance &amp;amp; backups
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Check the overview after every update.&lt;/strong&gt; New Nextcloud versions bring new checks. After every upgrade take a look at &lt;strong&gt;Administration → Overview&lt;/strong&gt; or run &lt;code&gt;occ setupchecks&lt;/code&gt; – this way you catch new warnings early.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use the official security scan regularly.&lt;/strong&gt; The &lt;a href="https://scan.nextcloud.com/" rel="noopener noreferrer"&gt;Nextcloud Security Scan&lt;/a&gt; checks your domain from outside and assigns a grade. With HSTS and current versions, &lt;strong&gt;A/A+&lt;/strong&gt; is the realistic target; if the grade drops, an update is usually overdue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep an eye on second factors.&lt;/strong&gt; Keep the backup codes separate from the phone. If a user loses their device, you remove their factor with &lt;code&gt;occ twofactorauth:disable USER totp&lt;/code&gt;, after which they set it up anew.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clean up app passwords.&lt;/strong&gt; Every connected client (desktop, phone) gets its own app password. Under &lt;strong&gt;Personal settings → Security&lt;/strong&gt; you see all devices and can specifically revoke individual ones on loss.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The hardening replaces no backup.&lt;/strong&gt; All of this protects against foreign access, not against data loss. The consistent backup of a database dump and data directory stays mandatory – see the maintenance section in &lt;a href="https://serverkueche.de/en/tutorials/self-host-nextcloud/" rel="noopener noreferrer"&gt;part 1&lt;/a&gt; and &lt;a href="https://serverkueche.de/en/tutorials/restic-backups/" rel="noopener noreferrer"&gt;backups with Restic&lt;/a&gt;. Honest about the effort: firmly plan the monthly update pass with a quick look at the overview – then your cloud stays green permanently.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This post first appeared on &lt;a href="https://serverkueche.de/en/tutorials/harden-optimize-nextcloud/" rel="noopener noreferrer"&gt;serverkueche.de&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>nextcloud</category>
      <category>security</category>
    </item>
    <item>
      <title>First steps with a netcup VPS</title>
      <dc:creator>serverkueche.de</dc:creator>
      <pubDate>Sat, 26 Sep 2026 06:07:02 +0000</pubDate>
      <link>https://dev.to/serverkueche/first-steps-with-a-netcup-vps-2485</link>
      <guid>https://dev.to/serverkueche/first-steps-with-a-netcup-vps-2485</guid>
      <description>&lt;p&gt;You've ordered your first netcup server – congratulations! Before we fire up the kitchen, let's set the system up cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are we building?
&lt;/h2&gt;

&lt;p&gt;By the end of this tutorial, your VPS runs a current &lt;strong&gt;Debian 13&lt;/strong&gt; with all security updates, and you work with your own user with sudo rights instead of permanently as &lt;code&gt;root&lt;/code&gt;. That's the foundation for all the other recipes in the Serverküche – from hardening SSH to your first application.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 Tip&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;All commands are written for &lt;strong&gt;Debian 13&lt;/strong&gt;. On Ubuntu they work almost identically.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An ordered netcup server with the credentials from your customer account&lt;/li&gt;
&lt;li&gt;A terminal on your machine (Linux/macOS) or an SSH client like PuTTY (Windows)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Not sure which server size you even need? The &lt;a href="https://serverkueche.de/en/server-calculator/" rel="noopener noreferrer"&gt;server calculator&lt;/a&gt; estimates the RAM and CPU you'll need for your planned services and suggests a matching netcup plan. How the plans differ – VPS, VPS Lite or root server – is covered in the &lt;a href="https://serverkueche.de/en/netcup-recommendation/" rel="noopener noreferrer"&gt;server comparison&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step by step
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Log in via SSH
&lt;/h3&gt;

&lt;p&gt;Log in with your server's IP address as &lt;code&gt;root&lt;/code&gt;. You'll find the IP and the password in the credentials from your customer account:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh root@YOUR_SERVER_IP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the very first login, SSH asks whether you want to trust the server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The authenticity of host 'YOUR_SERVER_IP (YOUR_SERVER_IP)' can't be established.
ED25519 key fingerprint is SHA256:...
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Answer &lt;code&gt;yes&lt;/code&gt; – your machine remembers the server from now on. After that you land on a prompt like &lt;code&gt;root@v2200123456789012345:~#&lt;/code&gt;: netcup assigns a &lt;code&gt;v&lt;/code&gt; plus a long number as the hostname, so yours will look different.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Update the system
&lt;/h3&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt upgrade &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;apt update&lt;/code&gt; fetches the current package lists, &lt;code&gt;apt upgrade -y&lt;/code&gt; installs all available updates without prompting. Debian 13 ships &lt;strong&gt;apt 3&lt;/strong&gt; for this: instead of the long package list of earlier versions you get the affected packages in columns and a terse summary below them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Upgrading:
  bsdutils  libblkid1  liblastlog2-2  libmount1  libsmartcols1  libuuid1  login  mount  util-linux

Summary:
  Upgrading: 9, Installing: 0, Removing: 0, Not Upgrading: 0
  Download size: 2223 kB
  Space needed: 33.8 kB / 239 GB available
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;How many packages pile up on your machine depends on how old the delivered image is – the line that matters is &lt;code&gt;Summary:&lt;/code&gt;, which tells you at a glance what gets upgraded, installed and removed.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠️ Warning&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Reboot the server after a kernel update (&lt;code&gt;reboot&lt;/code&gt;) so the changes take effect. On a fresh Debian there's no automation for this yet – the safest bet is a reboot right after the first big update. Debian does &lt;strong&gt;not&lt;/strong&gt; create the marker file &lt;code&gt;/var/run/reboot-required&lt;/code&gt; (which indicates a pending reboot) on kernel updates by default – the hook that creates it only arrives with the package from &lt;a href="https://serverkueche.de/en/tutorials/unattended-upgrades-automatic-updates/" rel="noopener noreferrer"&gt;automatic updates&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 3: Create a sudo user
&lt;/h3&gt;

&lt;p&gt;Don't work as &lt;code&gt;root&lt;/code&gt; permanently: a typo with full rights can wreck the entire system, and having your own user is the prerequisite for &lt;a href="https://serverkueche.de/en/tutorials/harden-ssh/" rel="noopener noreferrer"&gt;disabling root login completely&lt;/a&gt; later. Create a user – we'll call it &lt;code&gt;koch&lt;/code&gt; ("cook") here, but you can pick any name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adduser koch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;koch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 4: Test the new user
&lt;/h3&gt;

&lt;p&gt;Check &lt;strong&gt;in a new terminal session&lt;/strong&gt; (keep the root session open!) that login and sudo work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh koch@YOUR_SERVER_IP
&lt;span class="nb"&gt;sudo whoami&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After entering your password, &lt;code&gt;sudo whoami&lt;/code&gt; should return &lt;code&gt;root&lt;/code&gt;. From now on you continue working with this user.&lt;/p&gt;

&lt;h2&gt;
  
  
  When things go wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ssh: connect to host … port 22: Connection timed out&lt;/code&gt;.&lt;/strong&gt; Usually the IP was mistyped or the server isn't fully provisioned yet. Check the IP in your customer account and whether the server is shown as "online" there. Wait a few minutes after ordering.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Permission denied, please try again&lt;/code&gt; on root login.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!&lt;/code&gt;.&lt;/strong&gt; The server was reinstalled and has a new host key – SSH rightly raises the alarm. If &lt;strong&gt;you&lt;/strong&gt; reinstalled it, remove the old entry with &lt;code&gt;ssh-keygen -R YOUR_SERVER_IP&lt;/code&gt; and reconnect. If you didn't reinstall, investigate before you connect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;sudo: command not found&lt;/code&gt; as the new user.&lt;/strong&gt; On minimal images the package is sometimes missing. Install it as &lt;code&gt;root&lt;/code&gt;: &lt;code&gt;apt install sudo&lt;/code&gt;. Then check that the user is in the group: &lt;code&gt;groups koch&lt;/code&gt; must contain &lt;code&gt;sudo&lt;/code&gt; – otherwise repeat step 3 and log out and back in once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Maintenance &amp;amp; backups
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Updates:&lt;/strong&gt; You should schedule &lt;code&gt;sudo apt update &amp;amp;&amp;amp; sudo apt upgrade&lt;/code&gt; at least &lt;strong&gt;weekly&lt;/strong&gt; – or &lt;a href="https://serverkueche.de/en/tutorials/unattended-upgrades-automatic-updates/" rel="noopener noreferrer"&gt;automate security updates&lt;/a&gt; with &lt;code&gt;unattended-upgrades&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Snapshots:&lt;/strong&gt; Create a snapshot in the &lt;a href="https://serverkueche.de/en/tutorials/netcup-snapshots-scp/" rel="noopener noreferrer"&gt;netcup Server Control Panel (SCP)&lt;/a&gt; before bigger changes. It's your safety line while you don't have a proper backup strategy yet – but a snapshot does &lt;strong&gt;not replace&lt;/strong&gt; a backup outside the server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credentials:&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You'll find both in the server overview of the SCP: the &lt;strong&gt;VNC console&lt;/strong&gt; is under the &lt;strong&gt;"Screen"&lt;/strong&gt; tab (called &lt;strong&gt;"Bildschirm"&lt;/strong&gt; if your panel is set to German), and the remaining &lt;strong&gt;snapshot quota&lt;/strong&gt; is shown in the status block.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwjpbuyc39cev4ctsv1zc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwjpbuyc39cev4ctsv1zc.png" alt="The server overview in the netcup Server Control Panel: tabs including " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post first appeared on &lt;a href="https://serverkueche.de/en/tutorials/first-steps-netcup-vps/" rel="noopener noreferrer"&gt;serverkueche.de&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>linux</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>The essential terminal commands for your server</title>
      <dc:creator>serverkueche.de</dc:creator>
      <pubDate>Fri, 25 Sep 2026 05:55:48 +0000</pubDate>
      <link>https://dev.to/serverkueche/the-essential-terminal-commands-for-your-server-dcn</link>
      <guid>https://dev.to/serverkueche/the-essential-terminal-commands-for-your-server-dcn</guid>
      <description>&lt;p&gt;Whoever runs their own server lives (at least occasionally) in the terminal. This article is your toolbox: the commands you really need – once for &lt;strong&gt;debugging&lt;/strong&gt; when the server suddenly crawls, and once for &lt;strong&gt;daily work&lt;/strong&gt;. Not a dry manual, but the selection that has proven itself in everyday use.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are we building?
&lt;/h2&gt;

&lt;p&gt;No setup this time, but a &lt;strong&gt;battle-tested reference&lt;/strong&gt;. We go through two toolboxes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Debugging performance&lt;/strong&gt; – when the server gets sluggish: &lt;code&gt;top&lt;/code&gt;, &lt;code&gt;htop&lt;/code&gt;, &lt;code&gt;btop&lt;/code&gt;, &lt;code&gt;ctop&lt;/code&gt;, &lt;code&gt;iotop&lt;/code&gt;, &lt;code&gt;nload&lt;/code&gt;, &lt;code&gt;ncdu&lt;/code&gt; and &lt;code&gt;kill&lt;/code&gt;. With these you find out &lt;em&gt;what&lt;/em&gt; is eating the resources, and intervene.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Daily work&lt;/strong&gt; – the commands you type constantly: &lt;code&gt;ls&lt;/code&gt;, &lt;code&gt;cd&lt;/code&gt;, &lt;code&gt;nano&lt;/code&gt;, &lt;code&gt;wget&lt;/code&gt;, &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;dig&lt;/code&gt;, &lt;code&gt;tmux&lt;/code&gt;, &lt;code&gt;history&lt;/code&gt;, &lt;code&gt;sudo&lt;/code&gt;, &lt;code&gt;scp&lt;/code&gt;, &lt;code&gt;rsync&lt;/code&gt;, &lt;code&gt;dd&lt;/code&gt; and &lt;code&gt;ssh-agent&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All examples ran on a real &lt;strong&gt;Debian 13&lt;/strong&gt; server; the outputs shown are real. The interactive full-screen tools (top, htop, btop, ctop, nload, ncdu) we additionally show you in a picture, so you recognize the interface – the copyable single commands stay deliberately as text to type out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A server you can log into via SSH – e.g. from &lt;a href="https://serverkueche.de/en/tutorials/first-steps-netcup-vps/" rel="noopener noreferrer"&gt;First steps with the netcup VPS&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;A user with &lt;code&gt;sudo&lt;/code&gt; rights to install packages.&lt;/li&gt;
&lt;li&gt;No fear of the command line – we'll shed that together here.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 Install first, then use&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;top&lt;/code&gt;, &lt;code&gt;kill&lt;/code&gt;, &lt;code&gt;ls&lt;/code&gt;, &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;dig&lt;/code&gt;, &lt;code&gt;scp&lt;/code&gt;, &lt;code&gt;dd&lt;/code&gt; and &lt;code&gt;ssh-agent&lt;/code&gt; are usually already there on Debian. The more comfortable tools you install in one go:&lt;/p&gt;


&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; htop btop iotop nload ncdu tmux rsync
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;&lt;code&gt;ctop&lt;/code&gt; is not in the package sources – we'll fetch it directly as a ready-made file in the corresponding section.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step by step
&lt;/h2&gt;

&lt;p&gt;First the debugging toolbox – the commands for the moment the server is stuck. Then the tools for daily work.&lt;/p&gt;

&lt;h3&gt;
  
  
  top and htop: the first look
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;top&lt;/code&gt; is present on every Linux and shows the running processes live, sorted by CPU load. For a quick look it's enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;top
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F43qhrbqohs05316t2r7x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F43qhrbqohs05316t2r7x.png" alt="The top output with CPU state line, memory line and the process list sorted by CPU load" width="800" height="790"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At the top you see load average, memory and the process list. The most important – and most cryptic – line is &lt;strong&gt;&lt;code&gt;%Cpu(s)&lt;/code&gt;&lt;/strong&gt;. Its eight values tell you &lt;em&gt;what&lt;/em&gt; the CPU is busy with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;us&lt;/code&gt;&lt;/strong&gt; (user) – normal programs in userspace (your apps, container processes).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;sy&lt;/code&gt;&lt;/strong&gt; (system) – the kernel itself (system calls, drivers, network stack).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ni&lt;/code&gt;&lt;/strong&gt; (nice) – userspace processes with lowered priority (started via &lt;code&gt;nice&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;id&lt;/code&gt;&lt;/strong&gt; (idle) – idling. &lt;strong&gt;High &lt;code&gt;id&lt;/code&gt; = plenty of headroom&lt;/strong&gt;, low &lt;code&gt;id&lt;/code&gt; = the CPU is maxed out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;wa&lt;/code&gt;&lt;/strong&gt; (io-wait) – the CPU is &lt;strong&gt;waiting for the disk&lt;/strong&gt;. Consistently high means: your bottleneck is the disk, not the compute power (then continue with &lt;code&gt;iotop&lt;/code&gt;, see below).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;hi&lt;/code&gt;&lt;/strong&gt; (hardware interrupts) – time for hardware interrupts (devices reporting in).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;si&lt;/code&gt;&lt;/strong&gt; (software interrupts) – time for software interrupts (often network processing).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;st&lt;/code&gt;&lt;/strong&gt; (steal) – &lt;strong&gt;only on VPS/virtual servers&lt;/strong&gt;: compute time the hypervisor "stole" from your VM because other guests ran on the same host. Consistently high &lt;code&gt;st&lt;/code&gt; means the host is oversubscribed – a reason to question the plan or provider.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With &lt;code&gt;q&lt;/code&gt; you quit the view. But &lt;code&gt;top&lt;/code&gt; is spartan – the successor &lt;code&gt;htop&lt;/code&gt; is much more readable: colored bars per CPU core, mouse operation and easy sorting.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;htop
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjety4vdk0tjgsd8sk7uk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjety4vdk0tjgsd8sk7uk.png" alt="The htop dashboard with colored CPU and memory bars and the process list" width="800" height="481"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At the top, watch the &lt;strong&gt;CPU bars&lt;/strong&gt; (one bar per core) and the &lt;strong&gt;Mem&lt;/strong&gt; bar. In the list you sort with &lt;code&gt;F6&lt;/code&gt; by a column (e.g. &lt;code&gt;%MEM&lt;/code&gt;), search a process with &lt;code&gt;F3&lt;/code&gt; and end it with &lt;code&gt;F9&lt;/code&gt;. Exactly here you find the service that's currently slowing everything down.&lt;/p&gt;

&lt;h3&gt;
  
  
  btop: the fancy system monitor
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;btop&lt;/code&gt; (version 1.3.2 in the test) goes one step further: CPU, memory, disk I/O and network in one pretty, mouse-operable dashboard – all at a glance.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;btop
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgrbqtrmkxewv76d5ngxd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgrbqtrmkxewv76d5ngxd.png" alt="The btop dashboard with a CPU usage graph, memory, disk and network display plus a process list" width="800" height="394"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For the daily health check, &lt;code&gt;btop&lt;/code&gt; is our favorite because you see network and disk right along and don't need several tools. Quit with &lt;code&gt;q&lt;/code&gt;. &lt;code&gt;Esc&lt;/code&gt; does &lt;strong&gt;not&lt;/strong&gt; quit – it opens the main menu (options, help, quit); a second &lt;code&gt;Esc&lt;/code&gt; closes it again.&lt;/p&gt;

&lt;h3&gt;
  
  
  ctop: monitor Docker containers live
&lt;/h3&gt;

&lt;p&gt;If your server runs with &lt;a href="https://serverkueche.de/en/tutorials/install-docker/" rel="noopener noreferrer"&gt;Docker&lt;/a&gt;, you want to know which &lt;strong&gt;container&lt;/strong&gt; draws how much. &lt;code&gt;htop&lt;/code&gt; only shows processes – &lt;code&gt;ctop&lt;/code&gt; shows containers. It's not in the package sources, so we fetch the ready-made file (version 0.7.7):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;curl &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /usr/local/bin/ctop https://github.com/bcicen/ctop/releases/download/v0.7.7/ctop-0.7.7-linux-amd64
&lt;span class="nb"&gt;sudo chmod&lt;/span&gt; +x /usr/local/bin/ctop
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We deliberately download the file with a full path and make it executable – no blind &lt;code&gt;curl … | bash&lt;/code&gt;. Then you simply start it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ctop
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/terminal-ctop.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/terminal-ctop.png" title="ctop: each container one row – here \" alt="The ctop overview with one row per container and columns for CPU, memory, network and I/O" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You get one row per container with CPU, RAM, network and disk I/O, updated live. With Enter on a container you open a menu to stop, restart or show logs – ideal when a single service runs amok.&lt;/p&gt;

&lt;h3&gt;
  
  
  iotop and nload: disk and network
&lt;/h3&gt;

&lt;p&gt;Sometimes the problem isn't the CPU but the &lt;strong&gt;disk&lt;/strong&gt;. &lt;code&gt;iotop&lt;/code&gt; shows which process is currently reading and writing (needs root):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;iotop
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sorted by write/read rate you immediately spot the I/O hog – often a backup, a database or a log gone out of control. For the &lt;strong&gt;network&lt;/strong&gt;, &lt;code&gt;nload&lt;/code&gt; does the same: incoming and outgoing throughput in real time, with a small graph.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8j7rqm7b2b5ge4alq59h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8j7rqm7b2b5ge4alq59h.png" alt="The nload view with bar graphs for incoming and outgoing network throughput on eth0" width="800" height="394"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With the arrow keys you switch between the network interfaces (or you specify the desired one directly, e.g. &lt;code&gt;nload eth0&lt;/code&gt;). Handy to check whether a large upload is running – or whether unexpected traffic is clogging the connection.&lt;/p&gt;

&lt;h3&gt;
  
  
  ncdu: find the storage hog
&lt;/h3&gt;

&lt;p&gt;The most common cause of a broken server is a &lt;strong&gt;full disk&lt;/strong&gt;. The rough overview is given by &lt;code&gt;df&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; /
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Filesystem      Size  Used Avail Use% Mounted on
/dev/vda4       251G   11G  230G   5% /
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;df&lt;/code&gt; tells you &lt;em&gt;that&lt;/em&gt; it's getting tight, but not &lt;em&gt;where&lt;/em&gt;. That's what &lt;code&gt;ncdu&lt;/code&gt; does – an interactive directory analyzer that shows you which folders take up the space:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ncdu /
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6up1swhcq6vtd0ux3q80.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6up1swhcq6vtd0ux3q80.png" alt="The ncdu view with the largest directories under /usr, each with a bar and size" width="800" height="394"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ncdu&lt;/code&gt; scans and lists the largest directories at the top. With the arrow keys you navigate in, with &lt;code&gt;d&lt;/code&gt; you delete specifically (careful!). This is how you find the huge log file or the forgotten backup folder in seconds.&lt;/p&gt;

&lt;h3&gt;
  
  
  kill: end hanging processes
&lt;/h3&gt;

&lt;p&gt;If in &lt;code&gt;htop&lt;/code&gt; or &lt;code&gt;top&lt;/code&gt; you found the &lt;strong&gt;PID&lt;/strong&gt; (process ID) of the culprit, you end it with &lt;code&gt;kill&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;kill &lt;/span&gt;12345
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That sends a polite "please quit" (signal &lt;code&gt;TERM&lt;/code&gt;) that allows the process to clean up properly. If it doesn't react, the hard variant follows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;kill&lt;/span&gt; &lt;span class="nt"&gt;-9&lt;/span&gt; 12345
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-9&lt;/code&gt; (signal &lt;code&gt;KILL&lt;/code&gt;) ends immediately and mercilessly. &lt;strong&gt;Use it only as a last resort&lt;/strong&gt; – the process can no longer save any data. If you only know the name, &lt;code&gt;pkill nginx&lt;/code&gt; helps, which hits all matching processes.&lt;/p&gt;

&lt;p&gt;That ends the debugging box. Now the tools for daily work.&lt;/p&gt;

&lt;h3&gt;
  
  
  ls, cd and nano: navigate and edit
&lt;/h3&gt;

&lt;p&gt;The basic framework of every terminal session. &lt;code&gt;cd&lt;/code&gt; changes the directory, &lt;code&gt;ls&lt;/code&gt; lists the contents. Most useful is &lt;code&gt;ls&lt;/code&gt; with options:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-lh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-l&lt;/code&gt; shows details (permissions, owner, size, date), &lt;code&gt;-h&lt;/code&gt; makes the sizes readable (&lt;code&gt;4.0K&lt;/code&gt; instead of &lt;code&gt;4096&lt;/code&gt;). With &lt;code&gt;ls -la&lt;/code&gt; you also see hidden files (those starting with &lt;code&gt;.&lt;/code&gt;). For editing configuration files, &lt;code&gt;nano&lt;/code&gt; is the most beginner-friendly editor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nano /etc/hosts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At the bottom are the shortcuts: &lt;code&gt;Ctrl+O&lt;/code&gt; saves ("Write Out"), &lt;code&gt;Ctrl+X&lt;/code&gt; quits. No cryptic vim – exactly right to quickly change a line.&lt;/p&gt;

&lt;h3&gt;
  
  
  wget, curl and dig: fetch data and check the network
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;wget&lt;/code&gt; downloads files – ideal for downloads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wget https://YOUR_SOURCE/file.tar.gz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;curl&lt;/code&gt; is the Swiss Army knife for HTTP. Especially valuable: viewing only the &lt;strong&gt;response headers&lt;/strong&gt; to check what a server returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-I&lt;/span&gt; https://example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP/2 200
date: Sat, 18 Jul 2026 09:55:49 GMT
content-type: text/html
server: cloudflare
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Status &lt;code&gt;200&lt;/code&gt; means "all ok", &lt;code&gt;301&lt;/code&gt;/&lt;code&gt;302&lt;/code&gt; are redirects, &lt;code&gt;404&lt;/code&gt; "not found". For DNS questions, &lt;code&gt;dig&lt;/code&gt; is the tool – e.g. to check whether your domain points to the right server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short example.com A
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;172.66.147.243
104.20.23.154
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;+short&lt;/code&gt; delivers only the pure answer (the IP address). Without the switch you see the full report including response time – indispensable before Traefik can fetch a certificate (see &lt;a href="https://serverkueche.de/en/tutorials/connect-domain-to-server/" rel="noopener noreferrer"&gt;connecting a domain to your server&lt;/a&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  tmux: the session that never breaks off
&lt;/h3&gt;

&lt;p&gt;The most important tool for remote maintenance. If you start a long command (update, backup, compiling) over SSH and your connection breaks, &lt;strong&gt;normally your process dies too&lt;/strong&gt;. &lt;code&gt;tmux&lt;/code&gt; solves that: it keeps your session alive on the server, whether or not you're connected.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tmux
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You land in a tmux session and work normally. If the connection breaks off (or you deliberately close with &lt;code&gt;Ctrl+B&lt;/code&gt;, then &lt;code&gt;D&lt;/code&gt; – "detach"), everything keeps running. On the next login you attach again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tmux attach
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;tmux can do much more (split windows, several panes). The shortcuts all begin with the "prefix" &lt;code&gt;Ctrl+B&lt;/code&gt;. A compact overview of all shortcuts is on the &lt;a href="https://tmuxcheatsheet.com/" rel="noopener noreferrer"&gt;tmux cheatsheet&lt;/a&gt; – worth printing out.&lt;/p&gt;

&lt;h3&gt;
  
  
  scp, rsync and dd: copy and transfer
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;scp&lt;/code&gt; copies files over SSH – from the local machine to the server and back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scp file.txt YOUR_USER@YOUR_SERVER_IP:/opt/target/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For anything beyond single files, &lt;code&gt;rsync&lt;/code&gt; is superior: it transfers only &lt;em&gt;changes&lt;/em&gt;, can resume, and synchronizes entire directory trees. Always test first with &lt;code&gt;-n&lt;/code&gt; (dry run) before you really copy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rsync &lt;span class="nt"&gt;-avn&lt;/span&gt; /source/ /target/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sending incremental file list
created directory /target
./
file.txt
sub/
sub/more.txt

sent 136 bytes  received 59 bytes  390.00 bytes/sec
total size is 11  speedup is 0.06 (DRY RUN)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-a&lt;/code&gt; preserves permissions and timestamps, &lt;code&gt;-v&lt;/code&gt; is verbose, &lt;code&gt;-n&lt;/code&gt; only shows &lt;em&gt;what would happen&lt;/em&gt;. If it looks good, you leave out the &lt;code&gt;-n&lt;/code&gt;. &lt;code&gt;rsync&lt;/code&gt; also works over SSH (&lt;code&gt;rsync -av /source/ user@server:/target/&lt;/code&gt;) and is thus the backbone of many backup scripts.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;dd&lt;/code&gt;, finally, copies &lt;strong&gt;raw, block by block&lt;/strong&gt; – e.g. to write a boot image onto a USB stick. It's powerful and merciless (more on that below):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo dd &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;image.iso &lt;span class="nv"&gt;of&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/dev/sdX &lt;span class="nv"&gt;bs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4M &lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;progress
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  history, sudo and ssh-agent: the little helpers
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;history&lt;/code&gt; shows your most recently typed commands with a number – handy to find a long command from yesterday again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;history&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;docker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;!123&lt;/code&gt; you run the command with number 123 again, with &lt;code&gt;Ctrl+R&lt;/code&gt; you search interactively backwards. &lt;code&gt;sudo&lt;/code&gt; runs a single command with administrator rights – always only as much privilege as needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart docker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ssh-agent&lt;/code&gt;, finally, remembers your SSH key password for the session so you don't have to retype it on every connection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;ssh-agent &lt;span class="nt"&gt;-s&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
ssh-add ~/.ssh/id_ed25519
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that you log in without entering the passphrase again – until you log out. More on secure SSH keys is in &lt;a href="https://serverkueche.de/en/tutorials/harden-ssh/" rel="noopener noreferrer"&gt;hardening SSH&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When things go wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;dd&lt;/code&gt; overwrote the wrong disk – data gone.&lt;/strong&gt; &lt;code&gt;dd&lt;/code&gt; doesn't ask and isn't mockingly called "disk destroyer" for nothing. Check the target (&lt;code&gt;of=&lt;/code&gt;) &lt;strong&gt;always&lt;/strong&gt; beforehand with &lt;code&gt;lsblk&lt;/code&gt;, and never confuse &lt;code&gt;sda&lt;/code&gt; with &lt;code&gt;sdb&lt;/code&gt;. There's no undo – when in doubt, read it three times.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;kill -9&lt;/code&gt; ended a service, but the database is corrupted afterwards.&lt;/strong&gt; &lt;code&gt;-9&lt;/code&gt; gives the process no chance to close cleanly. Always first use &lt;code&gt;kill&lt;/code&gt; without &lt;code&gt;-9&lt;/code&gt; and give the process a few seconds. Only when it really hangs does the hard variant follow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;rsync&lt;/code&gt; copied (or deleted) much more than expected.&lt;/strong&gt; The &lt;strong&gt;trailing slash&lt;/strong&gt; decides: &lt;code&gt;rsync -a /source/&lt;/code&gt; copies the &lt;em&gt;contents&lt;/em&gt; of &lt;code&gt;source&lt;/code&gt;, &lt;code&gt;rsync -a /source&lt;/code&gt; copies the &lt;em&gt;folder&lt;/em&gt; along with it. And &lt;code&gt;--delete&lt;/code&gt; deletes everything in the target that's missing in the source. Test with &lt;code&gt;-n&lt;/code&gt; first, always.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A tool reports "command not found".&lt;/strong&gt; The package isn't installed. Install it (see tip box above) or check the name. Some tools like &lt;code&gt;iotop&lt;/code&gt; additionally need &lt;code&gt;sudo&lt;/code&gt; to see any data at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After &lt;code&gt;tmux&lt;/code&gt;, on the next login "everything is gone".&lt;/strong&gt; You started a &lt;em&gt;new&lt;/em&gt; session instead of attaching. &lt;code&gt;tmux ls&lt;/code&gt; lists running sessions, &lt;code&gt;tmux attach -t 0&lt;/code&gt; attaches you to the first. Just &lt;code&gt;tmux&lt;/code&gt; on its own creates a fresh one every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Maintenance &amp;amp; backups
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;These commands are your backup tool.&lt;/strong&gt; &lt;code&gt;rsync&lt;/code&gt;, &lt;code&gt;scp&lt;/code&gt; and &lt;code&gt;dd&lt;/code&gt; are the foundation of every backup strategy. For real, encrypted off-site backups you build on this – see &lt;a href="https://serverkueche.de/en/tutorials/restic-backups/" rel="noopener noreferrer"&gt;backups with Restic&lt;/a&gt;, which internally uses the same logic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Help yourself.&lt;/strong&gt; Every command brings its own docs: &lt;code&gt;man rsync&lt;/code&gt; opens the manual, &lt;code&gt;rsync --help&lt;/code&gt; shows the options compactly. For brief practical examples &lt;code&gt;tldr&lt;/code&gt; is worth it, which instead of pages-long man pages shows the three or four most common use cases. There is no package called &lt;code&gt;tldr&lt;/code&gt; in Debian 13 (&lt;code&gt;apt&lt;/code&gt; answers with &lt;code&gt;Package 'tldr' has no installation candidate&lt;/code&gt;) – the command lives in the &lt;code&gt;tealdeer&lt;/code&gt; package. Install it with &lt;code&gt;sudo apt install -y tealdeer&lt;/code&gt;, then run &lt;code&gt;tldr --update&lt;/code&gt; once (it downloads the example collection, otherwise you only get &lt;code&gt;Page cache not found&lt;/code&gt;). After that, &lt;code&gt;tldr rsync&lt;/code&gt; shows the typical invocations on half a page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stay current.&lt;/strong&gt; Keep the tools fresh via &lt;code&gt;sudo apt update &amp;amp;&amp;amp; sudo apt upgrade&lt;/code&gt;; &lt;code&gt;ctop&lt;/code&gt; you update by downloading the new release file again. The versions are named deliberately (state of the test) – newer ones generally work just the same.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Honest about the effort:&lt;/strong&gt; you don't have to memorize this reference. Bookmark it and come back when the server is stuck. After a few weeks the most important moves stick on their own.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This post first appeared on &lt;a href="https://serverkueche.de/en/tutorials/essential-terminal-commands/" rel="noopener noreferrer"&gt;serverkueche.de&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>cli</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>Hardening SSH access</title>
      <dc:creator>serverkueche.de</dc:creator>
      <pubDate>Fri, 25 Sep 2026 05:55:29 +0000</pubDate>
      <link>https://dev.to/serverkueche/hardening-ssh-access-46d3</link>
      <guid>https://dev.to/serverkueche/hardening-ssh-access-46d3</guid>
      <description>&lt;p&gt;After the &lt;a href="https://serverkueche.de/en/tutorials/first-steps-netcup-vps/" rel="noopener noreferrer"&gt;initial setup&lt;/a&gt; we harden the SSH access – the most important door to your server.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are we building?
&lt;/h2&gt;

&lt;p&gt;By the end you log in with an &lt;strong&gt;SSH key&lt;/strong&gt; 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 &lt;strong&gt;OpenSSH 10.0p2 on Debian 13&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;a href="https://serverkueche.de/en/tutorials/first-steps-netcup-vps/" rel="noopener noreferrer"&gt;set-up server&lt;/a&gt; with a sudo user&lt;/li&gt;
&lt;li&gt;Access to the VNC console in the netcup SCP as a safety line in case you lock yourself out&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step by step
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Generate an SSH key pair
&lt;/h3&gt;

&lt;p&gt;If you don't have a key yet, generate an Ed25519 key pair &lt;strong&gt;on your own machine&lt;/strong&gt; (not on the server!):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; ed25519
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can accept the suggested storage location with Enter. Set a &lt;strong&gt;passphrase&lt;/strong&gt; – it protects the key if your machine falls into the wrong hands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Generating public/private ed25519 key pair.
Enter file in which to save the key (/home/YOUR_USER/.ssh/id_ed25519):
Created directory '/home/YOUR_USER/.ssh'.
Enter passphrase for "/home/YOUR_USER/.ssh/id_ed25519" (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /home/YOUR_USER/.ssh/id_ed25519
Your public key has been saved in /home/YOUR_USER/.ssh/id_ed25519.pub
The key fingerprint is:
SHA256:... koch@your-laptop
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Created directory&lt;/code&gt; line only appears if you didn't have a &lt;code&gt;~/.ssh&lt;/code&gt; yet. After that comes the "randomart image", a small ASCII graphic of the fingerprint – you can ignore it. What matters are the two files: &lt;code&gt;~/.ssh/id_ed25519&lt;/code&gt; (private, stays with you) and &lt;code&gt;~/.ssh/id_ed25519.pub&lt;/code&gt; (public, goes on the server).&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Transfer the public key
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ssh-copy-id&lt;/code&gt; appends your public key to the &lt;code&gt;~/.ssh/authorized_keys&lt;/code&gt; file of your server user – with the correct file permissions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-copy-id koch@YOUR_SERVER_IP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server asks for the user's &lt;strong&gt;password&lt;/strong&gt; one last time here – after this it won't again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/usr/bin/ssh-copy-id: INFO: Source of key(s) to be installed: "/home/YOUR_USER/.ssh/id_ed25519.pub"
/usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed
/usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys
koch@YOUR_SERVER_IP's password:

Number of key(s) added: 1

Now try logging into the machine, with: "ssh 'koch@YOUR_SERVER_IP'"
and check to make sure that only the key(s) you wanted were added.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Number of key(s) added: 1&lt;/code&gt; is the success message. On a second run you get &lt;code&gt;WARNING: All keys were skipped because they already exist on the remote system.&lt;/code&gt; instead – then the key is already in place. Test right afterwards that key login works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh koch@YOUR_SERVER_IP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should now be logged in &lt;strong&gt;without&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Disable password login and root login
&lt;/h3&gt;

&lt;p&gt;Open the SSH server configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nano /etc/ssh/sshd_config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set (or uncomment) these two lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PermitRootLogin no
PasswordAuthentication no
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;PermitRootLogin no&lt;/code&gt; blocks direct root login completely, &lt;code&gt;PasswordAuthentication no&lt;/code&gt; allows only key logins.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠️ Warning&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;On cloud images there are often extra files under &lt;code&gt;/etc/ssh/sshd_config.d/&lt;/code&gt; that &lt;strong&gt;override&lt;/strong&gt; these settings. Check with &lt;code&gt;grep -r PasswordAuthentication /etc/ssh/sshd_config.d/&lt;/code&gt; and adjust any matches as well.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 4: Test the configuration and restart SSH
&lt;/h3&gt;

&lt;p&gt;Check the configuration for syntax errors &lt;strong&gt;before&lt;/strong&gt; you restart – a typo would otherwise take the SSH service down:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;sshd &lt;span class="nt"&gt;-t&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No output means: all good. Then apply it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart ssh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;🛑 Caution&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Test key login now in a &lt;strong&gt;second&lt;/strong&gt; terminal session before you close the first – otherwise you might lock yourself out.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;To verify: &lt;code&gt;sudo systemctl status ssh&lt;/code&gt; should show &lt;code&gt;active (running)&lt;/code&gt;, and a login attempt as root must now be rejected with &lt;code&gt;Permission denied (publickey)&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When things go wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;SSH keeps asking for the password despite &lt;code&gt;ssh-copy-id&lt;/code&gt;.&lt;/strong&gt; Usually the file permissions on the server are wrong: SSH ignores &lt;code&gt;authorized_keys&lt;/code&gt; if group or others may write to &lt;code&gt;~/.ssh&lt;/code&gt;. The log on the server (&lt;code&gt;sudo journalctl -u ssh --since -5min&lt;/code&gt;) then shows &lt;code&gt;Authentication refused: bad ownership or modes for directory /home/koch/.ssh&lt;/code&gt;. Fix it with &lt;code&gt;chmod 700 ~/.ssh &amp;amp;&amp;amp; chmod 600 ~/.ssh/authorized_keys&lt;/code&gt;, and also check that you're connecting with the right user (&lt;code&gt;koch@…&lt;/code&gt;, not &lt;code&gt;root@…&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Permission denied (publickey)&lt;/code&gt; – and you can't get in at all anymore.&lt;/strong&gt; Password login was disabled before the key worked. No drama: open the &lt;strong&gt;VNC console in the netcup SCP&lt;/strong&gt;, log in locally there, set &lt;code&gt;PasswordAuthentication yes&lt;/code&gt;, restart SSH and start again at step 2.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After the restart the SSH service no longer starts.&lt;/strong&gt; Syntax error in the &lt;code&gt;sshd_config&lt;/code&gt; – a mistyped &lt;code&gt;PasswordAuthentification&lt;/code&gt; is enough (that's why you always run &lt;code&gt;sshd -t&lt;/code&gt; before restarting). Log in via the VNC console; &lt;code&gt;sudo sshd -t&lt;/code&gt; then names file, line and option: &lt;code&gt;/etc/ssh/sshd_config: line 125: Bad configuration option: PasswordAuthentification&lt;/code&gt;, followed by &lt;code&gt;/etc/ssh/sshd_config: terminating, 1 bad configuration options&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The settings seem to take effect, but password login still works.&lt;/strong&gt; A file under &lt;code&gt;/etc/ssh/sshd_config.d/&lt;/code&gt; (often &lt;code&gt;50-cloud-init.conf&lt;/code&gt;) overrides your values. &lt;code&gt;sudo sshd -T | grep -i passwordauthentication&lt;/code&gt; shows the actually effective setting – adjust matches in the extra files and restart SSH.&lt;/p&gt;

&lt;h2&gt;
  
  
  Maintenance &amp;amp; backups
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Back up the private key:&lt;/strong&gt; Without &lt;code&gt;~/.ssh/id_ed25519&lt;/code&gt; 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 &lt;code&gt;authorized_keys&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep an eye on logins:&lt;/strong&gt; &lt;code&gt;sudo journalctl -u ssh --since today&lt;/code&gt; shows you all login attempts. OpenSSH 10 logs the individual connections under the name &lt;code&gt;sshd-session&lt;/code&gt;, for example as &lt;code&gt;Connection closed by authenticating user root 203.0.113.10 port 50718 [preauth]&lt;/code&gt;. Failed attempts on port 22 are normal and, thanks to the key requirement, harmless – you can automatically ban such bots later with &lt;a href="https://serverkueche.de/en/tutorials/fail2ban-setup/" rel="noopener noreferrer"&gt;Fail2ban&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Updates:&lt;/strong&gt; OpenSSH gets its security updates through the normal &lt;code&gt;apt upgrade&lt;/code&gt; – so keep the server up to date, ideally &lt;a href="https://serverkueche.de/en/tutorials/unattended-upgrades-automatic-updates/" rel="noopener noreferrer"&gt;automated&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This post first appeared on &lt;a href="https://serverkueche.de/en/tutorials/harden-ssh/" rel="noopener noreferrer"&gt;serverkueche.de&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>security</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>Connecting a domain to your server (DNS basics)</title>
      <dc:creator>serverkueche.de</dc:creator>
      <pubDate>Fri, 25 Sep 2026 05:55:07 +0000</pubDate>
      <link>https://dev.to/serverkueche/connecting-a-domain-to-your-server-dns-basics-55p6</link>
      <guid>https://dev.to/serverkueche/connecting-a-domain-to-your-server-dns-basics-55p6</guid>
      <description>&lt;p&gt;So far you only reach your server via its IP address. For real services you need a &lt;strong&gt;domain&lt;/strong&gt; – and not just for looks: without a publicly resolving domain, &lt;a href="https://serverkueche.de/en/tutorials/traefik-reverse-proxy/" rel="noopener noreferrer"&gt;Traefik&lt;/a&gt; can't fetch a &lt;strong&gt;TLS certificate&lt;/strong&gt; from Let's Encrypt later. This tutorial connects your domain to the server and explains the three terms beginners get stuck on: A record, TTL and propagation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are we building?
&lt;/h2&gt;

&lt;p&gt;By the end, your domain (&lt;code&gt;YOUR_DOMAIN&lt;/code&gt;) points to your server IP via an &lt;strong&gt;A record&lt;/strong&gt; (IPv4) and – if your server has IPv6 – an &lt;strong&gt;AAAA record&lt;/strong&gt;, including &lt;code&gt;www&lt;/code&gt;. You can verify this yourself with &lt;code&gt;dig&lt;/code&gt; and understand why a change isn't always visible immediately. Optionally you also set up &lt;strong&gt;reverse DNS (PTR)&lt;/strong&gt; – important as soon as your server sends emails. This lays the foundation for all publicly reachable services.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;a href="https://serverkueche.de/en/tutorials/first-steps-netcup-vps/" rel="noopener noreferrer"&gt;set-up server&lt;/a&gt; with a known public IP address&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;registered domain&lt;/strong&gt; (at netcup, another registrar, or included in your hosting package)&lt;/li&gt;
&lt;li&gt;Access to the &lt;strong&gt;DNS management&lt;/strong&gt; of your domain (at netcup: the customer account CCP)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step by step
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Find the server IP
&lt;/h3&gt;

&lt;p&gt;Connect via SSH and display the public IP addresses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip &lt;span class="nt"&gt;-4&lt;/span&gt; addr show scope global
ip &lt;span class="nt"&gt;-6&lt;/span&gt; addr show scope global
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The IPv4 address is after &lt;code&gt;inet&lt;/code&gt; (e.g. &lt;code&gt;203.0.113.10&lt;/code&gt;), any IPv6 address after &lt;code&gt;inet6&lt;/code&gt; (does not start with &lt;code&gt;fe80::&lt;/code&gt;, that would only be link-local). If Docker is already running on the server, private addresses from &lt;code&gt;172.16.0.0/12&lt;/code&gt; on &lt;code&gt;docker0&lt;/code&gt;/&lt;code&gt;br-…&lt;/code&gt; show up as well – container networks that don't belong in DNS. You need the one on &lt;code&gt;eth0&lt;/code&gt;. Alternatively you'll find both in the netcup &lt;strong&gt;Server Control Panel (SCP)&lt;/strong&gt; in the server overview.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 Tip&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Note down both addresses. If your server has &lt;strong&gt;no&lt;/strong&gt; global IPv6 address, you simply don't create an AAAA record later – that's perfectly fine.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 2: Create A and AAAA records
&lt;/h3&gt;

&lt;p&gt;Open the DNS management of your domain. There you enter &lt;strong&gt;resource records&lt;/strong&gt; – the mapping of names to addresses. Create:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Host / Name&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;A&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;YOUR_SERVER_IPV4&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;A&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;www&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;YOUR_SERVER_IPV4&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AAAA&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;YOUR_SERVER_IPV6&lt;/code&gt; &lt;em&gt;(only if available)&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AAAA&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;www&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;YOUR_SERVER_IPV6&lt;/code&gt; &lt;em&gt;(only if available)&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@&lt;/code&gt;&lt;/strong&gt; stands for the domain itself (&lt;code&gt;YOUR_DOMAIN&lt;/code&gt;), &lt;code&gt;www&lt;/code&gt; for the subdomain &lt;code&gt;www.YOUR_DOMAIN&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A&lt;/strong&gt; points to an IPv4 address, &lt;strong&gt;AAAA&lt;/strong&gt; to an IPv6 address. The name "AAAA" comes from an IPv6 address being four times as long as an IPv4 address.&lt;/li&gt;
&lt;li&gt;For individual services you can later create specific subdomains (e.g. &lt;code&gt;cloud&lt;/code&gt;, &lt;code&gt;git&lt;/code&gt;) – following the same pattern.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠️ Warning&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For A/AAAA enter &lt;strong&gt;only the bare IP&lt;/strong&gt; – no &lt;code&gt;http://&lt;/code&gt;, no slash, no port. For redirects to other names there would be &lt;code&gt;CNAME&lt;/code&gt;, but for "domain → own server" the A/AAAA record is the right and most robust way.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 3: Understand TTL and propagation
&lt;/h3&gt;

&lt;p&gt;Every record has a &lt;strong&gt;TTL&lt;/strong&gt; (Time To Live) in seconds – e.g. &lt;code&gt;3600&lt;/code&gt; (one hour). It tells resolvers worldwide &lt;strong&gt;how long&lt;/strong&gt; they may cache the answer before asking again.&lt;/p&gt;

&lt;p&gt;From this follows the notorious &lt;strong&gt;propagation&lt;/strong&gt;: if you change a record, some resolvers see the new value immediately, others only after the old TTL expires. "DNS propagating" means nothing more than these caches expiring one by one. In everyday use, reckon with &lt;strong&gt;minutes up to an hour&lt;/strong&gt;, occasionally longer.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 Before planned migrations&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you want to change an IP soon, lower the TTL &lt;strong&gt;one or two days beforehand&lt;/strong&gt; to e.g. &lt;code&gt;300&lt;/code&gt; (5 minutes). Then the actual switch takes effect almost immediately later. Raise it again after the migration.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 4: Verify with dig
&lt;/h3&gt;

&lt;p&gt;Wait a few minutes, then check from your own machine where the domain points. &lt;code&gt;dig&lt;/code&gt; is the standard tool for that; on Debian and Ubuntu it lives in the &lt;code&gt;bind9-dnsutils&lt;/code&gt; package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;bind9-dnsutils
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then query the A record:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +noall +answer YOUR_DOMAIN A
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is what a correct answer looks like (example for this site's real domain):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;serverkueche.de.    3600    IN  A   152.53.246.100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The number &lt;code&gt;3600&lt;/code&gt; is the remaining TTL – it counts down while the answer sits in a cache – followed by the target IP. For IPv6, query the &lt;code&gt;AAAA&lt;/code&gt; record the same way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short YOUR_DOMAIN AAAA
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2a0a:4cc0:c0:939c::1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;+short&lt;/code&gt; returns just the bare values, for IPv4 too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short YOUR_DOMAIN
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;152.53.246.100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your correct server IP appears, the domain is connected. Also check &lt;code&gt;www&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short www.YOUR_DOMAIN
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With its own A record for &lt;code&gt;www&lt;/code&gt; (step 2), exactly one line with the same IP as the apex comes back. If &lt;code&gt;www&lt;/code&gt; instead points to the main domain via &lt;code&gt;CNAME&lt;/code&gt; – also common and perfectly fine – &lt;code&gt;dig +short&lt;/code&gt; prints the CNAME target first and then its IP, as on this site:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;serverkueche.de.
152.53.246.100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Either way, what counts is the &lt;strong&gt;last&lt;/strong&gt; line: it has to carry your server's IP.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Set reverse DNS (PTR) – optional but recommended
&lt;/h3&gt;

&lt;p&gt;The A record answers the question "Which IP belongs to this name?". &lt;strong&gt;Reverse DNS&lt;/strong&gt; – the PTR record – answers the opposite direction: "Which name belongs to this IP?". For a plain website you don't need this. But as soon as your server &lt;strong&gt;sends emails&lt;/strong&gt; – even if only notifications from Nextcloud, Gitea &amp;amp; co. – it's practically mandatory: the receiving mail servers reject mail from IPs &lt;strong&gt;without&lt;/strong&gt; a matching PTR or treat it as spam. The PTR is only the first of four DNS building blocks – SPF, DKIM and DMARC join it as soon as you really send mail; those are covered by the&lt;br&gt;
&lt;a href="https://serverkueche.de/en/tutorials/email-deliverability/" rel="noopener noreferrer"&gt;email deliverability&lt;/a&gt; tutorial.&lt;/p&gt;

&lt;p&gt;The crucial difference from the previous records: you enter the PTR &lt;strong&gt;not&lt;/strong&gt; at your DNS provider, but where the &lt;strong&gt;IP address is managed&lt;/strong&gt; – i.e. at your &lt;strong&gt;VPS provider&lt;/strong&gt;. At netcup you'll find it in the &lt;strong&gt;Server Control Panel (SCP)&lt;/strong&gt; in the &lt;strong&gt;Netzwerk (Network)&lt;/strong&gt; tab – there's a &lt;strong&gt;Reverse DNS&lt;/strong&gt; field per address (IPv4 and IPv6). Enter a hostname there that points to the same server via an A record – either directly &lt;code&gt;YOUR_DOMAIN&lt;/code&gt; or your own name like &lt;code&gt;server.YOUR_DOMAIN&lt;/code&gt; (shown as an example in the screenshot) – and click &lt;strong&gt;Save&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffzopz6vplclqi5tne9dj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffzopz6vplclqi5tne9dj.png" alt="netcup Server Control Panel in the Network tab: the reverse-DNS fields for the IPv4 and IPv6 address, each with an example hostname entered and a Save button" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 Forward and reverse must match&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For the PTR to count as "clean" (forward-confirmed rDNS, FCrDNS), the name entered there must &lt;strong&gt;point back to the same IP&lt;/strong&gt; via an A/AAAA record. For &lt;code&gt;YOUR_DOMAIN&lt;/code&gt; and &lt;code&gt;www&lt;/code&gt; this is already satisfied by step 2. If you use your own name like &lt;code&gt;server.YOUR_DOMAIN&lt;/code&gt;, first create an A/AAAA record for it as in step 2 – otherwise the reverse direction doesn't match.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you have &lt;strong&gt;IPv6&lt;/strong&gt;, set the PTR &lt;strong&gt;for both&lt;/strong&gt; addresses – IPv4 &lt;em&gt;and&lt;/em&gt; IPv6. The IPv6 PTR is easily forgotten, and that's exactly what some mail servers trip over after all.&lt;/p&gt;

&lt;p&gt;You can check the result with &lt;code&gt;dig&lt;/code&gt; and the &lt;code&gt;-x&lt;/code&gt; option (reverse lookup) – for IPv6 do the same with &lt;code&gt;YOUR_SERVER_IPV6&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short &lt;span class="nt"&gt;-x&lt;/span&gt; YOUR_SERVER_IPV4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exactly the hostname you just entered should come back – with a trailing dot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;server.YOUR_DOMAIN.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If nothing comes back, or still the provider's default name (at netcup something like &lt;code&gt;v220….bestsrv.de&lt;/code&gt;), the PTR is not (correctly) set yet – or, as with every DNS entry, the change still needs some time.&lt;/p&gt;

&lt;h2&gt;
  
  
  When things go wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;dig&lt;/code&gt; returns no IP or a wrong (old) one.&lt;/strong&gt; Usually still propagation. Query a public resolver specifically to bypass your local cache: &lt;code&gt;dig +short YOUR_DOMAIN @1.1.1.1&lt;/code&gt;. If it already shows the correct value, only your local/provider cache hasn't expired yet – wait.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The domain resolves, but to the wrong IP – e.g. a registrar's parking page.&lt;/strong&gt; Often an old A record or a redirect/parking setting still exists. Remove contradictory entries; per name and type there should be exactly &lt;strong&gt;one&lt;/strong&gt; value (deliberate exceptions aside).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;IPv4 works, IPv6 (AAAA) leads to timeouts.&lt;/strong&gt; You entered an AAAA record even though the server has no working IPv6 address. Browsers then prefer IPv6 and run into a timeout. Remove the AAAA record until the server really supports IPv6.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In some interfaces the value is stored with a trailing dot (&lt;code&gt;YOUR_DOMAIN.&lt;/code&gt;) and you're unsure.&lt;/strong&gt; The trailing dot (root of the DNS hierarchy) is normal and correct – many DNS managers add it automatically. No reason to worry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Emails sent from the server land in spam or are rejected (the mail log shows e.g. &lt;code&gt;does not resolve to address&lt;/code&gt; or &lt;code&gt;no PTR record&lt;/code&gt;).&lt;/strong&gt; The reverse-DNS entry (PTR) is missing or doesn't match the sender hostname. Set the PTR as described in step 5 at the VPS provider and make sure it points to the same name that also resolves via the A/AAAA record.&lt;/p&gt;

&lt;h2&gt;
  
  
  Maintenance &amp;amp; backups
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DNS is low-maintenance.&lt;/strong&gt; Once set correctly, the A/AAAA record rarely changes. Exception: a &lt;strong&gt;server migration&lt;/strong&gt; – then the TTL trick from step 3 applies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Document your records.&lt;/strong&gt; Keep track somewhere of which names (&lt;code&gt;@&lt;/code&gt;, &lt;code&gt;www&lt;/code&gt;, &lt;code&gt;cloud&lt;/code&gt;, …) point to which IP. With several services you otherwise lose the overview.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No classic backup needed&lt;/strong&gt;, but a screenshot or export of your DNS zone doesn't hurt in case you switch providers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Carry reverse DNS along on an IP change.&lt;/strong&gt; If the server gets a new IP (migration, new product), you have to reset the PTR at the VPS provider – it's tied to the IP, not the domain.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This post first appeared on &lt;a href="https://serverkueche.de/en/tutorials/connect-domain-to-server/" rel="noopener noreferrer"&gt;serverkueche.de&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dns</category>
      <category>networking</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Installing Docker on Debian</title>
      <dc:creator>serverkueche.de</dc:creator>
      <pubDate>Fri, 25 Sep 2026 05:54:05 +0000</pubDate>
      <link>https://dev.to/serverkueche/installing-docker-on-debian-9a7</link>
      <guid>https://dev.to/serverkueche/installing-docker-on-debian-9a7</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are we building?
&lt;/h2&gt;

&lt;p&gt;By the end, the current &lt;strong&gt;Docker Engine with the Compose plugin&lt;/strong&gt; runs on your &lt;strong&gt;Debian 13&lt;/strong&gt;, installed from the official Docker repository. That has two advantages over the Debian package &lt;code&gt;docker.io&lt;/code&gt;: more current versions with timely security updates, and the official &lt;code&gt;docker compose&lt;/code&gt; that all Compose recipes here build on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;a href="https://serverkueche.de/en/tutorials/harden-ssh/" rel="noopener noreferrer"&gt;hardened server&lt;/a&gt; with a sudo user&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step by step
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Set up the repository key
&lt;/h3&gt;

&lt;p&gt;First, install the tools needed to verify the Docker repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; ca-certificates curl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then create the keyring directory and download Docker's GPG key – apt uses it later to check that the packages really come from Docker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo install&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 0755 &lt;span class="nt"&gt;-d&lt;/span&gt; /etc/apt/keyrings
&lt;span class="nb"&gt;sudo &lt;/span&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://download.docker.com/linux/debian/gpg &lt;span class="nt"&gt;-o&lt;/span&gt; /etc/apt/keyrings/docker.asc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: Add the Docker package source
&lt;/h3&gt;

&lt;p&gt;Add the Docker repository to your package sources. The command detects the architecture and Debian version automatically, so you can copy it unchanged:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"deb [arch=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;dpkg &lt;span class="nt"&gt;--print-architecture&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; /etc/os-release &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$VERSION_CODENAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; stable"&lt;/span&gt; | &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/apt/sources.list.d/docker.list &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3: Install Docker
&lt;/h3&gt;

&lt;p&gt;Refresh the package lists (now including the Docker repository) and install the Engine together with the Compose plugin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check that the service is running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl status docker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see &lt;code&gt;active (running)&lt;/code&gt;. A quick smoke test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; hello-world
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output &lt;code&gt;Hello from Docker!&lt;/code&gt; confirms that the installation works.&lt;/p&gt;

&lt;p&gt;Finally, check which versions actually got installed – that's the information every bug report will ask you for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nt"&gt;--version&lt;/span&gt;
docker compose version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Docker version 29.7.2, build a7dcaa6
Docker Compose version v5.4.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact patch numbers keep moving; what matters is that the Engine is on &lt;strong&gt;29.x&lt;/strong&gt; and Compose on &lt;strong&gt;v5.x&lt;/strong&gt; – that's what the Compose recipes here build on.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Use Docker without sudo
&lt;/h3&gt;

&lt;p&gt;Add your user to the &lt;code&gt;docker&lt;/code&gt; group so you don't have to prefix every command with &lt;code&gt;sudo&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker &lt;span class="nv"&gt;$USER&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 Tip&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The group membership only takes effect after you &lt;strong&gt;log out and back in&lt;/strong&gt; (end the SSH session and reconnect).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⚠️ Security note&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Members of the &lt;code&gt;docker&lt;/code&gt; group effectively have root rights on the server – only add your own admin user, no shared or unprivileged accounts.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;After that, &lt;code&gt;docker ps&lt;/code&gt; works without sudo and shows a (still empty) container list.&lt;/p&gt;

&lt;h2&gt;
  
  
  When things go wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;E: Package 'docker-ce' has no installation candidate&lt;/code&gt;.&lt;/strong&gt; The package source from step 2 is missing or malformed – apt only knows the name from the Debian packages' dependencies and finds no package behind it. Check the contents of &lt;code&gt;/etc/apt/sources.list.d/docker.list&lt;/code&gt; – it must contain your Debian version (e.g. &lt;code&gt;trixie&lt;/code&gt;) – and then run &lt;code&gt;sudo apt update&lt;/code&gt; again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;permission denied while trying to connect to the docker API at unix:///var/run/docker.sock&lt;/code&gt;.&lt;/strong&gt; The &lt;code&gt;docker&lt;/code&gt; group membership hasn't taken effect yet. End the SSH session and reconnect; &lt;code&gt;groups&lt;/code&gt; must then include &lt;code&gt;docker&lt;/code&gt;. If not, repeat step 4.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conflicts during installation with already-present packages.&lt;/strong&gt; Another Docker variant is already installed (&lt;code&gt;docker.io&lt;/code&gt;, &lt;code&gt;podman-docker&lt;/code&gt;, …). Remove the old packages first: &lt;code&gt;sudo apt remove docker.io docker-doc docker-compose podman-docker containerd runc&lt;/code&gt; – existing containers/images are preserved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;docker compose&lt;/code&gt; reports &lt;code&gt;docker: unknown command: docker compose&lt;/code&gt;.&lt;/strong&gt; The Compose plugin is missing – Docker was probably installed differently earlier. Run &lt;code&gt;sudo apt install docker-compose-plugin&lt;/code&gt;. Note: the old &lt;code&gt;docker-compose&lt;/code&gt; (with a hyphen) is a different, outdated tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Maintenance &amp;amp; backups
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Updates:&lt;/strong&gt; Docker updates along with the normal &lt;code&gt;sudo apt update &amp;amp;&amp;amp; sudo apt upgrade&lt;/code&gt; – 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cleanup:&lt;/strong&gt; Unused images and build leftovers pile up quickly. &lt;code&gt;docker system df&lt;/code&gt; shows the usage, &lt;code&gt;docker system prune&lt;/code&gt; cleans up (&lt;strong&gt;careful:&lt;/strong&gt; it also removes stopped containers).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backups:&lt;/strong&gt; The actual data of your applications will later live in &lt;a href="https://serverkueche.de/en/tutorials/docker-volumes-vs-bind-mounts/" rel="noopener noreferrer"&gt;volumes or bind mounts&lt;/a&gt; – we build the backup strategy for that with &lt;a href="https://serverkueche.de/en/tutorials/restic-backups/" rel="noopener noreferrer"&gt;encrypted Restic backups&lt;/a&gt; and in the individual application tutorials.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This post first appeared on &lt;a href="https://serverkueche.de/en/tutorials/install-docker/" rel="noopener noreferrer"&gt;serverkueche.de&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>linux</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Setting up a firewall with UFW</title>
      <dc:creator>serverkueche.de</dc:creator>
      <pubDate>Fri, 25 Sep 2026 05:53:26 +0000</pubDate>
      <link>https://dev.to/serverkueche/setting-up-a-firewall-with-ufw-4lk5</link>
      <guid>https://dev.to/serverkueche/setting-up-a-firewall-with-ufw-4lk5</guid>
      <description>&lt;p&gt;By default, your server accepts connections on all open ports. A &lt;strong&gt;firewall&lt;/strong&gt; turns that around: it blocks everything and only lets through what you explicitly allow. &lt;strong&gt;UFW&lt;/strong&gt; ("Uncomplicated Firewall") does this with a few, readable commands – the perfect first line of defense.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are we building?
&lt;/h2&gt;

&lt;p&gt;By the end, &lt;strong&gt;UFW&lt;/strong&gt; runs on your &lt;strong&gt;Debian 13&lt;/strong&gt; with the base rule "&lt;strong&gt;block all incoming&lt;/strong&gt;", with only &lt;strong&gt;SSH&lt;/strong&gt; (your access) plus &lt;strong&gt;HTTP/HTTPS&lt;/strong&gt; (ports 80/443, which you'll need later for Traefik) open. Outgoing connections stay allowed. The key part: we proceed in a way that ensures you &lt;strong&gt;don't lock yourself out&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;a href="https://serverkueche.de/en/tutorials/harden-ssh/" rel="noopener noreferrer"&gt;hardened server&lt;/a&gt; with a sudo user&lt;/li&gt;
&lt;li&gt;You know which &lt;strong&gt;port your SSH&lt;/strong&gt; runs on (default: 22)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step by step
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Install UFW
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; ufw
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check which version you got:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ufw 0.36.2
Copyright 2008-2023 Canonical Ltd.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Right after installation UFW is still &lt;strong&gt;inactive&lt;/strong&gt; – and that's intended: a packet filter that goes live without any allowances would lock you out. &lt;code&gt;sudo ufw status&lt;/code&gt; confirms it with &lt;code&gt;Status: inactive&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Define the base rules
&lt;/h3&gt;

&lt;p&gt;First the default direction: deny everything incoming, allow everything outgoing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw default deny incoming
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw default allow outgoing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Default incoming policy changed to 'deny'
(be sure to update your rules accordingly)
Default outgoing policy changed to 'allow'
(be sure to update your rules accordingly)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These rules don't take effect yet – UFW is still inactive. So the exceptions come now, &lt;strong&gt;before&lt;/strong&gt; we switch it on.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Allow SSH – first, or you'll lock yourself out
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;🛑 Allow SSH first, then activate&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you enable UFW with the "deny incoming" rule &lt;strong&gt;without&lt;/strong&gt; 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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If your SSH runs on the default port 22 (and &lt;code&gt;openssh-server&lt;/code&gt; is installed), there's a ready-made profile for it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow OpenSSH
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you changed the SSH port (e.g. to 2222), allow exactly that port instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 2222/tcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 4: Allow HTTP and HTTPS
&lt;/h3&gt;

&lt;p&gt;For the later &lt;a href="https://serverkueche.de/en/tutorials/traefik-reverse-proxy/" rel="noopener noreferrer"&gt;reverse proxy with Traefik&lt;/a&gt; you need the web ports. Open them right away:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 80/tcp
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 443/tcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;UFW confirms each rule with &lt;code&gt;Rules updated&lt;/code&gt; and &lt;code&gt;Rules updated (v6)&lt;/code&gt; (the IPv6 variant).&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Activate the firewall and check it
&lt;/h3&gt;

&lt;p&gt;Now switch it on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw &lt;span class="nb"&gt;enable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Command may disrupt existing ssh connections. Proceed with operation (y|n)? y
Firewall is active and enabled on system startup
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw status verbose
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip

To                         Action      From
--                         ------      ----
22/tcp (OpenSSH)           ALLOW IN    Anywhere
80/tcp                     ALLOW IN    Anywhere
443/tcp                    ALLOW IN    Anywhere
22/tcp (OpenSSH (v6))      ALLOW IN    Anywhere (v6)
80/tcp (v6)                ALLOW IN    Anywhere (v6)
443/tcp (v6)               ALLOW IN    Anywhere (v6)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Status: active&lt;/code&gt; and your three allowances – done. &lt;strong&gt;To be safe, test in a second SSH session&lt;/strong&gt; that you can still connect before closing the first one.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;disabled (routed)&lt;/code&gt; in the &lt;code&gt;Default&lt;/code&gt; line is normal on a fresh Debian: UFW only filters forwarded traffic if IP forwarding is enabled in the kernel at all. Once you &lt;a href="https://serverkueche.de/en/tutorials/install-docker/" rel="noopener noreferrer"&gt;install Docker&lt;/a&gt; later – which turns on &lt;code&gt;net.ipv4.ip_forward&lt;/code&gt; – it will read &lt;code&gt;deny (routed)&lt;/code&gt; instead. Both are fine, they just describe different starting points.&lt;/p&gt;

&lt;h2&gt;
  
  
  When things go wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;After &lt;code&gt;ufw enable&lt;/code&gt; you can no longer get in via SSH.&lt;/strong&gt; The SSH rule was missing or targeted the wrong port. Connect via the &lt;strong&gt;console in the netcup SCP&lt;/strong&gt; (VNC, independent of SSH), allow your SSH port there (&lt;code&gt;sudo ufw allow …&lt;/code&gt;) and test again. That's exactly what step 3 warns about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ERROR: Could not find a profile matching 'OpenSSH'&lt;/code&gt;.&lt;/strong&gt; The OpenSSH profile only exists if &lt;code&gt;openssh-server&lt;/code&gt; is installed. Use the port number instead: &lt;code&gt;sudo ufw allow 22/tcp&lt;/code&gt; (or your port). &lt;code&gt;sudo ufw app list&lt;/code&gt; shows the available profiles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A Docker container is reachable from outside even though UFW doesn't open the port.&lt;/strong&gt; Not your mistake – &lt;strong&gt;Docker bypasses UFW&lt;/strong&gt;. Docker writes its rules directly into &lt;code&gt;iptables&lt;/code&gt; and inserts them ahead of the UFW chains. A published container port (&lt;code&gt;ports:&lt;/code&gt; in the compose.yaml) is thus open, no matter what UFW says. The clean solution is a second firewall layer &lt;strong&gt;in front of&lt;/strong&gt; the server – at netcup the &lt;a href="https://serverkueche.de/en/tutorials/netcup-firewall-setup/" rel="noopener noreferrer"&gt;firewall in the SCP&lt;/a&gt; as an upstream perimeter. On the host it also helps to bind container ports only to &lt;code&gt;127.0.0.1&lt;/code&gt; instead of &lt;code&gt;0.0.0.0&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Maintenance &amp;amp; backups
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;View and delete rules:&lt;/strong&gt; &lt;code&gt;sudo ufw status numbered&lt;/code&gt; numbers all rules; &lt;code&gt;sudo ufw delete 3&lt;/code&gt; removes rule 3. That's how you tidy up when a service goes away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;New services:&lt;/strong&gt; For every additional public port, one targeted rule – never "just open everything". What doesn't need to be open stays closed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No backup needed,&lt;/strong&gt; but note down your allowances (or keep them in the same document as your DNS records). The rule set is retyped in seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know the limits:&lt;/strong&gt; UFW protects the host, but not against the Docker gap above. An upstream perimeter firewall like the &lt;a href="https://serverkueche.de/en/tutorials/netcup-firewall-setup/" rel="noopener noreferrer"&gt;netcup firewall in the SCP&lt;/a&gt; complements UFW into two layers that secure each other – ideal once containers with open ports are running.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This post first appeared on &lt;a href="https://serverkueche.de/en/tutorials/firewall-ufw-setup/" rel="noopener noreferrer"&gt;serverkueche.de&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>security</category>
      <category>networking</category>
    </item>
    <item>
      <title>Self-hosting Jellyfin: your own media server behind Traefik</title>
      <dc:creator>serverkueche.de</dc:creator>
      <pubDate>Fri, 25 Sep 2026 05:52:37 +0000</pubDate>
      <link>https://dev.to/serverkueche/self-hosting-jellyfin-your-own-media-server-behind-traefik-562j</link>
      <guid>https://dev.to/serverkueche/self-hosting-jellyfin-your-own-media-server-behind-traefik-562j</guid>
      <description>&lt;p&gt;Netflix, Spotify and Google Photos in one – only on your own server and without a monthly fee: &lt;strong&gt;Jellyfin&lt;/strong&gt; streams your movie, series and music collection to any device. In this recipe we hang Jellyfin behind Traefik and put the library on netcup's &lt;strong&gt;Local Block Storage&lt;/strong&gt; so you don't run out of space.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are we building?
&lt;/h2&gt;

&lt;p&gt;By the end, &lt;strong&gt;Jellyfin 10.11&lt;/strong&gt; runs as a container on your server, reachable at &lt;code&gt;https://jellyfin.YOUR_DOMAIN&lt;/code&gt; with valid HTTPS. Jellyfin is a completely free media server (no cloud, no telemetry, no subscription): you store your files, Jellyfin automatically pulls covers, descriptions and metadata and streams everything to the browser, phone, smart TV or the Jellyfin app.&lt;/p&gt;

&lt;p&gt;The real challenge with a media server is &lt;strong&gt;storage space&lt;/strong&gt; – a movie collection quickly bursts the small system SSD of a VPS. That's why we mount netcup's &lt;strong&gt;Local Block Storage&lt;/strong&gt;: an additional, local disk that appears in the server like a normal drive and on which the library lives.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;ℹ️ Honest about transcoding&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Jellyfin re-encodes videos on demand live into a format your end device understands (&lt;strong&gt;transcoding&lt;/strong&gt;) – that costs &lt;strong&gt;a lot of CPU&lt;/strong&gt;. netcup servers are pure CPU machines &lt;strong&gt;without a graphics card&lt;/strong&gt;, so real hardware transcoding (via a GPU) doesn't exist there. On a &lt;a href="https://serverkueche.de/en/netcup-recommendation/" rel="noopener noreferrer"&gt;root server&lt;/a&gt; with &lt;strong&gt;dedicated cores&lt;/strong&gt; (RS 1000), software transcoding works well for one or two parallel streams; but the royal road remains &lt;strong&gt;Direct Play&lt;/strong&gt; – more on that below.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A running &lt;strong&gt;Traefik reverse proxy&lt;/strong&gt; with the &lt;code&gt;proxy&lt;/code&gt; network and the resolver &lt;code&gt;le&lt;/code&gt; – see &lt;a href="https://serverkueche.de/en/tutorials/traefik-reverse-proxy/" rel="noopener noreferrer"&gt;reverse proxy with Traefik&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;A subdomain &lt;code&gt;jellyfin.YOUR_DOMAIN&lt;/code&gt; with a DNS record to your server IP – see &lt;a href="https://serverkueche.de/en/tutorials/connect-domain-to-server/" rel="noopener noreferrer"&gt;connecting a domain to your server&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dedicated CPU cores&lt;/strong&gt; are clearly at an advantage for smooth streaming (transcoding). On a shared VPS it works too for Direct Play, but gets tight quickly on re-encoding.&lt;/li&gt;
&lt;li&gt;Optional but recommended: booked &lt;strong&gt;netcup Local Block Storage&lt;/strong&gt; for the library (step 2). Without it, you simply put the media in a folder on the system disk – until the space runs out.&lt;/li&gt;
&lt;li&gt;A few &lt;strong&gt;of your own&lt;/strong&gt; media files (movies/series/music) that you're allowed to host.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whether your setup really needs dedicated cores is shown by the &lt;a href="https://serverkueche.de/en/server-calculator/" rel="noopener noreferrer"&gt;server calculator&lt;/a&gt; – with transcoding the answer is usually yes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step by step
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Create the DNS record
&lt;/h3&gt;

&lt;p&gt;Create &lt;code&gt;jellyfin.YOUR_DOMAIN&lt;/code&gt; (A/AAAA to your server IP) and check that it resolves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short jellyfin.YOUR_DOMAIN
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your server IP must come back – otherwise Traefik won't fetch a certificate later.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Mount netcup Local Block Storage
&lt;/h3&gt;

&lt;p&gt;The &lt;strong&gt;Local Block Storage&lt;/strong&gt; is additional storage you book to your server in the netcup customer account. Unlike the network-attached &lt;strong&gt;Storage Space&lt;/strong&gt; of other providers (which is connected via NFS and is slower), it appears in the server as a &lt;strong&gt;completely normal local disk&lt;/strong&gt; – ideal for a library because it's fast and freely partitionable.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 No block storage booked?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Then skip this step and simply create the library under &lt;code&gt;~/jellyfin/media&lt;/code&gt; – all the following commands work the same, only the path is different. You can add the block storage later at any time and move the data.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;First look at which disks the server knows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;fdisk &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The system disk is typically &lt;code&gt;/dev/sda&lt;/code&gt; (or &lt;code&gt;/dev/vda&lt;/code&gt;). The new block storage appears as an &lt;strong&gt;additional&lt;/strong&gt; device, usually &lt;code&gt;/dev/vdb&lt;/code&gt; – recognizable by the size you booked.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;🛑 Check first, then partition&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The device names can differ per server. If you accidentally partition the &lt;strong&gt;system disk&lt;/strong&gt;, your server is toast. Identify the device unambiguously by its &lt;strong&gt;size&lt;/strong&gt; before you continue. When in doubt, create a &lt;a href="https://serverkueche.de/en/tutorials/netcup-snapshots-scp/" rel="noopener noreferrer"&gt;snapshot&lt;/a&gt; first.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Create a partition on the new disk (here &lt;code&gt;/dev/vdb&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;cfdisk /dev/vdb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Choose &lt;strong&gt;gpt&lt;/strong&gt; at the prompt (the modern standard – &lt;strong&gt;dos&lt;/strong&gt;/MBR can address at most 2 TiB, libraries like to grow beyond that), then &lt;strong&gt;New&lt;/strong&gt;, take the full size, and write the table with &lt;strong&gt;Write&lt;/strong&gt; (type &lt;code&gt;yes&lt;/code&gt;), then &lt;strong&gt;Quit&lt;/strong&gt;. The partition &lt;code&gt;/dev/vdb1&lt;/code&gt; is created. Format it with an ext4 filesystem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;mkfs.ext4 /dev/vdb1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create a mount point and mount the partition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /mnt/media
&lt;span class="nb"&gt;sudo &lt;/span&gt;mount /dev/vdb1 /mnt/media
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So that the disk is mounted automatically &lt;strong&gt;even after a reboot&lt;/strong&gt;, you enter it in &lt;code&gt;/etc/fstab&lt;/code&gt; with its &lt;strong&gt;UUID&lt;/strong&gt; (not with &lt;code&gt;/dev/vdb1&lt;/code&gt;, which can change). Read out the UUID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;blkid /dev/vdb1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/dev/vdb1: UUID="a1b2c3d4-...." TYPE="ext4" ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open the &lt;code&gt;fstab&lt;/code&gt; and append the line with &lt;strong&gt;your&lt;/strong&gt; UUID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nano /etc/fstab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;UUID=a1b2c3d4-....   /mnt/media   ext4   defaults   0   2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the entries &lt;strong&gt;before&lt;/strong&gt; you rely on the reboot – a typo in the &lt;code&gt;fstab&lt;/code&gt; can block the boot process:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;umount /mnt/media &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;mount &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; /mnt/media
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If no error appears here and &lt;code&gt;df -h&lt;/code&gt; shows your new disk under &lt;code&gt;/mnt/media&lt;/code&gt;, everything is entered correctly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Create the project and media folders
&lt;/h3&gt;

&lt;p&gt;Create the Compose project – the configuration is small and may go on the system disk. You create the subfolders &lt;code&gt;config&lt;/code&gt; and &lt;code&gt;cache&lt;/code&gt; right along, &lt;strong&gt;before&lt;/strong&gt; the container starts for the first time: if the Docker daemon created them first, they would belong to &lt;code&gt;root&lt;/code&gt; – but Jellyfin runs as UID 1000 (see step 4) and couldn't write into its own &lt;code&gt;/config&lt;/code&gt;, the container would crash on the first start.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/jellyfin/&lt;span class="o"&gt;{&lt;/span&gt;config,cache&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; ~/jellyfin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 Audiobooks belong elsewhere&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Jellyfin can do music – but it is awkward for &lt;strong&gt;audiobooks and podcasts&lt;/strong&gt;: it does not sync your listening position across devices, and it knows nothing about chapter marks or a sleep timer. If you want both, use &lt;a href="https://serverkueche.de/en/tutorials/audiobookshelf-audiobooks/" rel="noopener noreferrer"&gt;Audiobookshelf&lt;/a&gt; for those; the two services run side by side on the same server without trouble.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And the folder structure for the library on the block storage. Jellyfin sorts best when movies, series and music are kept separate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /mnt/media/&lt;span class="o"&gt;{&lt;/span&gt;filme,serien,musik&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So that the Jellyfin container may read the files, they have to belong to your user. Determine your user and group ID (usually &lt;code&gt;1000&lt;/code&gt;) and hand over the folder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt;
&lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; 1000:1000 /mnt/media
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now copy your media into the appropriate subfolders (via &lt;code&gt;scp&lt;/code&gt;, &lt;code&gt;rsync&lt;/code&gt; or an SFTP client). For clean recognition, a clear naming helps, e.g. &lt;code&gt;filme/The Godfather (1972)/The Godfather (1972).mkv&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: The compose.yaml
&lt;/h3&gt;

&lt;p&gt;Now the central file. Replace &lt;code&gt;jellyfin.YOUR_DOMAIN&lt;/code&gt; with your subdomain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;jellyfin&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jellyfin/jellyfin:10.11.11&lt;/span&gt;
    &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1000:1000"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;JELLYFIN_PublishedServerUrl=https://jellyfin.YOUR_DOMAIN&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./config:/config&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./cache:/cache&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/mnt/media:/media:ro&lt;/span&gt;
    &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.enable=true"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.jellyfin.rule=Host(`jellyfin.YOUR_DOMAIN`)"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.jellyfin.entrypoints=websecure"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.jellyfin.tls.certresolver=le"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.services.jellyfin.loadbalancer.server.port=8096"&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;proxy&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;proxy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What the lines mean:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;image: …:10.11.11&lt;/code&gt;&lt;/strong&gt; – the fixed, current stable version. No &lt;code&gt;latest&lt;/code&gt; (see "Maintenance &amp;amp; backups").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;user: "1000:1000"&lt;/code&gt;&lt;/strong&gt; – Jellyfin runs with &lt;strong&gt;your&lt;/strong&gt; user/group ID so it can read the media. If you adjust other IDs in step 3, change them here too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;JELLYFIN_PublishedServerUrl&lt;/code&gt;&lt;/strong&gt; – tells Jellyfin under which public address it's reachable. This prevents app links from suddenly pointing to an internal IP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;volumes&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;config&lt;/code&gt; (settings, users, metadata) and &lt;code&gt;cache&lt;/code&gt; live small in the project folder; the &lt;strong&gt;library&lt;/strong&gt; is mounted from the block storage under &lt;code&gt;/mnt/media&lt;/code&gt; into the container as &lt;code&gt;/media&lt;/code&gt; – &lt;strong&gt;&lt;code&gt;:ro&lt;/code&gt;&lt;/strong&gt; (read-only), so Jellyfin never changes your originals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The four Traefik labels&lt;/strong&gt; are the familiar pattern. New is only the port: Jellyfin listens on &lt;strong&gt;8096&lt;/strong&gt;, hence the &lt;code&gt;loadbalancer.server.port&lt;/code&gt; label. Without the label Traefik guesses the port from the image – which works out for the Jellyfin image, because it exposes exactly one port. Don't rely on that: as soon as an image brings several ports, Traefik picks the wrong one and you get a &lt;code&gt;502 Bad Gateway&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;ports:&lt;/code&gt;&lt;/strong&gt; – Jellyfin is reachable exclusively via Traefik (HTTPS), not directly from the internet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Start the stack and watch it boot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt; jellyfin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the first start, certificate issuance takes a few seconds. Once &lt;code&gt;Startup complete&lt;/code&gt; appears in the log, Jellyfin is ready.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: The initial setup wizard
&lt;/h3&gt;

&lt;p&gt;Open &lt;code&gt;https://jellyfin.YOUR_DOMAIN&lt;/code&gt; in the browser. On the very first call, a wizard guides you through the basic setup:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Set the &lt;strong&gt;server name&lt;/strong&gt; and the &lt;strong&gt;preferred display language&lt;/strong&gt;. Pre-filled is the container's hostname – a random hex string like &lt;code&gt;df7c7caa7bcd&lt;/code&gt;. The server shows up under this name in the apps later, so a speaking name is worth it.&lt;/li&gt;
&lt;li&gt;Create an &lt;strong&gt;administrator account&lt;/strong&gt; – set a &lt;strong&gt;long, unique password&lt;/strong&gt;, because the login page is publicly on the net.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add media libraries&lt;/strong&gt;: click "Add Media Library", choose the type (e.g. &lt;em&gt;Movies&lt;/em&gt;), and enter the &lt;strong&gt;container path&lt;/strong&gt; as the folder – i.e. &lt;code&gt;/media/filme&lt;/code&gt; (not &lt;code&gt;/mnt/media/filme&lt;/code&gt;, that's the path on the host!). Matching that, the directory picker only shows container paths: besides the root &lt;code&gt;/&lt;/code&gt;, the three mounted folders &lt;code&gt;/media&lt;/code&gt;, &lt;code&gt;/config&lt;/code&gt; and &lt;code&gt;/cache&lt;/code&gt;. Repeat this for &lt;code&gt;serien&lt;/code&gt; and &lt;code&gt;musik&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Set the &lt;strong&gt;metadata language&lt;/strong&gt; and country.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configure remote access&lt;/strong&gt;: "Allow remote connections to this server" stays on – otherwise you lock yourself out from the outside. There's nothing else to do on this page; Traefik takes care of the route from outside.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Finish&lt;/strong&gt;. Jellyfin sends you to the login page, where you sign in with the account you just created.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8r3gzb2bvpa45drk9ma3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8r3gzb2bvpa45drk9ma3.png" alt="The Jellyfin setup wizard on first launch with fields for server name and preferred display language" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After the wizard, Jellyfin scans the folders and loads covers and descriptions. For large collections the first scan takes a while – you see the progress under &lt;strong&gt;Dashboard → Scheduled Tasks&lt;/strong&gt;. After that you land on the home page and see your library with the first titles:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fchvgzhktjpvucfxsri1z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fchvgzhktjpvucfxsri1z.png" alt="The Jellyfin home page after setup with the " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6: Configure Jellyfin cleanly behind the proxy
&lt;/h3&gt;

&lt;p&gt;Two settings make Jellyfin work correctly behind Traefik. Open &lt;strong&gt;Dashboard → Networking&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9gb684oqaksg0jcy87jc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9gb684oqaksg0jcy87jc.png" alt="The Jellyfin administration dashboard with server version 10.11.11, active devices and the storage paths, on the left the menu with network and playback settings" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Under &lt;strong&gt;Known proxies&lt;/strong&gt; you enter the Traefik network so Jellyfin sees the &lt;strong&gt;real&lt;/strong&gt; client IP (instead of Traefik's container IP). You determine the subnet with:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  docker network inspect proxy &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{ (index .IPAM.Config 0).Subnet }}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enter the returned range (e.g. &lt;code&gt;172.19.0.0/16&lt;/code&gt;) there – the field accepts CIDR notation. The setting only takes effect after a &lt;strong&gt;restart&lt;/strong&gt;, which the interface points out right below the field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  docker compose restart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can check whether it worked under &lt;strong&gt;Dashboard → Activity&lt;/strong&gt;: before, every login showed Traefik's container IP (&lt;code&gt;172.19.0.2&lt;/code&gt; or similar), afterwards it shows the real IP of the device you signed in from.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;HTTPS&lt;/strong&gt;: since Traefik handles the encryption, Jellyfin itself needs &lt;strong&gt;no&lt;/strong&gt; TLS. Leave the HTTPS options in Jellyfin empty – everything runs via the proxy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;WebSockets (for live updates of the interface) Traefik forwards automatically, nothing to do there.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 7: Transcoding vs. Direct Play
&lt;/h3&gt;

&lt;p&gt;Whether your server breaks a sweat is decided here. Two cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Direct Play&lt;/strong&gt; – the end device can play the file as-is. The server just pushes bytes through, &lt;strong&gt;almost no CPU load&lt;/strong&gt;. That's the ideal case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transcoding&lt;/strong&gt; – format, codec or bitrate don't fit the device (or the line is too slow), so Jellyfin re-encodes &lt;strong&gt;live&lt;/strong&gt;. On a netcup server this happens via the &lt;strong&gt;CPU&lt;/strong&gt; – with a 4K stream that can max out a whole server.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is how you keep the load low:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Provide &lt;strong&gt;suitable formats&lt;/strong&gt;: H.264/AAC in an &lt;code&gt;.mp4&lt;/code&gt;/&lt;code&gt;.mkv&lt;/code&gt; plays directly on practically every device. Exotic codecs (e.g. certain 4K HEVC audio tracks) force transcoding.&lt;/li&gt;
&lt;li&gt;Set the &lt;strong&gt;client quality&lt;/strong&gt; in the app to "Original/Direct Play" when the line is enough.&lt;/li&gt;
&lt;li&gt;Under &lt;strong&gt;Dashboard → Playback&lt;/strong&gt; you can set the transcoding settings and a limit for simultaneous streams.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠️ Hardware transcoding needs a GPU&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;hardware acceleration&lt;/strong&gt; selectable in Jellyfin (VAAPI, QSV, NVENC) requires a graphics unit under &lt;code&gt;/dev/dri&lt;/code&gt;. Standard VPS and root servers at netcup are KVM machines &lt;strong&gt;without&lt;/strong&gt; a passed-through GPU – leave hardware acceleration &lt;strong&gt;off&lt;/strong&gt; there, it would only produce errors. Instead plan with dedicated CPU cores and Direct Play.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  When things go wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;502 Bad Gateway&lt;/code&gt; on access, even though the container is running.&lt;/strong&gt; Traefik reaches the container but hits the wrong port. Jellyfin listens on &lt;strong&gt;8096&lt;/strong&gt; – check the label &lt;code&gt;traefik.http.services.jellyfin.loadbalancer.server.port=8096&lt;/code&gt; for typos. If a &lt;strong&gt;&lt;code&gt;504 Gateway Timeout&lt;/code&gt;&lt;/strong&gt; comes back after a few seconds instead, the port isn't the problem, the network is: then &lt;code&gt;networks: [proxy]&lt;/code&gt; is missing and Traefik is running against an address it can't reach at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The libraries stay empty, even though files are there.&lt;/strong&gt; Almost always a &lt;strong&gt;permission problem&lt;/strong&gt;. The container runs as &lt;code&gt;1000:1000&lt;/code&gt; (step 4), so the media must belong to that user: &lt;code&gt;sudo chown -R 1000:1000 /mnt/media&lt;/code&gt;. And: in Jellyfin the &lt;strong&gt;container path&lt;/strong&gt; must be entered (&lt;code&gt;/media/filme&lt;/code&gt;), not the host path. Afterwards start "Scan all libraries" under &lt;strong&gt;Dashboard → Scheduled Tasks&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Video stutters/buffers, &lt;code&gt;docker stats&lt;/code&gt; shows Jellyfin at ~100% CPU.&lt;/strong&gt; It's &lt;strong&gt;transcoding&lt;/strong&gt;. Under &lt;strong&gt;Dashboard → Playback&lt;/strong&gt; the activity monitor shows whether it says "Transcode" instead of "Direct Play". Set the client quality to original, change exotic codecs to a widely supported format, or give the server more (dedicated) cores.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After a reboot the library is gone and Jellyfin shows empty folders.&lt;/strong&gt; The block storage wasn't mounted – usually a missing or wrong &lt;code&gt;fstab&lt;/code&gt; entry (step 2). Check with &lt;code&gt;df -h /mnt/media&lt;/code&gt; and &lt;code&gt;sudo mount -a&lt;/code&gt;. The line must use the &lt;strong&gt;UUID&lt;/strong&gt;, not &lt;code&gt;/dev/vdb1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The login page loads, but the app can't find the server / links point to an internal address.&lt;/strong&gt; &lt;code&gt;JELLYFIN_PublishedServerUrl&lt;/code&gt; is missing or wrong. Set it in the &lt;code&gt;compose.yaml&lt;/code&gt; to &lt;code&gt;https://jellyfin.YOUR_DOMAIN&lt;/code&gt; and restart with &lt;code&gt;docker compose up -d&lt;/code&gt; (a &lt;code&gt;restart&lt;/code&gt; isn't enough for the environment change to take effect).&lt;/p&gt;

&lt;h2&gt;
  
  
  Maintenance &amp;amp; backups
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Apply updates deliberately.&lt;/strong&gt; The fixed tag (&lt;code&gt;jellyfin:10.11.11&lt;/code&gt;) means: you decide when to update. Bump the tag → &lt;code&gt;docker compose pull&lt;/code&gt; → &lt;code&gt;docker compose up -d&lt;/code&gt;. Before jumping to a new major version, read the release notes – database migrations aren't always reversible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Back up the config, treat the media separately.&lt;/strong&gt; The &lt;code&gt;config&lt;/code&gt; volume (users, settings, playback state, metadata) is small and the actually valuable part – back it up &lt;a href="https://serverkueche.de/en/tutorials/restic-backups/" rel="noopener noreferrer"&gt;encrypted off-site with Restic&lt;/a&gt;. The &lt;strong&gt;library&lt;/strong&gt; itself is usually too large for a classic backup and reproducible from the original media; decide deliberately whether you back it up or classify it as "replaceable". Honestly: a second backup of your movie collection costs real storage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitor disk space.&lt;/strong&gt; A library grows steadily. Keep an eye on usage (&lt;code&gt;df -h /mnt/media&lt;/code&gt;) and best set up a monitor in &lt;a href="https://serverkueche.de/en/tutorials/uptime-kuma-monitoring/" rel="noopener noreferrer"&gt;Uptime Kuma&lt;/a&gt; that raises the alarm before the block storage is full.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publicly reachable = secure it.&lt;/strong&gt; The login page is on the net, and Jellyfin brings &lt;strong&gt;no two-factor authentication&lt;/strong&gt; (not even via an official plugin). So consistently assign strong passwords, deactivate unused accounts – and for purely private use, consider making Jellyfin reachable not publicly but only via a VPN.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This post first appeared on &lt;a href="https://serverkueche.de/en/tutorials/jellyfin-media-server/" rel="noopener noreferrer"&gt;serverkueche.de&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>docker</category>
      <category>jellyfin</category>
    </item>
    <item>
      <title>Setting up the netcup firewall in the SCP (with the stateless-UDP trick)</title>
      <dc:creator>serverkueche.de</dc:creator>
      <pubDate>Fri, 25 Sep 2026 05:52:00 +0000</pubDate>
      <link>https://dev.to/serverkueche/setting-up-the-netcup-firewall-in-the-scp-with-the-stateless-udp-trick-3ice</link>
      <guid>https://dev.to/serverkueche/setting-up-the-netcup-firewall-in-the-scp-with-the-stateless-udp-trick-3ice</guid>
      <description>&lt;p&gt;netcup comes with a &lt;strong&gt;firewall in front of your server&lt;/strong&gt; – in the Server Control Panel, before a packet even reaches the operating system. Built correctly, it's a strong shield. But there's a trick many people fail at: the netcup firewall is &lt;strong&gt;stateless for UDP&lt;/strong&gt;. This tutorial shows you a clean, reusable firewall – and why DNS and NTP otherwise suddenly get stuck.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are we building?
&lt;/h2&gt;

&lt;p&gt;In the &lt;strong&gt;netcup SCP&lt;/strong&gt; we build a network firewall from &lt;strong&gt;composable policy templates&lt;/strong&gt;: a &lt;strong&gt;base&lt;/strong&gt; template (that every server needs), one for &lt;strong&gt;SSH&lt;/strong&gt;, and one for &lt;strong&gt;web (HTTP/HTTPS)&lt;/strong&gt;. You assign these building blocks to your server as needed – a web server gets base + SSH + web, a server without a website only base + SSH. By the end, everything inbound is blocked except what you deliberately allow.&lt;/p&gt;

&lt;p&gt;A netcup-specific detail makes the difference: for &lt;strong&gt;UDP the firewall works statelessly&lt;/strong&gt; – which is why services like DNS and NTP need their own inbound rule, otherwise they suddenly get stuck. Why that is and how the base template solves it is shown in step 2.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;ℹ️ Network firewall ≠ host firewall&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The netcup firewall works in the &lt;strong&gt;network in front of the server&lt;/strong&gt; and does &lt;strong&gt;not&lt;/strong&gt; replace the firewall on the server itself (&lt;a href="https://serverkueche.de/en/tutorials/firewall-ufw-setup/" rel="noopener noreferrer"&gt;UFW&lt;/a&gt;). Both together are "defense in depth": if one layer fails or is misconfigured, the other kicks in. Feel free to set up both.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A netcup server, e.g. your &lt;a href="https://serverkueche.de/en/tutorials/first-steps-netcup-vps/" rel="noopener noreferrer"&gt;first VPS&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Your &lt;strong&gt;SCP credentials&lt;/strong&gt; (customer number + SCP password from the netcup welcome email – different from your SSH login).&lt;/li&gt;
&lt;li&gt;Clarity about your &lt;strong&gt;SSH port&lt;/strong&gt;: the default is &lt;code&gt;22&lt;/code&gt;, and it stays that way when &lt;a href="https://serverkueche.de/en/tutorials/harden-ssh/" rel="noopener noreferrer"&gt;hardening SSH&lt;/a&gt;. If you changed it elsewhere, use your real port – otherwise you lock yourself out when activating.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step by step
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Open Firewall Policies in the SCP
&lt;/h3&gt;

&lt;p&gt;Log into the &lt;a href="https://www.servercontrolpanel.de/" rel="noopener noreferrer"&gt;Server Control Panel&lt;/a&gt; and open the &lt;strong&gt;Firewall Policies&lt;/strong&gt; menu item at the top. Here you create reusable rule sets ("policies") – independent of the individual server. You only assign them later.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Build the base template
&lt;/h3&gt;

&lt;p&gt;Click &lt;strong&gt;Create firewall policy&lt;/strong&gt;, give it the name &lt;code&gt;Serverküche Base&lt;/code&gt; and, via &lt;strong&gt;Add rule&lt;/strong&gt;, create these four rules – all &lt;strong&gt;INBOUND&lt;/strong&gt; and &lt;strong&gt;ACCEPT&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;th&gt;Protocol&lt;/th&gt;
&lt;th&gt;Source port (Src)&lt;/th&gt;
&lt;th&gt;Destination port (Dst)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DNS replies (stateless)&lt;/td&gt;
&lt;td&gt;UDP&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;53&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;any&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NTP replies (stateless)&lt;/td&gt;
&lt;td&gt;UDP&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;123&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;any&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ICMP (ping/PMTU)&lt;/td&gt;
&lt;td&gt;ICMP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ICMPv6 (Neighbor Discovery)&lt;/td&gt;
&lt;td&gt;ICMPv6&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fumavarlxh5njjw91x8qo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fumavarlxh5njjw91x8qo.png" alt="The base template in the netcup SCP with four inbound ACCEPT rules: DNS and NTP via the source port, plus ICMP and ICMPv6" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two things that make the difference here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DNS/NTP via the &lt;code&gt;Src Port&lt;/code&gt;, not the destination port.&lt;/strong&gt; Your server &lt;em&gt;queries&lt;/em&gt; DNS/NTP (outbound); the &lt;strong&gt;reply&lt;/strong&gt; comes back from port 53 or 123. Because UDP is stateless, you need this inbound rule – otherwise there's no name resolution and no time synchronization, even though "everything else" seems to work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never forget ICMPv6.&lt;/strong&gt; Without Neighbor Discovery, IPv6 breaks completely. ICMP (v4) on top is useful for ping and path MTU discovery. netcup's default policy "Ping allow" does already cover ICMP – it's deliberately in the base template again so your foundation stays complete even if you ever remove the netcup defaults.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;ℹ️ As soon as one rule exists, netcup blocks the rest automatically&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You do &lt;strong&gt;not&lt;/strong&gt; need to create a final "block everything". &lt;strong&gt;Without&lt;/strong&gt; an assigned policy, everything inbound is allowed. &lt;strong&gt;But as soon as you assign the server at least one of your own policies, netcup switches the default to "block all"&lt;/strong&gt; – from then on everything inbound is blocked that none of your rules explicitly allows. So the whitelist arises on its own; outbound stays open.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;💡 Does your server use DHCP?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;netcup VPS are usually configured &lt;strong&gt;statically&lt;/strong&gt; (&lt;code&gt;iface … inet static&lt;/code&gt;) – then there's nothing to do here. netcup's network does run a DHCP server, but because UDP is stateless, the DHCP &lt;strong&gt;reply&lt;/strong&gt; (source port &lt;strong&gt;67&lt;/strong&gt;) is discarded by the whitelist. If your server is exceptionally configured via DHCP, add &lt;code&gt;INBOUND · UDP · ACCEPT · Src 67&lt;/code&gt; to the base template (for DHCPv6 additionally &lt;code&gt;Src 547&lt;/code&gt;) – otherwise it loses its IP at the next lease renewal.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 3: Small building blocks for SSH and web
&lt;/h3&gt;

&lt;p&gt;Instead of one big rule monolith, you create &lt;strong&gt;atomic templates&lt;/strong&gt; that you freely combine. Create two more policies following the same pattern:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Serverküche SSH&lt;/code&gt;&lt;/strong&gt; – one rule: INBOUND, TCP, ACCEPT, destination port &lt;strong&gt;22&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Serverküche Web&lt;/code&gt;&lt;/strong&gt; – two rules: INBOUND, TCP, ACCEPT, destination port &lt;strong&gt;80&lt;/strong&gt; and &lt;strong&gt;443&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmx6sbtce8kgli7kkv4mh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmx6sbtce8kgli7kkv4mh.png" alt="The three composable firewall templates in the netcup SCP overview: base, SSH and web" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The gain: the base rules appear only &lt;strong&gt;once&lt;/strong&gt;, not duplicated in every policy. If a service is added later (e.g. a mail server or WireGuard), you build another small template for it and plug it in – without touching the existing ones.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Assign templates to the server
&lt;/h3&gt;

&lt;p&gt;Switch to &lt;strong&gt;Server → your server → "Firewall" tab&lt;/strong&gt; and click &lt;strong&gt;Edit firewall policies&lt;/strong&gt;. Move the appropriate ones from &lt;strong&gt;Available firewall policies&lt;/strong&gt; to &lt;strong&gt;Selected&lt;/strong&gt;: for a web server &lt;code&gt;Serverküche Base&lt;/code&gt; + &lt;code&gt;Serverküche SSH&lt;/code&gt; + &lt;code&gt;Serverküche Web&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fscpjoyi8zruwudnhmsjp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fscpjoyi8zruwudnhmsjp.png" alt="The assignment dialog in the netcup SCP: on the left the available templates, on the right the ones selected for this server" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Confirm with &lt;strong&gt;Apply&lt;/strong&gt; and then &lt;strong&gt;Save&lt;/strong&gt;. Check that the &lt;strong&gt;"Firewall active"&lt;/strong&gt; switch is turned on. The assigned rules now appear in the list – together with netcup's &lt;strong&gt;default policies&lt;/strong&gt; (more on those below).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpxd8ldzds0951382ibwv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpxd8ldzds0951382ibwv.png" alt="The server's firewall tab with an active firewall and the assigned rules" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠️ Don't lock yourself out&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Only activate the firewall if the &lt;strong&gt;SSH rule&lt;/strong&gt; (port 22 or your real port) is in it. During the switch, keep a &lt;strong&gt;second SSH session open&lt;/strong&gt; and open a new connection in parallel to test access – only once that works do you close the old session. If you can't get in at all, the VNC console under &lt;strong&gt;Display&lt;/strong&gt; in the SCP helps.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 5: Ready-made templates for common services
&lt;/h3&gt;

&lt;p&gt;Exactly following the pattern from step 3, here are ready-made templates for the most common self-hosting services. Each is its &lt;strong&gt;own small template&lt;/strong&gt;, all rules are &lt;strong&gt;INBOUND&lt;/strong&gt; and &lt;strong&gt;ACCEPT&lt;/strong&gt;. Build only the ones you really need and plug them, as in step 4, into &lt;code&gt;Serverküche Base&lt;/code&gt; + &lt;code&gt;Serverküche SSH&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;ℹ️ For UDP services: destination port, not source port&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For UDP services you &lt;strong&gt;offer yourself&lt;/strong&gt; (VPN, TURN, your own DNS server), the port is in the &lt;strong&gt;destination port (Dst)&lt;/strong&gt; – because here clients connect &lt;em&gt;to your server&lt;/em&gt;. That's the &lt;strong&gt;opposite&lt;/strong&gt; of the DNS/NTP rules from the base template: there the port is in the &lt;strong&gt;source port (Src)&lt;/strong&gt;, because your server is the &lt;em&gt;querying client&lt;/em&gt; whose reply comes back. TCP replies aren't affected by this (the firewall state only matters for UDP); outbound is allowed anyway.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h4&gt;
  
  
  Mail server (mailcow &amp;amp; co.)
&lt;/h4&gt;

&lt;p&gt;Fits &lt;a href="https://serverkueche.de/en/tutorials/mailcow-mail-server/" rel="noopener noreferrer"&gt;mailcow&lt;/a&gt; and &lt;a href="https://serverkueche.de/en/tutorials/stalwart-mail-server/" rel="noopener noreferrer"&gt;Stalwart&lt;/a&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;th&gt;Protocol&lt;/th&gt;
&lt;th&gt;Source port (Src)&lt;/th&gt;
&lt;th&gt;Destination port (Dst)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SMTP (mail acceptance from others)&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;25&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Submission (STARTTLS)&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;587&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Submission (implicit TLS)&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;465&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IMAP (STARTTLS)&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;143&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IMAPS&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;993&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;POP3 (STARTTLS)&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;110&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;POP3S&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;995&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ManageSieve (filter rules)&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4190&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things that &lt;strong&gt;additionally&lt;/strong&gt; belong to a mail server:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Web UI and certificates:&lt;/strong&gt; mailcow needs &lt;code&gt;80&lt;/code&gt;/&lt;code&gt;443&lt;/code&gt; for the web interface and the Let's Encrypt retrieval – just assign the &lt;code&gt;Serverküche Web&lt;/code&gt; template along with it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enable outbound SMTP:&lt;/strong&gt; a mail server must be able to deliver on &lt;strong&gt;port 25 outbound&lt;/strong&gt;. netcup blocks outbound SMTP (ports &lt;strong&gt;25/465/587&lt;/strong&gt;) with the default policy "netcup Mail block" – you have to &lt;strong&gt;take it off the server&lt;/strong&gt;, i.e. deactivate that netcup template in the assignment (see "When things go wrong"). No support ticket needed. And without a correct &lt;a href="https://serverkueche.de/en/tutorials/connect-domain-to-server/" rel="noopener noreferrer"&gt;PTR/reverse-DNS entry&lt;/a&gt; your mails land in spam.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  WireGuard VPN
&lt;/h4&gt;

&lt;p&gt;Fits &lt;a href="https://serverkueche.de/en/tutorials/wireguard-vpn-setup/" rel="noopener noreferrer"&gt;set up a WireGuard VPN&lt;/a&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;th&gt;Protocol&lt;/th&gt;
&lt;th&gt;Source port (Src)&lt;/th&gt;
&lt;th&gt;Destination port (Dst)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;WireGuard&lt;/td&gt;
&lt;td&gt;UDP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;51820&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;51820&lt;/code&gt; is the usual default – use the value from your &lt;code&gt;ListenPort&lt;/code&gt;. Only this one inbound rule is needed; the replies to the clients go out outbound.&lt;/p&gt;

&lt;h4&gt;
  
  
  OpenVPN
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;th&gt;Protocol&lt;/th&gt;
&lt;th&gt;Source port (Src)&lt;/th&gt;
&lt;th&gt;Destination port (Dst)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;OpenVPN (UDP, default)&lt;/td&gt;
&lt;td&gt;UDP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1194&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenVPN (TCP fallback)&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1194&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The default is &lt;strong&gt;UDP 1194&lt;/strong&gt;. The TCP rule only if you deliberately run OpenVPN over TCP (some additionally put it on &lt;code&gt;TCP 443&lt;/code&gt; to get through restrictive foreign networks) – otherwise leave it out.&lt;/p&gt;

&lt;h4&gt;
  
  
  Zabbix monitoring
&lt;/h4&gt;

&lt;p&gt;Here it depends on the server's role:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;th&gt;Protocol&lt;/th&gt;
&lt;th&gt;Destination port (Dst)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Monitored host&lt;/strong&gt; (Zabbix agent)&lt;/td&gt;
&lt;td&gt;passive checks from the server&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10050&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Zabbix server&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;trapper (active agents/proxies)&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10051&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Zabbix server additionally needs &lt;code&gt;80&lt;/code&gt;/&lt;code&gt;443&lt;/code&gt; for the web frontend (&lt;code&gt;Serverküche Web&lt;/code&gt;). If you run &lt;strong&gt;active&lt;/strong&gt; checks, the agent connects outbound to the server on &lt;code&gt;10051&lt;/code&gt; – for that, &lt;strong&gt;no&lt;/strong&gt; inbound rule is needed on the agent host.&lt;/p&gt;

&lt;h4&gt;
  
  
  Checkmk
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;th&gt;Protocol&lt;/th&gt;
&lt;th&gt;Destination port (Dst)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Monitored host&lt;/strong&gt; (agent)&lt;/td&gt;
&lt;td&gt;agent controller, pull mode&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6556&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Checkmk server&lt;/strong&gt; (optional)&lt;/td&gt;
&lt;td&gt;agent receiver (push/registration)&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8000&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Checkmk server&lt;/strong&gt; (optional)&lt;/td&gt;
&lt;td&gt;Livestatus (distributed monitoring)&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6557&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In the &lt;strong&gt;default pull mode&lt;/strong&gt;, the Checkmk server fetches the data actively – so it connects outbound to &lt;code&gt;6556&lt;/code&gt; of the agents. Inbound, therefore, only the &lt;strong&gt;monitored host&lt;/strong&gt; needs port &lt;code&gt;6556&lt;/code&gt;. The Checkmk server itself gets by with &lt;code&gt;80&lt;/code&gt;/&lt;code&gt;443&lt;/code&gt; (&lt;code&gt;Serverküche Web&lt;/code&gt;); &lt;code&gt;8000&lt;/code&gt; and &lt;code&gt;6557&lt;/code&gt; only if you use push mode or distributed monitoring.&lt;/p&gt;

&lt;h4&gt;
  
  
  Coturn / TURN server (Nextcloud Talk, Matrix)
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;th&gt;Protocol&lt;/th&gt;
&lt;th&gt;Source port (Src)&lt;/th&gt;
&lt;th&gt;Destination port (Dst)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;STUN/TURN&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3478&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;STUN/TURN&lt;/td&gt;
&lt;td&gt;UDP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3478&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;STUN/TURN over TLS&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5349&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;STUN/TURN over TLS&lt;/td&gt;
&lt;td&gt;UDP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5349&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Media relay (audio/video)&lt;/td&gt;
&lt;td&gt;UDP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;49152–65535&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The large UDP relay range is the default – the actual audio/video streams run over it. You can narrow it in the coturn configuration (&lt;code&gt;min-port&lt;/code&gt;/&lt;code&gt;max-port&lt;/code&gt;) and then set the firewall rule to the same, smaller range.&lt;/p&gt;

&lt;h4&gt;
  
  
  Jitsi Meet (video conferencing)
&lt;/h4&gt;

&lt;p&gt;Goes with Jitsi Meet: your own video conferences (Tutorial expected in November).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;th&gt;Protocol&lt;/th&gt;
&lt;th&gt;Source port (Src)&lt;/th&gt;
&lt;th&gt;Destination port (Dst)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Media (video bridge)&lt;/td&gt;
&lt;td&gt;UDP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10000&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is the policy people forget most often, because &lt;strong&gt;without&lt;/strong&gt; it Jitsi looks entirely normal: the web interface arrives over 443 and is already covered by the base and web templates, the participant list fills up, and only once somebody speaks do you notice that nothing arrives. Audio and video do not go through the reverse proxy but straight to &lt;strong&gt;UDP 10000&lt;/strong&gt; – and because the netcup firewall is stateless for UDP, that path needs an inbound rule of its own.&lt;/p&gt;

&lt;p&gt;A rule in the other direction is not needed: with Jitsi the destination port stays fixed at 10000, unlike coturn with its large relay range.&lt;/p&gt;

&lt;h4&gt;
  
  
  Your own DNS server (AdGuard Home, Pi-hole, Unbound)
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;th&gt;Protocol&lt;/th&gt;
&lt;th&gt;Source port (Src)&lt;/th&gt;
&lt;th&gt;Destination port (Dst)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DNS queries&lt;/td&gt;
&lt;td&gt;UDP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;53&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DNS queries (large replies/TCP)&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;53&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DNS-over-TLS (DoT)&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;853&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Careful, this is exactly the &lt;strong&gt;opposite direction&lt;/strong&gt; to the base template: there &lt;code&gt;Src 53&lt;/code&gt; allows the &lt;em&gt;replies&lt;/em&gt; to your own DNS queries; here &lt;code&gt;Dst 53&lt;/code&gt; allows the &lt;em&gt;queries of foreign clients&lt;/em&gt; to your DNS server. Both exist side by side without problems.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠️ No open resolver&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A globally open recursive DNS server is abused for &lt;strong&gt;DNS amplification attacks&lt;/strong&gt;. Restrict access to your own networks (set the source address in the rule) or offer DNS exclusively encrypted (DoT/DoH) for registered clients – never open &lt;code&gt;53&lt;/code&gt; "just quickly" for the whole world.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h4&gt;
  
  
  Remote database access (emergency only)
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;th&gt;Protocol&lt;/th&gt;
&lt;th&gt;Source port (Src)&lt;/th&gt;
&lt;th&gt;Destination port (Dst)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5432&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MySQL / MariaDB&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3306&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;🛑 Databases don't belong on the open internet&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A directly reachable database is a preferred attack target. The &lt;strong&gt;right&lt;/strong&gt; way is to &lt;strong&gt;not&lt;/strong&gt; open the port at all and instead access it via a WireGuard tunnel or an SSH tunnel (&lt;code&gt;ssh -L 5432:localhost:5432 …&lt;/code&gt;). If you must open the port anyway, then &lt;strong&gt;only&lt;/strong&gt; with a source address restricted to your fixed admin IP in the netcup rule – never for everyone.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  When things go wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;After activating, name resolution stops working (&lt;code&gt;apt update&lt;/code&gt; hangs, &lt;code&gt;ping domain.de&lt;/code&gt; fails, but &lt;code&gt;ping 1.1.1.1&lt;/code&gt; works).&lt;/strong&gt; The DNS &lt;strong&gt;replies&lt;/strong&gt; are being blocked. Check the rule in the base template &lt;em&gt;INBOUND UDP ACCEPT, Src port 53&lt;/em&gt;. Important: &lt;strong&gt;source&lt;/strong&gt; port, not destination port.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The clock drifts, or TLS certificates are rejected due to the wrong time.&lt;/strong&gt; The NTP replies are missing. Add &lt;em&gt;INBOUND UDP ACCEPT, Src port 123&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;IPv6 no longer works (v4 does).&lt;/strong&gt; The &lt;strong&gt;ICMPv6&lt;/strong&gt; rule is missing. Without Neighbor Discovery, the server can't even find its neighbor (router) over IPv6.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After activating, you can no longer get in via SSH.&lt;/strong&gt; The SSH rule is missing or names the wrong port. Via the &lt;strong&gt;VNC console&lt;/strong&gt; (SCP → Display) you can still reach the server; correct the policy and save again. That's exactly what the "keep a second session open" rule above helps against.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The server can't send emails (outbound port 25 doesn't work).&lt;/strong&gt; That's &lt;strong&gt;not&lt;/strong&gt; an error in your policy, but netcup's &lt;strong&gt;default policy "netcup Mail block"&lt;/strong&gt; – it blocks outbound SMTP (ports 25/465/587) as spam protection. For real mail sending you have to disable this netcup template on the server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The server initially runs normally and is suddenly offline after one or two weeks.&lt;/strong&gt; If the server uses DHCP, the &lt;strong&gt;lease renewal&lt;/strong&gt; is blocked by the whitelist – the DHCP reply comes from UDP source port &lt;strong&gt;67&lt;/strong&gt;, which no rule allows, and UDP is stateless. Add &lt;code&gt;INBOUND · UDP · ACCEPT · Src 67&lt;/code&gt; to the base template. Statically configured netcup VPS (the default) aren't affected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Maintenance &amp;amp; backups
&lt;/h2&gt;

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




&lt;p&gt;&lt;em&gt;This post first appeared on &lt;a href="https://serverkueche.de/en/tutorials/netcup-firewall-setup/" rel="noopener noreferrer"&gt;serverkueche.de&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>networking</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>unattended-upgrades: automatic security updates for Debian</title>
      <dc:creator>serverkueche.de</dc:creator>
      <pubDate>Thu, 24 Sep 2026 13:03:11 +0000</pubDate>
      <link>https://dev.to/serverkueche/unattended-upgrades-automatic-security-updates-for-debian-52l9</link>
      <guid>https://dev.to/serverkueche/unattended-upgrades-automatic-security-updates-for-debian-52l9</guid>
      <description>&lt;p&gt;"Remember to install updates weekly" – this sentence from the maintenance sections of the other tutorials is the first one people forget. That's exactly why the server now takes it over itself: &lt;strong&gt;unattended-upgrades&lt;/strong&gt; installs security updates automatically. That closes the most dangerous gap in self-hosting – the server that runs unpatched for months.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are we building?
&lt;/h2&gt;

&lt;p&gt;By the end, your &lt;strong&gt;Debian 13&lt;/strong&gt; automatically pulls &lt;strong&gt;security updates&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;a href="https://serverkueche.de/en/tutorials/harden-ssh/" rel="noopener noreferrer"&gt;hardened server&lt;/a&gt; with an &lt;a href="https://serverkueche.de/en/tutorials/firewall-ufw-setup/" rel="noopener noreferrer"&gt;active firewall&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step by step
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Install the package
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; unattended-upgrades apt-listchanges
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;apt-listchanges&lt;/code&gt; shows the changelogs on updates – useful if you later do check manually what changed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Enable the automation
&lt;/h3&gt;

&lt;p&gt;The simplest way to switch on the daily run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;dpkg-reconfigure &lt;span class="nt"&gt;-plow&lt;/span&gt; unattended-upgrades
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Choose &lt;strong&gt;Yes&lt;/strong&gt; in the dialog. That creates the file &lt;code&gt;/etc/apt/apt.conf.d/20auto-upgrades&lt;/code&gt; with this content:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="err"&gt;APT::Periodic::Update-Package-Lists&lt;/span&gt; &lt;span class="err"&gt;"1"&lt;/span&gt;&lt;span class="c"&gt;;
&lt;/span&gt;&lt;span class="err"&gt;APT::Periodic::Unattended-Upgrade&lt;/span&gt; &lt;span class="err"&gt;"1"&lt;/span&gt;&lt;span class="c"&gt;;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;1&lt;/code&gt; means "daily": update package lists &lt;strong&gt;and&lt;/strong&gt; run unattended upgrades. It's triggered via the systemd timers &lt;code&gt;apt-daily.timer&lt;/code&gt; (package lists) and &lt;code&gt;apt-daily-upgrade.timer&lt;/code&gt; (upgrade run) – no separate cron job needed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Define what gets updated
&lt;/h3&gt;

&lt;p&gt;Take a look at &lt;code&gt;/etc/apt/apt.conf.d/50unattended-upgrades&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nano /etc/apt/apt.conf.d/50unattended-upgrades
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Debian 13, &lt;strong&gt;three&lt;/strong&gt; sources are active in the &lt;code&gt;Origins-Pattern&lt;/code&gt; block by default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="err"&gt;Unattended-Upgrade::Origins-Pattern&lt;/span&gt; &lt;span class="err"&gt;{&lt;/span&gt;
  &lt;span class="err"&gt;"&lt;/span&gt;&lt;span class="py"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Debian,codename=${distro_codename},label=Debian";&lt;/span&gt;
  &lt;span class="err"&gt;"&lt;/span&gt;&lt;span class="py"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Debian,codename=${distro_codename},label=Debian-Security";&lt;/span&gt;
  &lt;span class="err"&gt;"&lt;/span&gt;&lt;span class="py"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Debian,codename=${distro_codename}-security,label=Debian-Security";&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;&lt;span class="c"&gt;;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two &lt;code&gt;Debian-Security&lt;/code&gt; lines ensure &lt;strong&gt;timely security updates&lt;/strong&gt; – the actual purpose. The first line (&lt;code&gt;label=Debian&lt;/code&gt;) covers the &lt;strong&gt;conservative stable updates&lt;/strong&gt; that only arrive with Debian point releases; so it's not a risky rolling update, but well-seasoned. If you really want &lt;strong&gt;only&lt;/strong&gt; security updates, comment out the first line with &lt;code&gt;//&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two options are worth setting explicitly here. &lt;strong&gt;Clean up old kernels/packages automatically&lt;/strong&gt;, otherwise &lt;code&gt;/boot&lt;/code&gt; eventually fills up (&lt;code&gt;Remove-Unused-Kernel-Packages&lt;/code&gt; is already the default in code, but only appears commented out in the file – set explicitly, the decision is documented):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="err"&gt;Unattended-Upgrade::Remove-Unused-Kernel-Packages&lt;/span&gt; &lt;span class="err"&gt;"true"&lt;/span&gt;&lt;span class="c"&gt;;
&lt;/span&gt;&lt;span class="err"&gt;Unattended-Upgrade::Remove-Unused-Dependencies&lt;/span&gt; &lt;span class="err"&gt;"true"&lt;/span&gt;&lt;span class="c"&gt;;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the most important point – the &lt;strong&gt;automatic reboot&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="err"&gt;Unattended-Upgrade::Automatic-Reboot&lt;/span&gt; &lt;span class="err"&gt;"true"&lt;/span&gt;&lt;span class="c"&gt;;
&lt;/span&gt;&lt;span class="err"&gt;Unattended-Upgrade::Automatic-Reboot-Time&lt;/span&gt; &lt;span class="err"&gt;"04:00"&lt;/span&gt;&lt;span class="c"&gt;;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠️ Automatic reboot – decide deliberately&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Some security updates (especially kernel) only take effect after a reboot. With &lt;code&gt;Automatic-Reboot "true"&lt;/code&gt; the server then reboots on its own – &lt;strong&gt;always set an &lt;code&gt;Automatic-Reboot-Time&lt;/code&gt;&lt;/strong&gt; when you do: without it, the server reboots &lt;strong&gt;immediately&lt;/strong&gt; after the upgrade run (the code default is &lt;code&gt;"now"&lt;/code&gt;; the &lt;code&gt;02:00&lt;/code&gt; in the example file is only a commented-out suggestion). Choose a time with low usage. If you want &lt;strong&gt;no&lt;/strong&gt; automatic reboots, leave the option at &lt;code&gt;"false"&lt;/code&gt; and reboot yourself when &lt;code&gt;/var/run/reboot-required&lt;/code&gt; exists. Both are defensible – it just has to be a deliberate decision.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;What does that reboot actually depend on? unattended-upgrades only reboots if the marker file &lt;code&gt;/var/run/reboot-required&lt;/code&gt; exists. A &lt;strong&gt;bare&lt;/strong&gt; Debian 13 does not create it on kernel updates – on Ubuntu that hook lives in a helper package that doesn't exist in Debian. The &lt;code&gt;unattended-upgrades&lt;/code&gt; package, however, ships its own kernel hook, so there is nothing for you to build. A quick check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dpkg &lt;span class="nt"&gt;-S&lt;/span&gt; /etc/kernel/postinst.d/unattended-upgrades
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;unattended-upgrades: /etc/kernel/postinst.d/unattended-upgrades
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hook runs after every kernel installation, sets &lt;code&gt;/var/run/reboot-required&lt;/code&gt; and also records the triggering package in &lt;code&gt;/var/run/reboot-required.pkgs&lt;/code&gt;. That makes both &lt;code&gt;Automatic-Reboot&lt;/code&gt; and the manual check of the file reliable.&lt;/p&gt;

&lt;p&gt;On a Debian &lt;strong&gt;without&lt;/strong&gt; unattended-upgrades the file is never created – there, a missing &lt;code&gt;/var/run/reboot-required&lt;/code&gt; tells you nothing about whether a reboot is pending (see &lt;a href="https://serverkueche.de/en/tutorials/first-steps-netcup-vps/" rel="noopener noreferrer"&gt;First steps with a netcup VPS&lt;/a&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Do a dry run
&lt;/h3&gt;

&lt;p&gt;Test what unattended-upgrades would do, without a real installation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;unattended-upgrade &lt;span class="nt"&gt;--dry-run&lt;/span&gt; &lt;span class="nt"&gt;--debug&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the debug output, this line is decisive (on a fresh Debian 13, shortened):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Allowed origins are: origin=Debian,codename=trixie,label=Debian, origin=Debian,codename=trixie,label=Debian-Security, origin=Debian,codename=trixie-security,label=Debian-Security
[...]
No packages found that can be upgraded unattended and no pending auto-removals
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Debian-Security sources must appear there. If something is listed, the configuration applies; if the run finds nothing to do (as above), there's simply no security update pending right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  When things go wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The dry run reports &lt;code&gt;No packages found that can be upgraded unattended&lt;/code&gt;.&lt;/strong&gt; Usually perfectly normal – no security update is currently pending. Check the configuration anyway via the &lt;code&gt;Allowed origins are:&lt;/code&gt; line in the &lt;code&gt;--debug&lt;/code&gt; run. If no security origins appear there, the &lt;code&gt;Origins-Pattern&lt;/code&gt; from step 3 isn't right.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Updates come, but the server never reboots despite a kernel update.&lt;/strong&gt; Usually &lt;code&gt;Automatic-Reboot&lt;/code&gt; is set to &lt;code&gt;"false"&lt;/code&gt; (default) – then set it to &lt;code&gt;"true"&lt;/code&gt; and give it an &lt;code&gt;Automatic-Reboot-Time&lt;/code&gt; (step 3). If the option is right, check the marker file: without &lt;code&gt;/var/run/reboot-required&lt;/code&gt; the reboot never triggers. The package's kernel hook is what sets it – &lt;code&gt;dpkg -S /etc/kernel/postinst.d/unattended-upgrades&lt;/code&gt; must report it (step 3).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;/boot&lt;/code&gt; fills up, updates fail.&lt;/strong&gt; Old kernels pile up. Set &lt;code&gt;Remove-Unused-Kernel-Packages "true"&lt;/code&gt; (step 3); clean up once with &lt;code&gt;sudo apt autoremove --purge&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A package is stubbornly held back (&lt;code&gt;kept back&lt;/code&gt;).&lt;/strong&gt; unattended-upgrades doesn't install updates that would remove other packages. You resolve such cases deliberately by hand with &lt;code&gt;sudo apt upgrade&lt;/code&gt; and check what happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Maintenance &amp;amp; backups
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Still check anyway:&lt;/strong&gt; automation doesn't replace attention. Take a monthly look at the log &lt;code&gt;/var/log/unattended-upgrades/unattended-upgrades.log&lt;/code&gt; and occasionally run &lt;code&gt;sudo apt update &amp;amp;&amp;amp; sudo apt upgrade&lt;/code&gt; for the &lt;strong&gt;non&lt;/strong&gt;-security-critical updates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plan for reboots:&lt;/strong&gt; if you use automatic reboots, make sure your services survive a reboot cleanly (&lt;code&gt;restart: unless-stopped&lt;/code&gt; in &lt;a href="https://serverkueche.de/en/tutorials/docker-compose-basics/" rel="noopener noreferrer"&gt;Docker Compose&lt;/a&gt; if you run containers).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No dedicated backup needed&lt;/strong&gt;, but note whether you enabled automatic reboots – it later explains why the server was briefly gone at night.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This post first appeared on &lt;a href="https://serverkueche.de/en/tutorials/unattended-upgrades-automatic-updates/" rel="noopener noreferrer"&gt;serverkueche.de&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>security</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>Setting up Traefik: reverse proxy with automatic HTTPS</title>
      <dc:creator>serverkueche.de</dc:creator>
      <pubDate>Thu, 24 Sep 2026 13:02:32 +0000</pubDate>
      <link>https://dev.to/serverkueche/setting-up-traefik-reverse-proxy-with-automatic-https-18cg</link>
      <guid>https://dev.to/serverkueche/setting-up-traefik-reverse-proxy-with-automatic-https-18cg</guid>
      <description>&lt;p&gt;This is the most important building block of the Serverküche. A &lt;strong&gt;reverse proxy&lt;/strong&gt; takes in all requests on ports 80 and 443 and distributes them to the right container based on the domain – and &lt;strong&gt;Traefik&lt;/strong&gt; fetches the HTTPS certificates fully automatically from Let's Encrypt. From here on, every further app gets its domain and its TLS with a few lines of labels, without you ever touching a certificate by hand again.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are we building?
&lt;/h2&gt;

&lt;p&gt;By the end, &lt;strong&gt;Traefik v3&lt;/strong&gt; runs as the central entry point on your server. It listens on ports 80/443, detects new containers automatically via Docker labels, redirects HTTP to HTTPS automatically, and obtains a valid &lt;strong&gt;Let's Encrypt certificate&lt;/strong&gt; for every domain. As the first app, we hang &lt;code&gt;whoami&lt;/code&gt; behind the proxy – a tiny test service that shows routing and TLS are working. A secured dashboard comes on top.&lt;/p&gt;

&lt;p&gt;The pattern from this tutorial – a shared &lt;code&gt;proxy&lt;/code&gt; network plus a few labels – repeats afterwards in &lt;strong&gt;every&lt;/strong&gt; app recipe.&lt;/p&gt;

&lt;p&gt;A request always passes through the same four stations in Traefik – this vocabulary helps you debug:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Entrypoint&lt;/strong&gt; – the port on which the request arrives (&lt;code&gt;web&lt;/code&gt; = 80, &lt;code&gt;websecure&lt;/code&gt; = 443).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Router&lt;/strong&gt; – decides based on a &lt;strong&gt;rule&lt;/strong&gt; (usually &lt;code&gt;Host(...)&lt;/code&gt;) whether this request belongs to an app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Middleware&lt;/strong&gt; &lt;em&gt;(optional)&lt;/em&gt; – modifies the request along the way (e.g. HTTPS redirect, basic auth, security headers).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service&lt;/strong&gt; – the container that ultimately responds.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Debugging mnemonic: "&lt;strong&gt;Entrypoint → Router → Middleware → Service&lt;/strong&gt;". If a request ends up nowhere, it's almost always the router (wrong domain) or the network (service not reachable) at fault.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://serverkueche.de/en/tutorials/install-docker/" rel="noopener noreferrer"&gt;Docker + Compose installed&lt;/a&gt; and the &lt;a href="https://serverkueche.de/en/tutorials/docker-compose-basics/" rel="noopener noreferrer"&gt;Compose basics&lt;/a&gt; understood&lt;/li&gt;
&lt;li&gt;A rough idea of &lt;a href="https://serverkueche.de/en/tutorials/how-https-works/" rel="noopener noreferrer"&gt;how HTTPS works&lt;/a&gt; – Traefik does the work for you, but the background helps when a certificate gets stuck&lt;/li&gt;
&lt;li&gt;A &lt;a href="https://serverkueche.de/en/tutorials/connect-domain-to-server/" rel="noopener noreferrer"&gt;domain connected to the server&lt;/a&gt;: &lt;code&gt;YOUR_DOMAIN&lt;/code&gt; and the subdomains must resolve to the server via A/AAAA&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ports 80 and 443 are reachable from the internet&lt;/strong&gt; – Let's Encrypt uses them to verify that you own the domain. Open firewalls accordingly (see &lt;a href="https://serverkueche.de/en/tutorials/firewall-ufw-setup/" rel="noopener noreferrer"&gt;setting up a firewall with UFW&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠️ No resolving domain, no certificate&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Let's Encrypt only issues certificates for domains it can reach. Check &lt;strong&gt;beforehand&lt;/strong&gt; with &lt;code&gt;dig +short YOUR_DOMAIN&lt;/code&gt; that your server IP comes back. If the record still points nowhere, certificate issuance fails – that's the most common Traefik error of all.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step by step
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Create the shared proxy network
&lt;/h3&gt;

&lt;p&gt;Traefik and all apps must share a Docker network so Traefik can reach the containers. We create it &lt;strong&gt;once&lt;/strong&gt; and explicitly, so later stacks can simply dock onto it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker network create &lt;span class="nt"&gt;--ipv6&lt;/span&gt; &lt;span class="nt"&gt;--subnet&lt;/span&gt; fd00:cafe::/64 proxy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker network &lt;span class="nb"&gt;ls&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;proxy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ace6d528b14b   proxy     bridge    local
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This network is independent of the individual Compose projects – that's why we later include it as &lt;code&gt;external&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;--ipv6&lt;/code&gt;?&lt;/strong&gt; A Docker network without IPv6 does accept IPv6 visitors, but Docker then cannot rewrite the connection onto the container directly and routes it through a helper process. That process replaces the source address with the bridge gateway's: in Traefik and in every app behind it you then see &lt;code&gt;172.x.x.x&lt;/code&gt; instead of the real address. Over IPv4 you never notice, over IPv6 you always do – and a domain with an AAAA record is reached over IPv6 by most browsers. The range &lt;code&gt;fd00:cafe::/64&lt;/code&gt; is private (ULA) and only visible internally; you can pick any other range out of &lt;code&gt;fd00::/8&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠️ If the proxy network already exists without IPv6&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You cannot retrofit this – the network has to be created anew, and for that no container may still be attached to it. So first shut down &lt;strong&gt;all stacks&lt;/strong&gt; (&lt;code&gt;docker compose down&lt;/code&gt; in every project folder, Traefik last), then:&lt;/p&gt;


&lt;pre class="highlight shell"&gt;&lt;code&gt;docker network &lt;span class="nb"&gt;rm &lt;/span&gt;proxy
docker network create &lt;span class="nt"&gt;--ipv6&lt;/span&gt; &lt;span class="nt"&gt;--subnet&lt;/span&gt; fd00:cafe::/64 proxy
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;Afterwards bring everything back up, Traefik first. You check the result with &lt;code&gt;docker network inspect proxy | grep EnableIPv6&lt;/code&gt; – it has to say &lt;code&gt;true&lt;/code&gt;. No data is lost in the process; volumes are not attached to the network.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 2: Create the Traefik project
&lt;/h3&gt;

&lt;p&gt;Create a dedicated folder for Traefik and, inside it, the file where the certificates are stored:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/traefik &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; ~/traefik
&lt;span class="nb"&gt;touch &lt;/span&gt;acme.json
&lt;span class="nb"&gt;chmod &lt;/span&gt;600 acme.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;🛑 acme.json needs 600&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Without &lt;code&gt;chmod 600 acme.json&lt;/code&gt;, &lt;strong&gt;Traefik skips the Let's Encrypt resolver&lt;/strong&gt;: the container does start, but issues no valid certificate – you land on Traefik's self-signed emergency certificate. The log then reads &lt;code&gt;permissions 644 for /acme.json are too open, please use 600&lt;/code&gt;. The file contains your private keys – only the owner may read it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 3: The Traefik compose.yaml
&lt;/h3&gt;

&lt;p&gt;Now the central configuration. It's long, but every line has a purpose – the explanation follows right below:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;traefik&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik:v3.7&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# Dashboard (secured in step 7)&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--api.dashboard=true"&lt;/span&gt;
      &lt;span class="c1"&gt;# Docker as source; only containers with traefik.enable=true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--providers.docker=true"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--providers.docker.exposedbydefault=false"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--providers.docker.network=proxy"&lt;/span&gt;
      &lt;span class="c1"&gt;# Entrypoints: 80 (HTTP) and 443 (HTTPS)&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--entrypoints.web.address=:80"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--entrypoints.websecure.address=:443"&lt;/span&gt;
      &lt;span class="c1"&gt;# Redirect everything from HTTP to HTTPS automatically&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--entrypoints.web.http.redirections.entrypoint.to=websecure"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--entrypoints.web.http.redirections.entrypoint.scheme=https"&lt;/span&gt;
      &lt;span class="c1"&gt;# Let's Encrypt resolver named "le" via HTTP challenge&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--certificatesresolvers.le.acme.email=YOUR_EMAIL"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--certificatesresolvers.le.acme.storage=/acme.json"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--certificatesresolvers.le.acme.httpchallenge=true"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--certificatesresolvers.le.acme.httpchallenge.entrypoint=web"&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;80:80"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;443:443"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/run/docker.sock:/var/run/docker.sock:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./acme.json:/acme.json&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;proxy&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;proxy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The most important blocks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;providers.docker&lt;/code&gt; + &lt;code&gt;exposedbydefault=false&lt;/code&gt;&lt;/strong&gt;: Traefik watches the Docker socket, but only containers that explicitly carry &lt;code&gt;traefik.enable=true&lt;/code&gt;. No service is accidentally made public.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;providers.docker.network=proxy&lt;/code&gt;&lt;/strong&gt;: tells Traefik which network it uses to reach the containers – important when containers are attached to several networks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;entrypoints web/websecure&lt;/code&gt;&lt;/strong&gt;: ports 80 and 443. The two &lt;code&gt;redirections&lt;/code&gt; lines send every HTTP call automatically to HTTPS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;certificatesresolvers.le&lt;/code&gt;&lt;/strong&gt;: the Let's Encrypt resolver. Via the &lt;strong&gt;HTTP challenge&lt;/strong&gt;, Traefik proves to Let's Encrypt that the domain points to this server and stores the certificate in &lt;code&gt;acme.json&lt;/code&gt;. For this, &lt;strong&gt;port 80 must stay reachable from outside&lt;/strong&gt; – even if your app only runs over HTTPS, because the challenge comes over HTTP. Let's Encrypt uses the &lt;code&gt;acme.email&lt;/code&gt; solely for warnings about expiring certificates; enter a real address.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;Docker socket&lt;/strong&gt; is mounted read-only (&lt;code&gt;:ro&lt;/code&gt;) – Traefik must read it, but not write to it.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 Test with the staging server first&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Let's Encrypt has strict &lt;strong&gt;rate limits&lt;/strong&gt; for production certificates. While you're still building, add &lt;code&gt;--certificatesresolvers.le.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory&lt;/code&gt; as a test. This delivers test certificates (shown as insecure in the browser) without a limit. Once everything works, remove the line, &lt;strong&gt;empty &lt;code&gt;acme.json&lt;/code&gt;&lt;/strong&gt; (&lt;code&gt;&amp;gt; acme.json&lt;/code&gt;) and restart Traefik – then the real certificate comes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Start Traefik:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt; traefik
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The logs must show &lt;strong&gt;no&lt;/strong&gt; &lt;code&gt;ERR&lt;/code&gt; about ACME or the provider. &lt;code&gt;Ctrl+C&lt;/code&gt; only ends the following, not the container.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 An empty log is a good sign&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Traefik v3 writes &lt;strong&gt;only errors&lt;/strong&gt; at the default log level. So an empty log output means: everything is running. If you want to see more during setup (every detected router, every ACME request), add &lt;code&gt;--log.level=INFO&lt;/code&gt; to the &lt;code&gt;command&lt;/code&gt; block and restart.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 4: The first app behind Traefik (whoami)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;whoami&lt;/code&gt; is a tiny service that returns the received request – perfect for testing. Own folder, own &lt;code&gt;compose.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/whoami &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; ~/whoami
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;whoami&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik/whoami:v1.12&lt;/span&gt;
    &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.enable=true"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.whoami.rule=Host(`whoami.YOUR_DOMAIN`)"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.whoami.entrypoints=websecure"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.whoami.tls.certresolver=le"&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;proxy&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;proxy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are the four labels you'll need again and again from now on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;traefik.enable=true&lt;/code&gt;&lt;/strong&gt; – only then does Traefik touch the container.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;...routers.whoami.rule=Host(...)&lt;/code&gt;&lt;/strong&gt; – at which domain this container responds. &lt;code&gt;whoami&lt;/code&gt; is a freely chosen router name (unique per container).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;...entrypoints=websecure&lt;/code&gt;&lt;/strong&gt; – reachable over HTTPS (443).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;...tls.certresolver=le&lt;/code&gt;&lt;/strong&gt; – fetch the certificate via the resolver &lt;code&gt;le&lt;/code&gt; defined in step 3.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Important: the service has &lt;strong&gt;no &lt;code&gt;ports:&lt;/code&gt;&lt;/strong&gt; – it's only reachable via Traefik, not directly from outside. And it's on the &lt;strong&gt;&lt;code&gt;proxy&lt;/code&gt; network&lt;/strong&gt;, otherwise Traefik won't find it.&lt;/p&gt;

&lt;p&gt;Create the DNS record &lt;code&gt;whoami.YOUR_DOMAIN&lt;/code&gt; beforehand (A/AAAA to the server IP, &lt;a href="https://serverkueche.de/en/tutorials/connect-domain-to-server/" rel="noopener noreferrer"&gt;as in the DNS tutorial&lt;/a&gt;), then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;https://whoami.YOUR_DOMAIN&lt;/code&gt; in the browser. On the first call, certificate issuance takes a few seconds; after that you see a valid padlock icon and a text output like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Hostname: 7854060e86a8
IP: 127.0.0.1
IP: ::1
IP: 172.19.0.3
RemoteAddr: 172.19.0.2:45224
GET / HTTP/1.1
Host: whoami.YOUR_DOMAIN
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Host:&lt;/code&gt; line confirms that Traefik routed correctly to this container based on the domain. Exactly this behavior – a request for &lt;code&gt;whoami.YOUR_DOMAIN&lt;/code&gt; lands at the whoami container, an unknown domain gets a &lt;strong&gt;404&lt;/strong&gt; – is the heart of the reverse proxy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check which CA issued the certificate.&lt;/strong&gt; This separates "HTTPS is running" from "I only see Traefik's emergency certificate":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; | openssl s_client &lt;span class="nt"&gt;-connect&lt;/span&gt; whoami.YOUR_DOMAIN:443 &lt;span class="nt"&gt;-servername&lt;/span&gt; whoami.YOUR_DOMAIN 2&amp;gt;/dev/null | openssl x509 &lt;span class="nt"&gt;-noout&lt;/span&gt; &lt;span class="nt"&gt;-issuer&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As long as you use the &lt;strong&gt;staging&lt;/strong&gt; server (as recommended in step 3), a test issuer appears there – the browser still shows the certificate as insecure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;issuer=C=US, O=Let's Encrypt, CN=(STAGING) Ersatz Emmer YR2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;TRAEFIK DEFAULT CERT&lt;/code&gt; appears here, the resolver fetched no certificate – then go to troubleshooting below. If a Let's Encrypt issuer is there, the whole chain works.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Switch to the real certificate
&lt;/h3&gt;

&lt;p&gt;Once staging runs cleanly, you fetch the real certificate that's valid in the browser. Remove the &lt;code&gt;caserver&lt;/code&gt; line from the &lt;code&gt;traefik&lt;/code&gt; service (step 3), &lt;strong&gt;empty the staging certificates&lt;/strong&gt; and restart Traefik:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; acme.json                 &lt;span class="c"&gt;# discards the staging certificates (chmod 600 stays)&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the next call, Traefik fetches a fresh production certificate. In the &lt;code&gt;issuer&lt;/code&gt; line from above, the &lt;code&gt;(STAGING)&lt;/code&gt; then disappears, and the browser shows a valid padlock.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠️ Staging first, then production&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Let's Encrypt has hard &lt;strong&gt;rate limits&lt;/strong&gt; on production certificates (a few per domain per week). Only switch to production once routing and challenge demonstrably work with staging – otherwise you lock yourself out of the domain for hours.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 6: The HTTP-to-HTTPS redirect
&lt;/h3&gt;

&lt;p&gt;You already enabled this globally in step 3 (the two &lt;code&gt;redirections&lt;/code&gt; lines). Test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; http://whoami.YOUR_DOMAIN | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s1"&gt;'HTTP/|location'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP/1.1 308 Permanent Redirect
Location: https://whoami.YOUR_DOMAIN/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So every unencrypted call is automatically redirected to HTTPS – you no longer have to think about it in any app.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 7: Secure the dashboard
&lt;/h3&gt;

&lt;p&gt;Traefik comes with a dashboard that shows which routers and services are active. Never put it &lt;strong&gt;unprotected&lt;/strong&gt; on the internet. We secure it with basic auth and hang it on a dedicated subdomain. First create a user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; apache2-utils
htpasswd &lt;span class="nt"&gt;-nbB&lt;/span&gt; admin YOUR_PASSWORD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output (&lt;code&gt;admin:$2y$05$...&lt;/code&gt;) goes into the labels. &lt;strong&gt;In the &lt;code&gt;compose.yaml&lt;/code&gt;, double every &lt;code&gt;$&lt;/code&gt;&lt;/strong&gt; (&lt;code&gt;$$&lt;/code&gt;), otherwise Compose interprets it as a variable. Add to the &lt;code&gt;traefik&lt;/code&gt; service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;    &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.enable=true"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.dashboard.rule=Host(`traefik.YOUR_DOMAIN`)"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.dashboard.entrypoints=websecure"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.dashboard.tls.certresolver=le"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.dashboard.service=api@internal"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.dashboard.middlewares=dashboard-auth"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.middlewares.dashboard-auth.basicauth.users=admin:$$2y$$05$$..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After &lt;code&gt;docker compose up -d&lt;/code&gt; you reach the dashboard at &lt;code&gt;https://traefik.YOUR_DOMAIN&lt;/code&gt; – after a password prompt.&lt;/p&gt;

&lt;p&gt;In the dashboard you see, under &lt;strong&gt;HTTP → Routers&lt;/strong&gt;, every detected router (with its &lt;code&gt;Host(...)&lt;/code&gt; rule), under &lt;strong&gt;Services&lt;/strong&gt; the containers behind them, and under &lt;strong&gt;Middlewares&lt;/strong&gt; your building blocks like &lt;code&gt;dashboard-auth&lt;/code&gt;. A router turns &lt;strong&gt;green&lt;/strong&gt; when rule, service and – for &lt;code&gt;websecure&lt;/code&gt; – the certificate are correct; &lt;strong&gt;red&lt;/strong&gt; means something is missing (usually network or host rule). That makes the dashboard your first look at "why isn't my app responding?".&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8801t2c6wpc049ljwqs0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8801t2c6wpc049ljwqs0.png" alt="The Traefik dashboard with the websecure entrypoint on port 443, two detected HTTP routers and five HTTP services – all green" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Under &lt;strong&gt;HTTP Routers&lt;/strong&gt; you see each router individually – with its &lt;code&gt;Host(...)&lt;/code&gt; rule, the entrypoint, the TLS status (padlock) and the provider &lt;code&gt;docker&lt;/code&gt;. This is how you check at a glance whether your labels were detected correctly:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8kghfcjq0n6j881qh49x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8kghfcjq0n6j881qh49x.png" alt="The router list in the Traefik dashboard: per app the host rule, the entrypoint (websecure), TLS and the Docker provider" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 8: Security headers as a reusable middleware
&lt;/h3&gt;

&lt;p&gt;A &lt;strong&gt;middleware&lt;/strong&gt; hooks in between router and service and modifies the request or response. A set of security headers belongs on every public app – defined once, attached everywhere. Define the middleware on any container (common: on Traefik itself) via labels:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.middlewares.sec-headers.headers.stsSeconds=31536000"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.middlewares.sec-headers.headers.stsIncludeSubdomains=true"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.middlewares.sec-headers.headers.frameDeny=true"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.middlewares.sec-headers.headers.contentTypeNosniff=true"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What the most important ones do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;stsSeconds&lt;/code&gt; (HSTS)&lt;/strong&gt; – the browser will address the domain only over HTTPS from now on. One year (&lt;code&gt;31536000&lt;/code&gt;) is the usual value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;frameDeny&lt;/code&gt;&lt;/strong&gt; – forbids embedding in foreign &lt;code&gt;&amp;lt;iframe&amp;gt;&lt;/code&gt;s (clickjacking protection).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;contentTypeNosniff&lt;/code&gt;&lt;/strong&gt; – the browser doesn't guess the content type but takes the one delivered – rules out a whole class of attacks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Attach it to an app via a label (adjust the router name):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.whoami.middlewares=sec-headers"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Multiple middlewares are given comma-separated (&lt;code&gt;sec-headers,dashboard-auth&lt;/code&gt;) and are run &lt;strong&gt;in that order&lt;/strong&gt;. This is how you gradually build a toolbox (auth, rate limiting, IP whitelist) that every app can reuse – that toolbox gets built out in&lt;br&gt;
&lt;a href="https://serverkueche.de/en/tutorials/traefik-middlewares-hardening/" rel="noopener noreferrer"&gt;Hardening Traefik&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If a header set should apply &lt;strong&gt;to all&lt;/strong&gt; apps, you don't attach the middleware to each router individually, but globally to the entrypoint – one line in Traefik's &lt;code&gt;command&lt;/code&gt; block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--entrypoints.websecure.http.middlewares=sec-headers@docker"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The suffix &lt;code&gt;@docker&lt;/code&gt; tells Traefik the middleware comes from the Docker provider (where you defined it via label).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 Tip&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Only arm HSTS with &lt;code&gt;stsSeconds&lt;/code&gt; once HTTPS runs &lt;strong&gt;reliably&lt;/strong&gt; and permanently. The browser remembers the setting stubbornly – a broken certificate would then be hard to work around for the full duration.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 9: The recipe for every further app
&lt;/h3&gt;

&lt;p&gt;From now on, every app is the same pattern – you never have to touch Traefik again. A new application gets its own folder with a &lt;code&gt;compose.yaml&lt;/code&gt;, is on the &lt;code&gt;proxy&lt;/code&gt; network, and carries exactly these labels (adjust router name and domain; for a port ≠ 80 add the &lt;code&gt;loadbalancer&lt;/code&gt; label as well):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;myapp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;YOUR_IMAGE:TAG&lt;/span&gt;
    &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.enable=true"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.myapp.rule=Host(`app.YOUR_DOMAIN`)"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.myapp.entrypoints=websecure"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.myapp.tls.certresolver=le"&lt;/span&gt;
      &lt;span class="c1"&gt;# only needed if the app does NOT listen on port 80:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.services.myapp.loadbalancer.server.port=YOUR_PORT"&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;proxy&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;proxy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;docker compose up -d&lt;/code&gt;, set the DNS record to the server IP, done – domain and HTTPS are created automatically. This is exactly how &lt;a href="https://serverkueche.de/en/tutorials/uptime-kuma-monitoring/" rel="noopener noreferrer"&gt;the first real app (Uptime Kuma)&lt;/a&gt; hangs behind the proxy.&lt;/p&gt;

&lt;h2&gt;
  
  
  When things go wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"Certificate invalid" in the browser, or the Traefik log shows ACME errors.&lt;/strong&gt; The three usual reasons: (1) The DNS record doesn't point to the server yet – check &lt;code&gt;dig +short YOUR_DOMAIN&lt;/code&gt;. (2) Port 80 isn't reachable from outside (firewall/netcup firewall) – the HTTP challenge needs it. (3) You hit the &lt;strong&gt;rate limit&lt;/strong&gt; of the production CA – switch to the staging server (tip in step 3), test, then go back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;404 page not found&lt;/code&gt; when calling the app domain.&lt;/strong&gt; Traefik doesn't know the route. Check: Does the container have &lt;code&gt;traefik.enable=true&lt;/code&gt;? Is it on the &lt;strong&gt;&lt;code&gt;proxy&lt;/code&gt; network&lt;/strong&gt;? Is the domain in the &lt;code&gt;Host(...)&lt;/code&gt; rule exactly right (incl. subdomain)? The dashboard (step 7) shows under "HTTP Routers" whether the router was registered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The browser shows Traefik's self-signed emergency certificate; the log reads &lt;code&gt;permissions 644 for /acme.json are too open, please use 600&lt;/code&gt;.&lt;/strong&gt; Traefik is running but skipped the ACME resolver – hence no real certificate. Run &lt;code&gt;chmod 600 acme.json&lt;/code&gt; (step&lt;br&gt;
2) and restart the container.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No app is routed; the Traefik log repeats &lt;code&gt;client version 1.24 is too old. Minimum supported API version is 1.40&lt;/code&gt;.&lt;/strong&gt; Your Traefik version is too old for your Docker engine – the Docker provider can no longer query the socket. Current Docker (Engine 29, API level ≥ 1.40) needs &lt;strong&gt;Traefik ≥ v3.6&lt;/strong&gt;; that's why this tutorial uses &lt;code&gt;traefik:v3.7&lt;/code&gt;. Reproduced against Docker 29: &lt;code&gt;v3.5.6&lt;/code&gt; runs into exactly this error, &lt;code&gt;v3.6.25&lt;/code&gt; talks to the socket cleanly again. So older tags like &lt;code&gt;v3.3&lt;/code&gt; or &lt;code&gt;v3.5&lt;/code&gt; no longer work with new Docker – bump the image tag and run &lt;code&gt;docker compose up -d&lt;/code&gt; again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Basic auth on the dashboard is rejected immediately / the router is missing.&lt;/strong&gt; In the &lt;code&gt;compose.yaml&lt;/code&gt;, the &lt;code&gt;$&lt;/code&gt; characters of the hash must be &lt;strong&gt;doubled&lt;/strong&gt; (&lt;code&gt;$$&lt;/code&gt;). Check the hash once more outside with &lt;code&gt;htpasswd -nbB&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Gateway Timeout&lt;/code&gt; or Traefik doesn't reach the container.&lt;/strong&gt; Usually the app is on the wrong network, or Traefik doesn't know which one is meant. &lt;code&gt;providers.docker.network=proxy&lt;/code&gt; in Traefik &lt;strong&gt;and&lt;/strong&gt; &lt;code&gt;networks: [proxy]&lt;/code&gt; on the app must match.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;502 Bad Gateway&lt;/code&gt;, even though the container is running.&lt;/strong&gt; Traefik reaches the container but hits the wrong port. If the app doesn't listen on 80, it needs the label &lt;code&gt;traefik.http.services.&amp;lt;name&amp;gt;.loadbalancer.server.port=&amp;lt;real-port&amp;gt;&lt;/code&gt;. This exact case meets you with the first app in the next tutorial (Uptime Kuma on 3001).&lt;/p&gt;

&lt;h2&gt;
  
  
  Maintenance &amp;amp; backups
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You must back up &lt;code&gt;acme.json&lt;/code&gt; and all &lt;code&gt;compose.yaml&lt;/code&gt; files.&lt;/strong&gt; With those, Traefik is restored within minutes after a crash – the certificates don't need to be reissued (which also spares the rate limit). We build an encrypted off-site backup of these files in the &lt;a href="https://serverkueche.de/en/tutorials/restic-backups/" rel="noopener noreferrer"&gt;Restic tutorial&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Certificates renew automatically.&lt;/strong&gt; Let's Encrypt certificates expire after 90 days; Traefik renews them in good time on its own – no cron job needed. You can check the expiry date any time by appending &lt;code&gt;-dates&lt;/code&gt; instead of &lt;code&gt;-issuer&lt;/code&gt; to the &lt;code&gt;openssl&lt;/code&gt; command from step 4 (shows &lt;code&gt;notBefore&lt;/code&gt;/&lt;code&gt;notAfter&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Maintain the Traefik version.&lt;/strong&gt; The fixed tag (&lt;code&gt;traefik:v3.7&lt;/code&gt;) means you apply updates deliberately. Before jumping to a new minor/major version, read the release notes – Traefik changed the label syntax between v2 and v3, for example.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep an eye on the dashboard.&lt;/strong&gt; A quick login shows whether all routers are "green" – the fastest check of whether everything holds after a deploy.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This post first appeared on &lt;a href="https://serverkueche.de/en/tutorials/traefik-reverse-proxy/" rel="noopener noreferrer"&gt;serverkueche.de&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>selfhosted</category>
    </item>
  </channel>
</rss>
