<?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>Hardening Traefik: Security Headers, Rate Limit, BasicAuth &amp; IP Allowlist</title>
      <dc:creator>serverkueche.de</dc:creator>
      <pubDate>Tue, 20 Oct 2026 07:51:14 +0000</pubDate>
      <link>https://dev.to/serverkueche/hardening-traefik-security-headers-rate-limit-basicauth-ip-allowlist-58im</link>
      <guid>https://dev.to/serverkueche/hardening-traefik-security-headers-rate-limit-basicauth-ip-allowlist-58im</guid>
      <description>&lt;p&gt;Your Traefik routes requests and fetches certificates – but it does not protect anything yet. Four middlewares change that: security headers, a rate limit, BasicAuth and an IP allowlist. We build them one at a time and &lt;strong&gt;measure with &lt;code&gt;curl&lt;/code&gt; after every step&lt;/strong&gt; what actually changed.&lt;/p&gt;

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

&lt;p&gt;At the end you have four reusable middlewares and a chain you can attach to any service:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Middleware&lt;/th&gt;
&lt;th&gt;Effect&lt;/th&gt;
&lt;th&gt;Measured&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sec-headers&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HSTS, clickjacking and MIME protection&lt;/td&gt;
&lt;td&gt;5 new response headers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;limits requests per IP&lt;/td&gt;
&lt;td&gt;40 parallel requests → 13× &lt;code&gt;200&lt;/code&gt;, 27× &lt;code&gt;429&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;auth&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;BasicAuth in front of the service&lt;/td&gt;
&lt;td&gt;without password &lt;code&gt;401&lt;/code&gt;, with it &lt;code&gt;200&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;only-me&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;only your IP gets in&lt;/td&gt;
&lt;td&gt;foreign IP &lt;code&gt;403&lt;/code&gt;, your own &lt;code&gt;200&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Tested with &lt;strong&gt;Traefik v3.7.8&lt;/strong&gt; on Debian 13. Important if you are migrating: in Traefik v3 the allowlist is called &lt;code&gt;ipAllowList&lt;/code&gt; – the v2 name &lt;code&gt;ipWhiteList&lt;/code&gt; no longer exists, and configurations using it break on upgrade.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;running Traefik v3&lt;/strong&gt; with a &lt;code&gt;proxy&lt;/code&gt; network and working TLS, as built in &lt;a href="https://serverkueche.de/en/tutorials/traefik-reverse-proxy/" rel="noopener noreferrer"&gt;Traefik as a reverse proxy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;A service behind it to practise on. We use &lt;code&gt;traefik/whoami&lt;/code&gt; – a tiny container that echoes every request including its headers, which makes it perfect for verification&lt;/li&gt;
&lt;li&gt;A basic grasp of &lt;a href="https://serverkueche.de/en/tutorials/docker-compose-basics/" rel="noopener noreferrer"&gt;Docker Compose&lt;/a&gt; and labels&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  1. Measure the starting point
&lt;/h3&gt;

&lt;p&gt;Before hardening anything, record what it looked like before. Create a test service – &lt;code&gt;~/whoami/compose.yaml&lt;/code&gt;, replacing &lt;code&gt;YOUR_DOMAIN&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;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;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="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;proxy&lt;/span&gt;&lt;span class="pi"&gt;]&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="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.services.whoami.loadbalancer.server.port=80"&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;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;span class="o"&gt;&amp;amp;&amp;amp;&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;Now look at the response headers:&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; https://whoami.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;HTTP/2 200
content-type: text/plain; charset=utf-8
date: Sat, 25 Jul 2026 07:01:27 GMT
content-length: 351
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole truth: &lt;strong&gt;Traefik sets no security header at all on its own.&lt;/strong&gt; TLS gives you an encrypted transport – it does nothing against clickjacking, MIME sniffing or accidental HTTP.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Set security headers
&lt;/h3&gt;

&lt;p&gt;With Docker labels, middlewares are &lt;strong&gt;defined&lt;/strong&gt; on any container and &lt;strong&gt;activated&lt;/strong&gt; on a router. Add these labels to the test 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="c1"&gt;# activate&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.middlewares=sec-headers"&lt;/span&gt;
      &lt;span class="c1"&gt;# define&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.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.stsPreload=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;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.referrerPolicy=strict-origin-when-cross-origin"&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.permissionsPolicy=camera=(),&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;microphone=(),&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;geolocation=()"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sleep &lt;/span&gt;5 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://whoami.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;HTTP/2 200
content-type: text/plain; charset=utf-8
permissions-policy: camera=(), microphone=(), geolocation=()
referrer-policy: strict-origin-when-cross-origin
strict-transport-security: max-age=31536000; includeSubDomains; preload
x-content-type-options: nosniff
x-frame-options: DENY
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five headers more, and each has a purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;strict-transport-security&lt;/code&gt;&lt;/strong&gt; (HSTS) tells the browser: talk to this domain over HTTPS only, for &lt;strong&gt;a year&lt;/strong&gt;. That closes the gap between typing &lt;code&gt;example.com&lt;/code&gt; and the redirect to HTTPS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;x-frame-options: DENY&lt;/code&gt;&lt;/strong&gt; forbids embedding in foreign &lt;code&gt;&amp;lt;iframe&amp;gt;&lt;/code&gt;s – the classic clickjacking protection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;x-content-type-options: nosniff&lt;/code&gt;&lt;/strong&gt; stops the browser from „guessing" the content type and, say, executing an uploaded text file as a script.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;referrer-policy&lt;/code&gt;&lt;/strong&gt; prevents your full internal URL from being sent along when someone clicks an external link.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;permissions-policy&lt;/code&gt;&lt;/strong&gt; disables camera, microphone and location for this site.&lt;/li&gt;
&lt;/ul&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;stsPreload=true&lt;/code&gt; is the invitation to have your domain added to the browsers' &lt;strong&gt;HSTS preload list&lt;/strong&gt; (you apply for the listing separately). Once it is there, browsers enforce HTTPS for the domain &lt;strong&gt;and all its subdomains&lt;/strong&gt; – even if your server has long stopped serving a certificate. Getting out takes months. Only set &lt;code&gt;stsIncludeSubdomains&lt;/code&gt; and &lt;code&gt;stsPreload&lt;/code&gt; if you are sure that &lt;strong&gt;every&lt;/strong&gt; subdomain will permanently speak HTTPS. To start with, &lt;code&gt;stsSeconds&lt;/code&gt; alone is enough.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Deliberately &lt;strong&gt;not&lt;/strong&gt; in the list: &lt;code&gt;browserXssFilter&lt;/code&gt;. The &lt;code&gt;X-XSS-Protection&lt;/code&gt; header is considered obsolete and modern browsers ignore it. A &lt;code&gt;contentSecurityPolicy&lt;/code&gt; is missing too – it is effective, but differs per application and reliably breaks interfaces when applied blindly.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Rate limiting against brute force and bots
&lt;/h3&gt;

&lt;p&gt;This middleware limits how many requests &lt;strong&gt;a single IP&lt;/strong&gt; may make:&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.rate.ratelimit.average=5"&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.rate.ratelimit.burst=10"&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.rate.ratelimit.period=1s"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;average=5&lt;/code&gt; with &lt;code&gt;period=1s&lt;/code&gt; means five requests per second on average. &lt;code&gt;burst=10&lt;/code&gt; allows short spikes above that – web pages do load several files at once.&lt;/p&gt;

&lt;p&gt;Activate it and verify with real load. Important: test &lt;strong&gt;in parallel&lt;/strong&gt;, otherwise you are too slow to hit the limit at all:&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;seq &lt;/span&gt;40 | xargs &lt;span class="nt"&gt;-P&lt;/span&gt; 8 &lt;span class="nt"&gt;-I&lt;/span&gt;&lt;span class="o"&gt;{}&lt;/span&gt; curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://whoami.YOUR_DOMAIN/ &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&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;     13 200
     27 429
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is exactly how it should look: the first requests go through, after that Traefik answers with &lt;code&gt;429 Too Many Requests&lt;/code&gt; without troubling the service behind it at all.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 Sensible values&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For a normal web interface, 5–20 requests per second with a burst of 20–50 is realistic. Set it too tight and you block real users loading a single page. After enabling it, watch the Traefik logs for &lt;code&gt;429&lt;/code&gt; – if they show up in everyday use, the limit is too strict.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  4. BasicAuth for services without their own login
&lt;/h3&gt;

&lt;p&gt;Some tools have no login at all, or a weak one – a Traefik dashboard, a metrics endpoint, an admin interface. BasicAuth puts a hurdle in front of them before the request ever reaches the application.&lt;/p&gt;

&lt;p&gt;First create a password hash. No extra package needed, Docker will do:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; httpd:2.4-alpine htpasswd &lt;span class="nt"&gt;-nbB&lt;/span&gt; koch &lt;span class="s2"&gt;"YOUR_PASSWORD"&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;koch:$2y$05$IcOtS/jq6VR4haHO2Pvtl...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-B&lt;/code&gt; forces bcrypt (not the old MD5), &lt;code&gt;-nb&lt;/code&gt; writes the result to standard output.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠️ Every $ has to be doubled in Compose&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;bcrypt hashes contain &lt;code&gt;$&lt;/code&gt; characters, and Docker Compose treats &lt;code&gt;$&lt;/code&gt; as a variable. So enter the hash with &lt;strong&gt;doubled&lt;/strong&gt; dollar signs: &lt;code&gt;$2y$05$Ic…&lt;/code&gt; becomes &lt;code&gt;$$2y$$05$$Ic…&lt;/code&gt;. Forget that and parts of the hash vanish silently, making the login fail every time – &lt;code&gt;docker compose config&lt;/code&gt; shows you what Compose really produces.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&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.auth.basicauth.users=koch:$$2y$$05$$IcOtS/jq6VR4haHO2Pvtl..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify – three cases, three answers:&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;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://whoami.YOUR_DOMAIN/
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-u&lt;/span&gt; koch:wrong &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://whoami.YOUR_DOMAIN/
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-u&lt;/span&gt; koch:YOUR_PASSWORD &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://whoami.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;401
401
200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the &lt;code&gt;401&lt;/code&gt; the browser asks for credentials itself – Traefik sends &lt;code&gt;www-authenticate: Basic realm="traefik"&lt;/code&gt; along with it.&lt;/p&gt;

&lt;p&gt;BasicAuth is deliberately simple: no logout, no second factor, no user management. For „nobody but me should even be able to knock here" it is exactly right; for real user accounts across several services, single sign-on is the way to go.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. IP allowlist: only from home
&lt;/h3&gt;

&lt;p&gt;Even stricter: allow requests only from certain networks. In Traefik v3 the middleware is called &lt;code&gt;ipAllowList&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.middlewares.only-me.ipallowlist.sourcerange=YOUR_IP/32"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Find your public IP with &lt;code&gt;curl -s https://ifconfig.me&lt;/code&gt;. Separate several ranges with commas, e.g. &lt;code&gt;sourcerange=203.0.113.5/32,198.51.100.0/24&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The test shows both sides. With a foreign IP in the list Traefik answers:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;And with your own:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠️ Which IP does Traefik even see?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Traefik checks the IP of the &lt;strong&gt;direct&lt;/strong&gt; connection. If something sits in front of it – Cloudflare, a load balancer, another proxy – you see that component's IP, and an allowlist with your own address locks you out. What actually arrives is revealed by &lt;code&gt;traefik/whoami&lt;/code&gt; in the &lt;code&gt;X-Forwarded-For&lt;/code&gt; field. For such setups there is &lt;code&gt;ipallowlist.ipstrategy.depth&lt;/code&gt; and &lt;code&gt;excludedips&lt;/code&gt; – but first check how many proxies really sit in front.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;An IP allowlist is effective but inconvenient: on mobile data or in hotel Wi-Fi you no longer get in. The more elegant variant for „only me" is a &lt;a href="https://serverkueche.de/en/tutorials/wireguard-vpn-setup/" rel="noopener noreferrer"&gt;WireGuard VPN&lt;/a&gt; – then you allow the VPN network instead of changing public IPs.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Bundle middlewares into a chain
&lt;/h3&gt;

&lt;p&gt;Several middlewares on one router are listed comma-separated – &lt;strong&gt;the order is the execution order&lt;/strong&gt;. So you don't repeat this for every service, there is &lt;code&gt;chain&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.middlewares.hardened.chain.middlewares=sec-headers,rate,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.routers.whoami.middlewares=hardened"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify that everything applies at once:&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;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://whoami.YOUR_DOMAIN/
curl &lt;span class="nt"&gt;-sI&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; koch:YOUR_PASSWORD https://whoami.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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;401
strict-transport-security: max-age=31536000; includeSubDomains; preload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without credentials &lt;code&gt;401&lt;/code&gt;, with credentials you get the response &lt;strong&gt;and&lt;/strong&gt; the headers. As a rule of thumb for the order: &lt;strong&gt;cheap rejections first.&lt;/strong&gt; An IP allowlist drops the request without checking anything; the rate limit costs almost nothing; BasicAuth has to compute a bcrypt hash – and that is deliberately slow. So &lt;code&gt;only-me&lt;/code&gt;, then &lt;code&gt;rate&lt;/code&gt;, then &lt;code&gt;auth&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. The most important use case: the Traefik dashboard
&lt;/h3&gt;

&lt;p&gt;Traefik ships a dashboard showing all routers, services and middlewares – genuinely useful for debugging. It is &lt;strong&gt;off&lt;/strong&gt; by default, and for good reason: it reveals the complete structure of your server. This is exactly where BasicAuth pays off.&lt;/p&gt;

&lt;p&gt;Enable the API in the Traefik stack:&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;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;--api.dashboard=true"&lt;/span&gt;
      &lt;span class="c1"&gt;# … your existing parameters …&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And give Traefik labels for itself. The trick is the internal service &lt;code&gt;api@internal&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;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$$IcOtS/jq6VR4haHO2Pvtl..."&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;, check &lt;strong&gt;both&lt;/strong&gt; entry points – the dashboard and the API underneath it:&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;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://traefik.YOUR_DOMAIN/dashboard/
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-u&lt;/span&gt; admin:YOUR_PASSWORD &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://traefik.YOUR_DOMAIN/dashboard/
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://traefik.YOUR_DOMAIN/api/overview
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; admin:YOUR_PASSWORD https://traefik.YOUR_DOMAIN/api/overview
&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;401
200
401
{"http":{"routers":{"total":3,"warnings":0,"errors":0},"services":{"total":5, …
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third line is the important one: the &lt;strong&gt;API is protected just like the interface&lt;/strong&gt;. Had you attached the middleware only to a &lt;code&gt;/dashboard&lt;/code&gt; path, &lt;code&gt;/api/…&lt;/code&gt; would still be open – and with it your entire configuration readable. Because the router here matches the whole host, the login covers both.&lt;/p&gt;

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

&lt;p&gt;Even better than BasicAuth alone is the combination for the dashboard: &lt;code&gt;only-me&lt;/code&gt; (IP allowlist) &lt;strong&gt;and&lt;/strong&gt; &lt;code&gt;dashboard-auth&lt;/code&gt; as a chain. Then an attacker has to be on the right network before they may even guess a password. For a dashboard you rarely need, that is the right level of hardening.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  8. Define middlewares centrally (the scalable way)
&lt;/h3&gt;

&lt;p&gt;So far we defined everything through labels. With five services you copy the same lines five times – and changing the HSTS duration means editing five files. Better: define them &lt;strong&gt;once, centrally, in the file provider&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Add two startup parameters and a mount to the Traefik stack:&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;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# … your existing parameters …&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.file.directory=/dynamic"&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.file.watch=true"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# … your existing mounts …&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./dynamic:/dynamic:ro&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the middlewares as YAML in &lt;code&gt;~/traefik/dynamic/middlewares.yml&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;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;middlewares&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;sec-headers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;stsSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;31536000&lt;/span&gt;
        &lt;span class="na"&gt;stsIncludeSubdomains&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;stsPreload&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;frameDeny&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;contentTypeNosniff&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;referrerPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;strict-origin-when-cross-origin&lt;/span&gt;
    &lt;span class="na"&gt;rate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;rateLimit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;average&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;burst&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
        &lt;span class="na"&gt;period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restart Traefik – afterwards you attach them to any service with the suffix &lt;strong&gt;&lt;code&gt;@file&lt;/code&gt;&lt;/strong&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.whoami.middlewares=sec-headers@file,rate@file"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verified: headers and rate limit behave exactly as they did via labels (12× &lt;code&gt;200&lt;/code&gt;, 18× &lt;code&gt;429&lt;/code&gt; for 30 parallel requests in the test). The gain: &lt;code&gt;--providers.file.watch=true&lt;/code&gt; picks up changes to the file &lt;strong&gt;without a restart&lt;/strong&gt;, and the HSTS duration now lives in exactly one place.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;ℹ️ Where does a middleware come from?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Traefik appends the origin to every name: middlewares defined through labels are internally &lt;code&gt;sec-headers@docker&lt;/code&gt;, those loaded from files are &lt;code&gt;sec-headers@file&lt;/code&gt;. Within the same provider you may omit the suffix – &lt;strong&gt;across provider boundaries you may not.&lt;/strong&gt; That is exactly what causes most „middleware not found" errors.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  9. Which middleware for which service?
&lt;/h3&gt;

&lt;p&gt;Not every service needs everything. The right chain depends on who should use it and what it already brings along:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type of service&lt;/th&gt;
&lt;th&gt;Examples&lt;/th&gt;
&lt;th&gt;sensible chain&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Public, for everyone&lt;/td&gt;
&lt;td&gt;website, blog, status page&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sec-headers&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Public with a login&lt;/td&gt;
&lt;td&gt;Nextcloud, Immich, Vaultwarden&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sec-headers&lt;/code&gt;, &lt;code&gt;rate&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Only for you, with a login&lt;/td&gt;
&lt;td&gt;Uptime Kuma, Grafana&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sec-headers&lt;/code&gt;, &lt;code&gt;rate&lt;/code&gt; (+ allowlist if possible)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Without its own login&lt;/td&gt;
&lt;td&gt;Traefik dashboard, metrics endpoints&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;only-me&lt;/code&gt;, &lt;code&gt;rate&lt;/code&gt;, &lt;code&gt;auth&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dangerous if abused&lt;/td&gt;
&lt;td&gt;Dockge, Portainer, Adminer&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;only-me&lt;/code&gt;, &lt;code&gt;auth&lt;/code&gt; – or not public at all&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two rules behind that: &lt;strong&gt;no rate limit in front of services that need many parallel requests&lt;/strong&gt; – Immich during a phone backup or a media server while streaming will otherwise run into &lt;code&gt;429&lt;/code&gt;. And: &lt;strong&gt;never put BasicAuth in front of an app that has its own login&lt;/strong&gt; if it has a mobile app – most apps cannot cope with two stacked authentications.&lt;/p&gt;

&lt;p&gt;For everything in the last row, the honest answer is usually not „one more middleware" but: don't put it on the open internet, reach it through &lt;a href="https://serverkueche.de/en/tutorials/wireguard-vpn-setup/" rel="noopener noreferrer"&gt;WireGuard&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;The logs say „middleware … does not exist" and the service answers with 404.&lt;/strong&gt; Almost always the provider suffix is missing: a middleware defined in the file provider is called &lt;code&gt;name@file&lt;/code&gt; from the perspective of Docker labels. Without the suffix Traefik looks for it among the Docker labels – and finds nothing. The same applies the other way round for &lt;code&gt;name@docker&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BasicAuth rejects the correct password.&lt;/strong&gt; The &lt;code&gt;$&lt;/code&gt; characters in the bcrypt hash were interpreted as variables by Docker Compose. In Compose they have to be &lt;strong&gt;doubled&lt;/strong&gt; (&lt;code&gt;$$2y$$05$$…&lt;/code&gt;). &lt;code&gt;docker compose config&lt;/code&gt; shows the value that really arrives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rate limit doesn't seem to work.&lt;/strong&gt; Sequential &lt;code&gt;curl&lt;/code&gt; calls are too slow: every process opens a new connection, so you stay below the limit. Test in parallel, e.g. with &lt;code&gt;seq 40 | xargs -P 8 -I{} curl …&lt;/code&gt; – then the &lt;code&gt;429&lt;/code&gt;s appear.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After setting the IP allowlist you get 403 yourself.&lt;/strong&gt; Traefik does not see your IP but that of the proxy in front of it (Cloudflare, load balancer). Check with &lt;code&gt;traefik/whoami&lt;/code&gt; what &lt;code&gt;X-Forwarded-For&lt;/code&gt; contains, and use &lt;code&gt;ipallowlist.ipstrategy.depth&lt;/code&gt; for multi-stage setups.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A v2 configuration with &lt;code&gt;ipWhiteList&lt;/code&gt; stops working after the upgrade.&lt;/strong&gt; In Traefik v3 the middleware is called &lt;code&gt;ipAllowList&lt;/code&gt;; the old name was removed, so the rule no longer applies. Rename every occurrence when migrating.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The browser still refuses plain HTTP even though you fixed the configuration.&lt;/strong&gt; That is HSTS, not a bug: the browser remembers &lt;code&gt;max-age&lt;/code&gt; and enforces HTTPS even when your server no longer offers it. A private window or a different browser helps for testing – and that is why the warning about &lt;code&gt;preload&lt;/code&gt; exists above.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Re-check after every Traefik update.&lt;/strong&gt; Middlewares are configuration, and configuration ages: between v2 and v3 &lt;code&gt;ipWhiteList&lt;/code&gt; became &lt;code&gt;ipAllowList&lt;/code&gt;, other fields got new names. After an update a quick pass with &lt;code&gt;curl -sI&lt;/code&gt; is worth it – do the expected headers still appear? Does the allowlist still answer &lt;code&gt;403&lt;/code&gt;? Two minutes that save you from silent ineffectiveness.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Back up the &lt;code&gt;dynamic&lt;/code&gt; directory.&lt;/strong&gt; If you follow the file provider, it holds your entire hardening. It belongs in the backup together with &lt;code&gt;compose.yaml&lt;/code&gt; and &lt;code&gt;acme.json&lt;/code&gt; – small, but painful to reconstruct. How to automate that is covered in &lt;a href="https://serverkueche.de/en/tutorials/restic-backups/" rel="noopener noreferrer"&gt;Restic backups&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Allowlists age.&lt;/strong&gt; Dynamic IP addresses of home connections change, office networks move. Take a look at your &lt;code&gt;sourcerange&lt;/code&gt; entries once a quarter and remove anything you can no longer place – an allowlist containing strangers' addresses is worse than none.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honest about the reach of these measures.&lt;/strong&gt; Security headers protect your users' browsers, not your server. A rate limit slows brute force down, it does not prevent it. BasicAuth is a door with a lock, not a user concept. What these four middlewares do &lt;strong&gt;not&lt;/strong&gt; replace: a &lt;a href="https://serverkueche.de/en/tutorials/harden-ssh/" rel="noopener noreferrer"&gt;hardened SSH access&lt;/a&gt;, a &lt;a href="https://serverkueche.de/en/tutorials/firewall-ufw-setup/" rel="noopener noreferrer"&gt;firewall&lt;/a&gt;, &lt;a href="https://serverkueche.de/en/tutorials/unattended-upgrades-automatic-updates/" rel="noopener noreferrer"&gt;automatic updates&lt;/a&gt; and &lt;a href="https://serverkueche.de/en/tutorials/fail2ban-setup/" rel="noopener noreferrer"&gt;fail2ban&lt;/a&gt; or &lt;a href="https://serverkueche.de/en/tutorials/crowdsec-setup/" rel="noopener noreferrer"&gt;CrowdSec&lt;/a&gt; for the layer below. The middlewares are the layer that sits in front of your applications – not the only one.&lt;/p&gt;




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

</description>
      <category>security</category>
      <category>docker</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Dockge: Manage Compose Stacks in the Browser</title>
      <dc:creator>serverkueche.de</dc:creator>
      <pubDate>Tue, 20 Oct 2026 07:51:07 +0000</pubDate>
      <link>https://dev.to/serverkueche/dockge-manage-compose-stacks-in-the-browser-5f0p</link>
      <guid>https://dev.to/serverkueche/dockge-manage-compose-stacks-in-the-browser-5f0p</guid>
      <description>&lt;p&gt;Anyone running several Compose stacks knows the routine: SSH in, change into the right folder, &lt;code&gt;docker compose pull &amp;amp;&amp;amp; up -d&lt;/code&gt;, check the logs, next stack. Dockge brings exactly those steps into the browser – while writing perfectly ordinary &lt;code&gt;compose.yaml&lt;/code&gt; files that you can still touch from the CLI.&lt;/p&gt;

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

&lt;p&gt;At the end, &lt;strong&gt;Dockge 1.5.0&lt;/strong&gt; runs behind your Traefik at &lt;code&gt;https://YOUR_DOMAIN&lt;/code&gt;. You create stacks in the browser, edit their &lt;code&gt;compose.yaml&lt;/code&gt;, deploy them with one click and watch the output of &lt;code&gt;docker compose up&lt;/code&gt; &lt;strong&gt;live&lt;/strong&gt; – including container logs and a Bash console per container.&lt;/p&gt;

&lt;p&gt;The decisive difference from heavyweight Docker interfaces: Dockge does not invent a format of its own. Every stack is a folder under &lt;code&gt;/opt/stacks/&amp;lt;name&amp;gt;/&lt;/code&gt; with a &lt;code&gt;compose.yaml&lt;/code&gt; and an &lt;code&gt;.env&lt;/code&gt;. &lt;code&gt;docker compose ls&lt;/code&gt; lists them as normal projects. You can switch Dockge off tomorrow and carry on as if it had never existed – &lt;strong&gt;no lock-in&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;On resources: the container used around &lt;strong&gt;143 MiB RAM&lt;/strong&gt; in the test with virtually no CPU load. The image, however, is no lightweight at &lt;strong&gt;1.17 GB&lt;/strong&gt; – worth knowing if your VPS has a small system disk.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;🛑 Dockge is root on your server&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Dockge needs the Docker socket (&lt;code&gt;/var/run/docker.sock&lt;/code&gt;). Anyone with access to this interface can start arbitrary containers – privileged ones included – and thereby &lt;strong&gt;take over the whole host&lt;/strong&gt;. So this applies more strictly here than for any other app: reachable over HTTPS only, a long unique password, and if possible restricted to your own IP as well (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;). A Dockge on the open internet with a weak password is a compromised server.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;ul&gt;
&lt;li&gt;A server running Debian 13 with Docker (tested on a netcup VPS with Docker 29.6.1 and Compose v5.3.1)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://serverkueche.de/en/tutorials/docker-compose-basics/" rel="noopener noreferrer"&gt;Docker Compose basics&lt;/a&gt; – Dockge does not save you the understanding, only the typing&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;running Traefik&lt;/strong&gt; with its &lt;code&gt;proxy&lt;/code&gt; network as in &lt;a href="https://serverkueche.de/en/tutorials/traefik-reverse-proxy/" rel="noopener noreferrer"&gt;Traefik as a reverse proxy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;A subdomain pointing at your server (&lt;a href="https://serverkueche.de/en/tutorials/connect-domain-to-server/" rel="noopener noreferrer"&gt;connecting a domain&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  1. Decide on the stacks directory
&lt;/h3&gt;

&lt;p&gt;Dockge manages &lt;strong&gt;all stacks below one folder&lt;/strong&gt;. The default is &lt;code&gt;/opt/stacks&lt;/code&gt;, and we stick with it – it matches the convention used in the other recipes:&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; /opt/stacks /opt/dockge/data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠️ Host and container path must be identical&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The path has to be the same inside &lt;strong&gt;and&lt;/strong&gt; outside the container, i.e. &lt;code&gt;/opt/stacks:/opt/stacks&lt;/code&gt;. Dockge calls &lt;code&gt;docker compose&lt;/code&gt; through the host's socket – so the daemon interprets paths from the &lt;strong&gt;host's&lt;/strong&gt; point of view. If you mount &lt;code&gt;/opt/stacks&lt;/code&gt; to &lt;code&gt;/app/stacks&lt;/code&gt;, Dockge finds the files in its own view but the daemon does not: deployments fail with „no such file or directory".&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  2. The Compose file for Dockge
&lt;/h3&gt;

&lt;p&gt;Create &lt;code&gt;/opt/dockge/compose.yaml&lt;/code&gt; and replace &lt;code&gt;YOUR_DOMAIN&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;dockge&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;louislam/dockge:1.5.0&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;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DOCKGE_STACKS_DIR&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/opt/stacks&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&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./data:/app/data&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/opt/stacks:/opt/stacks&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="nv"&gt;proxy&lt;/span&gt;&lt;span class="pi"&gt;]&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.dockge.rule=Host(`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.dockge.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.dockge.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.dockge.loadbalancer.server.port=5001"&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;How the pieces fit together:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;DOCKGE_STACKS_DIR&lt;/code&gt;&lt;/strong&gt; must match the mount target exactly (step 1).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;./data&lt;/code&gt;&lt;/strong&gt; holds Dockge's own SQLite database: users, settings, JWT secret.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;loadbalancer.server.port=5001&lt;/code&gt;&lt;/strong&gt; – inside the container Dockge listens on 5001. We publish &lt;strong&gt;no&lt;/strong&gt; port to the outside; only Traefik reaches it through the &lt;code&gt;proxy&lt;/code&gt; network.&lt;/li&gt;
&lt;li&gt;No &lt;code&gt;user:&lt;/code&gt; entry: Dockge has to talk to the Docker daemon through the socket and write the stack files.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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;&lt;span class="nb"&gt;cd&lt;/span&gt; /opt/dockge &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;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;p&gt;The container ships a healthcheck, so you should see &lt;code&gt;(healthy)&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME              STATUS
dockge-dockge-1   Up 12 seconds (healthy)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A look at the logs confirms 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;docker logs &lt;span class="nt"&gt;--tail&lt;/span&gt; 5 dockge-dockge-1
&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;[SERVER] INFO: Connected to the database
[SERVER] INFO: JWT secret is not found, generate one.
[SERVER] INFO: Stored JWT secret into database
[SERVER] INFO: No user, need setup
[SERVER] INFO: Listening on 5001
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;No user, need setup&lt;/code&gt; means the server is waiting for your admin account.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Create the admin account
&lt;/h3&gt;

&lt;p&gt;Open &lt;code&gt;https://YOUR_DOMAIN&lt;/code&gt;. Dockge redirects to &lt;code&gt;/setup&lt;/code&gt; and asks for a username and password. The interface follows your browser's language.&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%2Finry1hb2blyknjp40a6a.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%2Finry1hb2blyknjp40a6a.png" alt="The Dockge setup page for creating the admin account, with a language selector and fields for username and password" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After that the door is shut: there is no open registration, further accounts are created in the settings. Use a long, unique password from your &lt;a href="https://serverkueche.de/en/tutorials/vaultwarden-password-manager/" rel="noopener noreferrer"&gt;password manager&lt;/a&gt; – see the warning above.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Understanding the home screen
&lt;/h3&gt;

&lt;p&gt;After logging in you see every Compose project Docker knows about on this host – in the example the existing &lt;code&gt;traefik&lt;/code&gt; stack plus the one just created:&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%2Fn8ya8splfbdblrhzcnpu.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%2Fn8ya8splfbdblrhzcnpu.png" alt="The Dockge home screen with the list of active stacks, counters for active, exited and inactive, and the Dockge agents section" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On the left, Dockge counts &lt;code&gt;active&lt;/code&gt;, &lt;code&gt;exited&lt;/code&gt; and &lt;code&gt;inactive&lt;/code&gt;. Important: the list shows &lt;strong&gt;all&lt;/strong&gt; projects, but Dockge can only manage those below &lt;code&gt;/opt/stacks&lt;/code&gt;. How to adopt existing stacks is step 7.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Create and deploy the first stack
&lt;/h3&gt;

&lt;p&gt;Click &lt;strong&gt;Compose&lt;/strong&gt; at the top. You land in the editor: on the left a form for the stack name and containers, on the right the &lt;code&gt;compose.yaml&lt;/code&gt; – both sides are linked, so you can click &lt;strong&gt;or&lt;/strong&gt; write YAML.&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%2Ff253m0p1vnz95y04gflr.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%2Ff253m0p1vnz95y04gflr.png" alt="The Dockge compose editor with a field for the stack name, the container form and the YAML editor beside it" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Give the stack a name (lowercase only – it becomes the folder and project name) and adjust the YAML. A click on &lt;strong&gt;Deploy&lt;/strong&gt; runs &lt;code&gt;docker compose up -d&lt;/code&gt;, and the real output scrolls by in the terminal pane:&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%2Fl21plqm91tux5zgi3v9a.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%2Fl21plqm91tux5zgi3v9a.png" alt="A freshly deployed stack in Dockge with a running container, live logs of the nginx container and the compose.yaml below" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The container entry shows status and published ports, with the logs running below. Each container also gets a &lt;strong&gt;Bash&lt;/strong&gt; button that opens a console inside it – handy for quickly checking a configuration.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. What happens on disk
&lt;/h3&gt;

&lt;p&gt;This is the part that sets Dockge apart from heavyweight interfaces. After deploying, the server holds exactly what you would expect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;find /opt/stacks &lt;span class="nt"&gt;-type&lt;/span&gt; f
&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;/opt/stacks/whoami-test/.env
/opt/stacks/whoami-test/compose.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And Docker knows the stack as a perfectly ordinary Compose project:&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 &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;NAME                STATUS              CONFIG FILES
dockge              running(1)          /opt/dockge/compose.yaml
traefik             running(1)          /opt/traefik/compose.yaml
whoami-test         running(1)          /opt/stacks/whoami-test/compose.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So you can SSH into the folder at any time and carry on as usual:&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;cd&lt;/span&gt; /opt/stacks/whoami-test &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&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                   STATUS         PORTS
whoami-test-nginx-1    Up 35 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;Both hold at the same time: changes in the browser are visible to the CLI, changes from the CLI show up in the browser after a reload. Dockge is a remote control, not a new system.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Adopt existing stacks
&lt;/h3&gt;

&lt;p&gt;A stack outside &lt;code&gt;/opt/stacks&lt;/code&gt; appears in the list but cannot be touched – Dockge says so plainly: „This stack is not managed by Dockge." There is neither an edit nor a deploy button.&lt;/p&gt;

&lt;p&gt;To adopt it, move the folder. Assuming your stack lives in &lt;code&gt;/opt/myapp&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;cd&lt;/span&gt; /opt/myapp &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker compose down
&lt;span class="nb"&gt;mv&lt;/span&gt; /opt/myapp /opt/stacks/myapp
&lt;span class="nb"&gt;cd&lt;/span&gt; /opt/stacks/myapp &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;The project name is derived from the folder name – keep it the same and your containers keep their names and &lt;strong&gt;named volumes survive&lt;/strong&gt;. Dockge then manages the stack fully:&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%2Fgfzevmv8y7w82dopmrdt.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%2Fgfzevmv8y7w82dopmrdt.png" alt="An adopted stack in Dockge with the edit, restart, update, stop and delete buttons plus the container's live logs" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;Don't do everything at once: move one non-critical stack, verify, then the next. I would touch your &lt;strong&gt;Traefik&lt;/strong&gt; last – if that goes wrong, Dockge is unreachable too.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  8. Convert &lt;code&gt;docker run&lt;/code&gt; commands
&lt;/h3&gt;

&lt;p&gt;Many projects only document a long &lt;code&gt;docker run&lt;/code&gt; command. The home screen has a field for exactly that: paste the command, click &lt;strong&gt;Convert to Compose&lt;/strong&gt; – and you land in the editor with finished YAML. 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 run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="nb"&gt;uptime&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; 3001:3001 &lt;span class="nt"&gt;-v&lt;/span&gt; uptime-data:/app/data &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--restart&lt;/span&gt; unless-stopped louislam/uptime-kuma:2.5.3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;becomes:&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;uptime-kuma&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;uptime&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="s"&gt;3001:3001&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;uptime-data:/app/data&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;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;louislam/uptime-kuma:2.5.3&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;uptime-data&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;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;uptime-data&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Very handy – with one trap: the named volume is written as &lt;strong&gt;&lt;code&gt;external: true&lt;/code&gt;&lt;/strong&gt;, i.e. „already exists". On a fresh installation the volume does not exist yet, and the deployment fails. Either delete those two lines (then Compose creates the volume itself) or create it beforehand with &lt;code&gt;docker volume create uptime-data&lt;/code&gt;. For the difference, see &lt;a href="https://serverkueche.de/en/tutorials/docker-volumes-vs-bind-mounts/" rel="noopener noreferrer"&gt;volumes vs. bind mounts&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  9. Console and agents – decide deliberately
&lt;/h3&gt;

&lt;p&gt;Two features you should know about before switching them on:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Console.&lt;/strong&gt; The console menu entry is &lt;strong&gt;disabled&lt;/strong&gt; by default, and that is a good thing. Dockge states why itself: it allows arbitrary commands &lt;em&gt;inside&lt;/em&gt; the Dockge container – and that container has the Docker socket. Enabled, it means a root shell on your host in the browser. Leave it off; for the rare case, SSH is enough.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agents (beta).&lt;/strong&gt; Dockge can attach further servers and manage their stacks in the same interface. That is convenient if you run several machines – but the feature is marked &lt;strong&gt;beta&lt;/strong&gt;, and every attached agent increases the damage if this one interface is ever compromised. To start with: one server, one Dockge.&lt;/p&gt;

&lt;h3&gt;
  
  
  10. Updates with one click
&lt;/h3&gt;

&lt;p&gt;The &lt;strong&gt;Update&lt;/strong&gt; button on a stack does exactly what you would otherwise type: pull new images and bring the stack back up. If the image is already current, nothing happens – in the test the container kept running unchanged afterwards (same start time), so it is not restarted without reason. That is convenient – and precisely why you should keep &lt;strong&gt;pinning&lt;/strong&gt; your images instead of using &lt;code&gt;latest&lt;/code&gt;. Otherwise one click turns into a jump across several major versions without you noticing. Why that matters is covered in &lt;a href="https://serverkueche.de/en/tutorials/keep-docker-stack-updated/" rel="noopener noreferrer"&gt;keeping your Docker stack up to date&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Traefik answers with 404 although the container is &lt;code&gt;healthy&lt;/code&gt;.&lt;/strong&gt; Usually &lt;code&gt;traefik.http.services.dockge.loadbalancer.server.port=5001&lt;/code&gt; is missing. Inside the container Dockge listens on 5001; without that line Traefik guesses. Also check that the container is attached to the &lt;code&gt;proxy&lt;/code&gt; network.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The deployment aborts with „no such file or directory".&lt;/strong&gt; The host and container paths of the stacks directory do not match. It must be mounted as &lt;code&gt;/opt/stacks:/opt/stacks&lt;/code&gt; with &lt;code&gt;DOCKGE_STACKS_DIR=/opt/stacks&lt;/code&gt; – Dockge has the host's Docker daemon do the work, and that daemon only knows host paths.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A stack is visible but all buttons are missing.&lt;/strong&gt; Then it lives outside &lt;code&gt;/opt/stacks&lt;/code&gt; and Dockge shows „This stack is not managed by Dockge." Move the folder as in step 7 – Dockge neither adopts it by itself nor makes a copy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The stack name is rejected.&lt;/strong&gt; Only lowercase letters, digits and hyphens are allowed, because they become the folder and Compose project name. &lt;code&gt;My App&lt;/code&gt; won't do, &lt;code&gt;my-app&lt;/code&gt; will.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After a restart the login is gone.&lt;/strong&gt; Then &lt;code&gt;./data&lt;/code&gt; was not mounted persistently – that is where the database and JWT secret live. Check that &lt;code&gt;/opt/dockge/data&lt;/code&gt; exists and is bound in the Compose file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The interface is in the wrong language.&lt;/strong&gt; Dockge follows the browser and falls back to English for unknown combinations; the settings let you pin the language.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Honest about the project's state.&lt;/strong&gt; Dockge is popular (around 24,000 stars) and still being developed – the most recent commits in the repository are from April 2026, among them container controls and a resource display. &lt;strong&gt;The last stable release 1.5.0, however, dates from 30 March 2025&lt;/strong&gt;, more than a year ago. There is a &lt;code&gt;nightly&lt;/code&gt; tag with the newer features – but it is explicitly not a release.&lt;/p&gt;

&lt;p&gt;In practice that means: stay on &lt;code&gt;1.5.0&lt;/code&gt; (completely unproblematic with Docker 29.6.1 and Compose v5.3.1 in the test), do not expect quick bug fixes, and do not make an operational decision that depends on a new version arriving tomorrow. For „manage stacks conveniently" it is enough – you just want to know what you are getting into.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Updating Dockge itself&lt;/strong&gt; works like any other stack, but from the CLI (the interface cannot restart itself):&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;cd&lt;/span&gt; /opt/dockge &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker compose pull &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;&lt;strong&gt;Backups.&lt;/strong&gt; Two things need saving, and both are small:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/opt/dockge/data&lt;/code&gt; – Dockge's database with accounts and settings&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/opt/stacks&lt;/code&gt; – &lt;strong&gt;this is where the actual work lives&lt;/strong&gt;: every &lt;code&gt;compose.yaml&lt;/code&gt; and &lt;code&gt;.env&lt;/code&gt; of your services&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That makes &lt;code&gt;/opt/stacks&lt;/code&gt; your central configuration location from now on – one more reason to back it up daily with &lt;a href="https://serverkueche.de/en/tutorials/restic-backups/" rel="noopener noreferrer"&gt;Restic&lt;/a&gt;. The data of your applications (volumes, bind mounts) is &lt;strong&gt;not&lt;/strong&gt; covered by Dockge; that still needs its own strategy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Review access regularly.&lt;/strong&gt; Because Dockge means root rights on the host, maintenance includes the question: who can actually reach it? The Traefik logs show who requests the login page – if unfamiliar IPs turn up there, an IP restriction or access only via &lt;a href="https://serverkueche.de/en/tutorials/wireguard-vpn-setup/" rel="noopener noreferrer"&gt;WireGuard&lt;/a&gt; is the better answer than an even longer password.&lt;/p&gt;




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

</description>
      <category>docker</category>
      <category>selfhosted</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Running WordPress Cleanly with Docker &amp; Traefik</title>
      <dc:creator>serverkueche.de</dc:creator>
      <pubDate>Tue, 20 Oct 2026 07:50:57 +0000</pubDate>
      <link>https://dev.to/serverkueche/running-wordpress-cleanly-with-docker-traefik-5m9</link>
      <guid>https://dev.to/serverkueche/running-wordpress-cleanly-with-docker-traefik-5m9</guid>
      <description>&lt;p&gt;WordPress powers a large part of the web – and is notorious for hacked installations. The difference between "runs and is secure" and "becomes a spam cannon" lies in a clean setup and honest maintenance. This recipe delivers both: WordPress with Docker, an isolated database, HTTPS – and a clear statement of what you have to do on an ongoing basis.&lt;/p&gt;

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

&lt;p&gt;A production-ready WordPress installation with &lt;strong&gt;WordPress 7.1.0&lt;/strong&gt; (PHP 8.5) and &lt;strong&gt;MariaDB 11.8&lt;/strong&gt; behind &lt;a href="https://serverkueche.de/en/tutorials/traefik-reverse-proxy/" rel="noopener noreferrer"&gt;Traefik&lt;/a&gt;. The database runs in an &lt;strong&gt;isolated internal network&lt;/strong&gt; with no internet access, WordPress gets its HTTPS from Traefik, and we cleanly solve the classic reverse-proxy pitfall (redirect loop / mixed content). By the end you have a running blog – and you know how to keep it secure.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;ℹ️ WordPress or Hugo?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you "only" need a website or blog without interactive features, a static site &lt;a href="https://serverkueche.de/en/tutorials/host-your-website-with-hugo/" rel="noopener noreferrer"&gt;with Hugo&lt;/a&gt; is safer and lower-maintenance to run (no database, no attack surface). WordPress is worth it when you need its huge plugin ecosystem, editorial workflows or shop/membership features. This tutorial takes WordPress honestly, including its maintenance burden.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;ul&gt;
&lt;li&gt;A server with &lt;a href="https://serverkueche.de/en/tutorials/traefik-reverse-proxy/" rel="noopener noreferrer"&gt;Traefik&lt;/a&gt; running and &lt;a href="https://serverkueche.de/en/tutorials/docker-compose-basics/" rel="noopener noreferrer"&gt;Docker Compose&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;A &lt;a href="https://serverkueche.de/en/tutorials/connect-domain-to-server/" rel="noopener noreferrer"&gt;domain pointing at the server&lt;/a&gt; – &lt;code&gt;YOUR_DOMAIN&lt;/code&gt; below&lt;/li&gt;
&lt;li&gt;A bit more RAM than simple services need – WordPress + database + PHP run comfortably from the &lt;a href="https://serverkueche.de/en/netcup-recommendation/" rel="noopener noreferrer"&gt;VPS 2000&lt;/a&gt; up&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  Step 1: Two containers, two networks
&lt;/h3&gt;

&lt;p&gt;WordPress consists of two parts: the PHP application and a &lt;strong&gt;MariaDB&lt;/strong&gt; database. A clean setup separates the two and isolates the database – it should only be reachable by WordPress and needs no internet itself. That's exactly what the two networks are for (see &lt;a href="https://serverkueche.de/en/tutorials/understanding-docker-networks/" rel="noopener noreferrer"&gt;Understanding Docker networks&lt;/a&gt;): the public &lt;code&gt;proxy&lt;/code&gt; network between WordPress and Traefik, and an &lt;code&gt;internal&lt;/code&gt; network between WordPress and the database.&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%2Fserverkueche.de%2Fen%2Ftutorials%2Fwordpress-with-docker%2Fwordpress-schema.svg" 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%2Fserverkueche.de%2Fen%2Ftutorials%2Fwordpress-with-docker%2Fwordpress-schema.svg" alt="Diagram: browser via HTTPS to Traefik, from there via HTTP with X-Forwarded-Proto to WordPress, WordPress via SQL to MariaDB on the internal network" width="929" height="450"&gt;&lt;/a&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; /opt/wordpress &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; /opt/wordpress
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: The Compose file
&lt;/h3&gt;

&lt;p&gt;Replace &lt;code&gt;YOUR_DOMAIN&lt;/code&gt; and all passwords with your own values:&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;mariadb:11.8&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;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;MARIADB_DATABASE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;wordpress&lt;/span&gt;
      &lt;span class="na"&gt;MARIADB_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;wordpress&lt;/span&gt;
      &lt;span class="na"&gt;MARIADB_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;YOUR_DB_PASSWORD&lt;/span&gt;
      &lt;span class="na"&gt;MARIADB_RANDOM_ROOT_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1"&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;db_data:/var/lib/mysql&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="nv"&gt;intern&lt;/span&gt;&lt;span class="pi"&gt;]&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"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;healthcheck.sh"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--connect"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--innodb_initialized"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;start_period&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;start_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2s&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;30s&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;wordpress&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;wordpress:7.1.0-php8.5-apache&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;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="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;WORDPRESS_DB_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;
      &lt;span class="na"&gt;WORDPRESS_DB_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;wordpress&lt;/span&gt;
      &lt;span class="na"&gt;WORDPRESS_DB_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;YOUR_DB_PASSWORD&lt;/span&gt;
      &lt;span class="na"&gt;WORDPRESS_DB_NAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;wordpress&lt;/span&gt;
      &lt;span class="na"&gt;WORDPRESS_CONFIG_EXTRA&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;if (isset($$_SERVER['HTTP_X_FORWARDED_PROTO']) &amp;amp;&amp;amp; $$_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') { $$_SERVER['HTTPS'] = 'on'; }&lt;/span&gt;
        &lt;span class="s"&gt;define('WP_HOME', 'https://YOUR_DOMAIN');&lt;/span&gt;
        &lt;span class="s"&gt;define('WP_SITEURL', 'https://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;wp_data:/var/www/html&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="nv"&gt;proxy&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;intern&lt;/span&gt;&lt;span class="pi"&gt;]&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.wp.rule=Host(`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.wp.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.wp.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.wp.loadbalancer.server.port=80"&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;db_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
  &lt;span class="na"&gt;wp_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&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;span class="na"&gt;intern&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;internal&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;Three details are crucial here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;depends_on: condition: service_healthy&lt;/code&gt;&lt;/strong&gt; – WordPress only starts once the database reports ready via the health check. Without it the first start fails in a race condition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;intern&lt;/code&gt; is &lt;code&gt;internal: true&lt;/code&gt;&lt;/strong&gt; – the database is &lt;em&gt;only&lt;/em&gt; on this network and therefore has no internet access; WordPress reaches it at the hostname &lt;code&gt;db&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;WORDPRESS_CONFIG_EXTRA&lt;/code&gt;&lt;/strong&gt; – the most important part (next step).&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠️ The reverse-proxy pitfall: detecting HTTPS&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Traefik terminates TLS and talks to WordPress internally over HTTP. WordPress therefore thinks it's running insecurely and generates &lt;code&gt;http://&lt;/code&gt; links – which leads to &lt;strong&gt;mixed-content errors&lt;/strong&gt; or a &lt;strong&gt;redirect loop&lt;/strong&gt; (&lt;code&gt;ERR_TOO_MANY_REDIRECTS&lt;/code&gt;). The line &lt;code&gt;if (... HTTP_X_FORWARDED_PROTO ... === 'https') { $_SERVER['HTTPS'] = 'on'; }&lt;/code&gt; tells WordPress the original connection was HTTPS. Together with &lt;code&gt;WP_HOME&lt;/code&gt;/&lt;code&gt;WP_SITEURL&lt;/code&gt; that's the clean solution. In Compose the PHP variables must be written as &lt;code&gt;$$&lt;/code&gt;, otherwise Docker interprets them as its own variables.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 3: Start
&lt;/h3&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;Docker starts the database first, waits for its health check and then starts WordPress. 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 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                           SERVICE     STATUS
wordpress-db-1          mariadb:11.8                    db          Up (healthy)
wordpress-wordpress-1   wordpress:7.1.0-php8.5-apache   wordpress   Up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify from outside that WordPress responds with valid HTTPS (the 302 redirects to the install):&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; https://YOUR_DOMAIN/ | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&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/2 302
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 4: The famous 5-minute install
&lt;/h3&gt;

&lt;p&gt;Open &lt;code&gt;https://YOUR_DOMAIN/&lt;/code&gt;. WordPress redirects to the install wizard. After the language selection you fill in the basics:&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%2Frc13fpkl413zc84gvcz1.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%2Frc13fpkl413zc84gvcz1.png" alt="The WordPress install wizard with fields for title, username, password and email" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠️ Don't use “admin” as the username&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Do &lt;strong&gt;not&lt;/strong&gt; choose the username &lt;code&gt;admin&lt;/code&gt; – it's the first target of every brute-force attack on WordPress. Use your own name and a strong password. That's the simplest and most effective hardening measure of all.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;After clicking "Install WordPress" and the first login you land in the dashboard:&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%2Funvi1vkcq0dbnz2yl238.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%2Funvi1vkcq0dbnz2yl238.png" alt="The WordPress dashboard after installation, version 7.1 with the Twenty Twenty-Five theme" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: The website is live
&lt;/h3&gt;

&lt;p&gt;Your site is immediately public – with valid HTTPS from Traefik and no mixed-content errors thanks to the proxy configuration:&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/wp-frontend.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/wp-frontend.png" title="The frontend under your own domain – the default post \" alt="The frontend of the freshly installed WordPress site with the first post " width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6: Basic hardening
&lt;/h3&gt;

&lt;p&gt;WordPress is functional out of the box but not optimally secured. The most important immediate measures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Enable automatic updates:&lt;/strong&gt; under &lt;strong&gt;Dashboard → Updates&lt;/strong&gt; make sure at least security updates are applied automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delete unnecessary default content:&lt;/strong&gt; the "Hello world!" post, the sample page and the bundled example plugin (&lt;code&gt;Hello Dolly&lt;/code&gt;) can go.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Less is more with plugins:&lt;/strong&gt; every plugin is additional attack surface. Only install what you really need, and only from a trustworthy source.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Protect the login further:&lt;/strong&gt; ideally put another hurdle in front of the WordPress login (&lt;code&gt;/wp-login.php&lt;/code&gt;). A rate-limit/BasicAuth middleware in Traefik or a 2FA plugin work well. &lt;a href="https://serverkueche.de/en/tutorials/fail2ban-setup/" rel="noopener noreferrer"&gt;Fail2ban&lt;/a&gt; can also evaluate WordPress login attempts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep an eye on XML-RPC:&lt;/strong&gt; the file &lt;code&gt;xmlrpc.php&lt;/code&gt; is a common target for brute force and DDoS. If you don't need it, block it via a Traefik middleware or plugin.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;strong&gt;Redirect loop (&lt;code&gt;ERR_TOO_MANY_REDIRECTS&lt;/code&gt;) or mixed-content warnings.&lt;/strong&gt; The HTTPS detection is missing. Check that the &lt;code&gt;WORDPRESS_CONFIG_EXTRA&lt;/code&gt; block with &lt;code&gt;HTTP_X_FORWARDED_PROTO&lt;/code&gt; and the &lt;code&gt;$$&lt;/code&gt; signs is set exactly (see the warning in step 2), and restart the WordPress container.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WordPress won't start, reports "Error establishing a database connection".&lt;/strong&gt; Usually a race condition or a password mismatch. Check that &lt;code&gt;depends_on: condition: service_healthy&lt;/code&gt; is set and &lt;code&gt;WORDPRESS_DB_PASSWORD&lt;/code&gt; matches &lt;code&gt;MARIADB_PASSWORD&lt;/code&gt; exactly. Logs: &lt;code&gt;docker compose logs db&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The first start fails even though everything runs later.&lt;/strong&gt; The database needs a few seconds to initialize on the very first start. The health check covers this; without it, another &lt;code&gt;docker compose up -d&lt;/code&gt; helps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Media uploads fail for large files.&lt;/strong&gt; PHP limits the upload size. Create your own &lt;code&gt;uploads.ini&lt;/code&gt; and mount it to &lt;code&gt;/usr/local/etc/php/conf.d/&lt;/code&gt; with e.g. &lt;code&gt;upload_max_filesize = 64M&lt;/code&gt; and &lt;code&gt;post_max_size = 64M&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After a domain change the site points nowhere.&lt;/strong&gt; &lt;code&gt;WP_HOME&lt;/code&gt;/&lt;code&gt;WP_SITEURL&lt;/code&gt; are hard-wired in the Compose file – for a new domain, change them there and restart the container (the values override the database setting).&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Honestly: WordPress is maintenance work.&lt;/strong&gt; Unlike a static site, active code with a database runs here – core, themes and plugins get regular security updates that must be applied &lt;strong&gt;promptly&lt;/strong&gt;. A neglected WordPress will reliably get hijacked. Enable automatic updates and check the update status monthly. Keep the container tag (&lt;code&gt;wordpress:7.1.0-php8.5-apache&lt;/code&gt;) current via your normal &lt;a href="https://serverkueche.de/en/tutorials/keep-docker-stack-updated/" rel="noopener noreferrer"&gt;update process&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The backup has two parts – both are mandatory.&lt;/strong&gt; WordPress consists of a &lt;strong&gt;database&lt;/strong&gt; (posts, settings, users) &lt;em&gt;and&lt;/em&gt; &lt;strong&gt;files&lt;/strong&gt; (&lt;code&gt;wp_data&lt;/code&gt; volume: uploads, themes, plugins). A Restic snapshot of the running MariaDB volume alone is no guarantee – back up the database additionally via a dump. &lt;code&gt;-T&lt;/code&gt; matters here (without it the command hangs on the password prompt and leaves an &lt;strong&gt;empty&lt;/strong&gt; file), as does putting the password directly after &lt;code&gt;-p&lt;/code&gt;, with no space:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; db mariadb-dump &lt;span class="nt"&gt;-u&lt;/span&gt; wordpress &lt;span class="nt"&gt;-p&lt;/span&gt;&lt;span class="s1"&gt;'YOUR_DB_PASSWORD'&lt;/span&gt; &lt;span class="nt"&gt;--single-transaction&lt;/span&gt; wordpress &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; backup.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Afterwards make sure the file isn't empty (&lt;code&gt;ls -l backup.sql&lt;/code&gt;). The dump &lt;strong&gt;and&lt;/strong&gt; the &lt;code&gt;wp_data&lt;/code&gt; volume belong in your &lt;a href="https://serverkueche.de/en/tutorials/restic-backups/" rel="noopener noreferrer"&gt;Restic backup&lt;/a&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Test the restore.&lt;/strong&gt; A backup that was never restored is just a hope – run a restore at least once as a test, so that in an emergency you know the dump and files fit together.&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/wordpress-with-docker/" rel="noopener noreferrer"&gt;serverkueche.de&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>docker</category>
      <category>selfhosted</category>
      <category>tutorial</category>
    </item>
    <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>
  </channel>
</rss>
