<?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>netcup VPS 1000 G12 benchmarked: how fast is it really?</title>
      <dc:creator>serverkueche.de</dc:creator>
      <pubDate>Sun, 20 Sep 2026 17:54:20 +0000</pubDate>
      <link>https://dev.to/serverkueche/netcup-vps-1000-g12-benchmarked-how-fast-is-it-really-5689</link>
      <guid>https://dev.to/serverkueche/netcup-vps-1000-g12-benchmarked-how-fast-is-it-really-5689</guid>
      <description>&lt;p&gt;The &lt;a href="https://serverkueche.de/en/netcup-recommendation/" rel="noopener noreferrer"&gt;VPS 1000 G12&lt;/a&gt; is netcup's entry-level VPS and the server most Serverküche recipes run on. But how fast is it &lt;strong&gt;really&lt;/strong&gt;? We measured it thoroughly with standard tools – and show you the commands you can use to check your own server against it.&lt;/p&gt;

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

&lt;p&gt;We benchmark the four things that matter in everyday self-hosting: &lt;strong&gt;CPU&lt;/strong&gt;, &lt;strong&gt;memory&lt;/strong&gt;, &lt;strong&gt;NVMe disk&lt;/strong&gt; and &lt;strong&gt;network&lt;/strong&gt; – each with established open-source tools (&lt;code&gt;sysbench&lt;/code&gt;, &lt;code&gt;fio&lt;/code&gt;, &lt;code&gt;7z&lt;/code&gt;, &lt;code&gt;openssl&lt;/code&gt;, &lt;code&gt;curl&lt;/code&gt;). All figures below come from a &lt;strong&gt;real VPS 1000 G12&lt;/strong&gt; on Debian 13. The test machine: &lt;strong&gt;AMD EPYC-Genoa, 4 vCore, 8 GB RAM, 256 GB NVMe.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The key figures at a glance (measured in &lt;strong&gt;July 2026&lt;/strong&gt; with sysbench 1.0.20 and fio 3.39 on a VPS 1000 G12 in netcup's Vienna data centre):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;Measurement (VPS 1000 G12)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CPU – 1 core (sysbench)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;1,495&lt;/strong&gt; events/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU – 4 cores (sysbench)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;5,974&lt;/strong&gt; events/s (≈ 4× scaling)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7-Zip (&lt;code&gt;7z b&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;~27,400&lt;/strong&gt; MIPS total&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AES-256-GCM (AES-NI)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~7.7 GB/s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAM throughput&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~30 GB/s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NVMe – 4K random read&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;101,000&lt;/strong&gt; IOPS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NVMe – 4K random write&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;67,000&lt;/strong&gt; IOPS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NVMe – sequential read&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.3 GB/s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NVMe – sequential write&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.1 GB/s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Download (Falkenstein)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;~246 MB/s&lt;/strong&gt; (about 2 Gbit/s)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Latency (Anycast resolver 1.1.1.1)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~12 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Steal time (in the test)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Quick assessment: for an entry-level VPS these are consistently &lt;strong&gt;strong&lt;/strong&gt; figures – especially the NVMe disk and the network play well above what you'd expect from the cheapest plan. But the context matters: a VPS shares the physical CPU with other customers (shared vCores). Your figures can differ depending on the neighbors' load – how you spot that is in "When things go wrong".&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;A netcup server, e.g. your &lt;a href="https://serverkueche.de/en/tutorials/first-steps-netcup-vps/" rel="noopener noreferrer"&gt;first VPS&lt;/a&gt;, with SSH access.&lt;/li&gt;
&lt;li&gt;The benchmark tools. All are in the Debian package sources:
&lt;/li&gt;
&lt;/ul&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; sysbench fio p7zip-full
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Some free storage space (the disk tests write a few GB temporarily) and ideally &lt;strong&gt;no&lt;/strong&gt; production load during the measurement.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 Measure several times&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A single benchmark is a momentary snapshot. Run each test &lt;strong&gt;two or three times&lt;/strong&gt; and ideally at different times of day – then you see how stable the figures are.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;h3&gt;
  
  
  Step 1: Size up the server
&lt;/h3&gt;

&lt;p&gt;Before you measure, look at what you're dealing with:&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;grep&lt;/span&gt; &lt;span class="nt"&gt;-m1&lt;/span&gt; &lt;span class="s2"&gt;"model name"&lt;/span&gt; /proc/cpuinfo &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;nproc&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; free &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;model name : AMD EPYC-Genoa Processor
4
               total        used        free      shared  buff/cache   available
Mem:           7.8Gi       689Mi       3.0Gi       656Ki       4.3Gi       7.1Gi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four vCores on an &lt;strong&gt;AMD EPYC-Genoa&lt;/strong&gt; (netcup's current G12 generation) and 8 GB RAM. Also take a look at the &lt;strong&gt;steal time&lt;/strong&gt; – the percentage the CPU "waits" because a neighbor on the same host is computing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vmstat 1 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the &lt;code&gt;st&lt;/code&gt; column (far right) it should ideally read &lt;code&gt;0&lt;/code&gt;. For us it was &lt;strong&gt;0&lt;/strong&gt; over the whole test – no noticeable neighbor influence. High, persistent steal values would be the sign of an oversubscribed host.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: CPU
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;sysbench&lt;/code&gt; computes prime numbers – once on one core, once on all four:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysbench cpu &lt;span class="nt"&gt;--cpu-max-prime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;20000 &lt;span class="nt"&gt;--threads&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 run
sysbench cpu &lt;span class="nt"&gt;--cpu-max-prime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;20000 &lt;span class="nt"&gt;--threads&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4 run
&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;1 Thread:   events per second:  1494.66
4 Threads:  events per second:  5973.69
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things are remarkable here: the solid single-thread performance (Genoa cores are fast) and the &lt;strong&gt;almost perfect scaling&lt;/strong&gt; – 4 threads deliver 3.996× a single one. That means: at the time of measurement, the four vCores were fully available, without neighbors siphoning off compute time.&lt;/p&gt;

&lt;p&gt;A second, practical CPU test is the built-in 7-Zip benchmark (compression, uses all cores):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;7z b
&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;Tot:  ...  27425  (MIPS total)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;7z b&lt;/code&gt; measures compression and decompression separately (each its own line) and combines both in the &lt;code&gt;Tot:&lt;/code&gt; line into an overall rating – that's the roughly &lt;strong&gt;27,400 MIPS&lt;/strong&gt;. A good reference figure to compare the VPS with other 7-Zip results online. And because encryption runs everywhere (HTTPS, backups, VPN), the AES performance with hardware acceleration (AES-NI):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl speed &lt;span class="nt"&gt;-evp&lt;/span&gt; aes-256-gcm
&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;type             16 bytes     64 bytes    256 bytes   1024 bytes   8192 bytes  16384 bytes
AES-256-GCM      89283.37k   343370.05k  1270428.16k  3164995.93k  7671136.75k  8464845.66k
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;openssl speed&lt;/code&gt; measures &lt;strong&gt;single-threaded&lt;/strong&gt; by default – so the figure applies to one core. For the throughput across all cores you append &lt;code&gt;-multi $(nproc)&lt;/code&gt;. In our run a single core reached about &lt;strong&gt;7.7 GB/s&lt;/strong&gt; (7,671,136 k) on the 8 KB blocks. TLS is thus never the bottleneck on this processor anyway.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Memory
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;sysbench&lt;/code&gt; writes a large block repeatedly through RAM and measures the throughput:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysbench memory &lt;span class="nt"&gt;--memory-block-size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1M &lt;span class="nt"&gt;--memory-total-size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;30G &lt;span class="nt"&gt;--threads&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4 run
&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;30720.00 MiB transferred (28727.21 MiB/sec)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Around 30 GB/s&lt;/strong&gt; – plenty for databases, caches (Redis/Valkey) and everything that keeps many small objects in memory. RAM on this plan is, in our experience, limited more by &lt;strong&gt;quantity&lt;/strong&gt; (8 GB) than by throughput.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: NVMe disk
&lt;/h3&gt;

&lt;p&gt;Here the wheat separates from the chaff – the disk is the actual bottleneck for most self-hosted apps. &lt;code&gt;fio&lt;/code&gt; measures realistically when you bypass the page cache with &lt;code&gt;--direct=1&lt;/code&gt; (otherwise you measure RAM, not the disk). First the &lt;strong&gt;4K random IOPS&lt;/strong&gt; decisive for databases:&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="c"&gt;# Read (4K random read)&lt;/span&gt;
fio &lt;span class="nt"&gt;--name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;rr &lt;span class="nt"&gt;--ioengine&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;libaio &lt;span class="nt"&gt;--direct&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nt"&gt;--rw&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;randread &lt;span class="nt"&gt;--bs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4k &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--numjobs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4 &lt;span class="nt"&gt;--iodepth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;32 &lt;span class="nt"&gt;--size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;512M &lt;span class="nt"&gt;--runtime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;20 &lt;span class="nt"&gt;--time_based&lt;/span&gt; &lt;span class="nt"&gt;--group_reporting&lt;/span&gt;

&lt;span class="c"&gt;# Write (4K random write) – same command, only --rw=randwrite&lt;/span&gt;
fio &lt;span class="nt"&gt;--name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;rw &lt;span class="nt"&gt;--ioengine&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;libaio &lt;span class="nt"&gt;--direct&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nt"&gt;--rw&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;randwrite &lt;span class="nt"&gt;--bs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4k &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--numjobs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4 &lt;span class="nt"&gt;--iodepth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;32 &lt;span class="nt"&gt;--size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;512M &lt;span class="nt"&gt;--runtime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;20 &lt;span class="nt"&gt;--time_based&lt;/span&gt; &lt;span class="nt"&gt;--group_reporting&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;read:  IOPS=101k, BW=394MiB/s
write: IOPS=67.0k, BW=262MiB/s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;101,000 read and 67,000 write IOPS&lt;/strong&gt; at 4K – that's real NVMe level and the reason why Nextcloud, databases or Paperless feel noticeably smooth on this VPS. And the sequential throughput (large files, backups, video):&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="c"&gt;# Read (sequential)&lt;/span&gt;
fio &lt;span class="nt"&gt;--name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sr &lt;span class="nt"&gt;--ioengine&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;libaio &lt;span class="nt"&gt;--direct&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nt"&gt;--rw&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;--bs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1M &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--numjobs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nt"&gt;--iodepth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;16 &lt;span class="nt"&gt;--size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2G &lt;span class="nt"&gt;--runtime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;15 &lt;span class="nt"&gt;--time_based&lt;/span&gt;

&lt;span class="c"&gt;# Write (sequential) – same command, only --rw=write&lt;/span&gt;
fio &lt;span class="nt"&gt;--name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sw &lt;span class="nt"&gt;--ioengine&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;libaio &lt;span class="nt"&gt;--direct&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nt"&gt;--rw&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;write &lt;span class="nt"&gt;--bs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1M &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--numjobs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nt"&gt;--iodepth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;16 &lt;span class="nt"&gt;--size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2G &lt;span class="nt"&gt;--runtime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;15 &lt;span class="nt"&gt;--time_based&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;READ:  bw=4123MiB/s (4.3 GB/s)
WRITE: bw=2937MiB/s (3.1 GB/s)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4.3 GB/s reading, 3.1 GB/s writing.&lt;/strong&gt; A &lt;code&gt;restic&lt;/code&gt; backup or a large &lt;code&gt;docker pull&lt;/code&gt; is thus done in seconds.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Network
&lt;/h3&gt;

&lt;p&gt;For throughput, you download a large test file from a well-connected server. We take the Hetzner speed test in Falkenstein (Germany):&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;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{speed_download} B/s in %{time_total}s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://fsn1-speed.hetzner.com/1GB.bin
&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;246095181 B/s in 4.36s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;~246 MB/s, i.e. about 2 Gbit/s&lt;/strong&gt; – 1 GB in just over four seconds. A download from the US (Ashburn) was, due to distance, at ~36 MB/s; within Europe the connection is excellent. Finally the latency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ping &lt;span class="nt"&gt;-c&lt;/span&gt; 5 1.1.1.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;rtt min/avg/max/mdev = 12.314/12.337/12.365/0.019 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;~12 ms&lt;/strong&gt; to &lt;code&gt;1.1.1.1&lt;/code&gt; (Cloudflare's Anycast resolver, i.e. a nearby network node – not a purely German target), very consistent (the deviation is in the hundredths). IPv6 is active and works; a ping over IPv6 was at ~26 ms.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Your figures are well below ours, especially for the CPU.&lt;/strong&gt; A VPS shares the physical CPU. Check the &lt;strong&gt;steal time&lt;/strong&gt; (&lt;code&gt;vmstat 1&lt;/code&gt;, column &lt;code&gt;st&lt;/code&gt;) and &lt;code&gt;top&lt;/code&gt; (line &lt;code&gt;%st&lt;/code&gt;). If it's persistently high, neighbors on the same host are computing right now. Measure again at a different time of day – often the difference is gone then.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The disk figures are absurdly high (e.g. "10 GB/s random read").&lt;/strong&gt; You're missing &lt;code&gt;--direct=1&lt;/code&gt; – then &lt;code&gt;fio&lt;/code&gt; measures the RAM cache, not the NVMe. Always test with direct I/O, otherwise the numbers are worthless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;lsblk&lt;/code&gt; shows &lt;code&gt;ROTA=1&lt;/code&gt; ("rotational") for &lt;code&gt;vda&lt;/code&gt; in the column – is that a hard disk instead of NVMe?&lt;/strong&gt; No. That's a &lt;strong&gt;virtualization artifact&lt;/strong&gt;: the virtio driver reports the virtual disk as rotational across the board. The measured 100k+ IOPS and 4 GB/s prove that real flash storage is behind it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The download is much slower than 2 Gbit/s.&lt;/strong&gt; Measure against a &lt;strong&gt;nearby, fast&lt;/strong&gt; server (e.g. Falkenstein). A distant target or a slow counterpart limits the measurement, not your VPS. A single &lt;code&gt;curl&lt;/code&gt; stream also doesn't always exhaust the full bandwidth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every run delivers different numbers.&lt;/strong&gt; Normal – benchmarks fluctuate. Measure multiple times, discard the first ("warm") run and take the median. Also compare only &lt;strong&gt;the same tool versions and parameters&lt;/strong&gt; with each other.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Who is the VPS 1000 G12 enough for?&lt;/strong&gt; For practically all the single services on this site – SSH, &lt;a href="https://serverkueche.de/en/tutorials/traefik-reverse-proxy/" rel="noopener noreferrer"&gt;Traefik&lt;/a&gt;, Vaultwarden, Uptime Kuma, a small Nextcloud. It gets tight less at CPU or disk than at &lt;strong&gt;RAM&lt;/strong&gt;: as soon as several heavy apps (Nextcloud + Immich + databases) run in parallel, an upgrade to the &lt;a href="https://serverkueche.de/en/netcup-recommendation/" rel="noopener noreferrer"&gt;VPS 2000&lt;/a&gt; (16 GB) is the most sensible next step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch steal time long-term.&lt;/strong&gt; A one-off benchmark is a momentary snapshot. Whoever wants to keep an eye on performance long-term takes CPU steal, I/O and network into a &lt;a href="https://serverkueche.de/en/tutorials/monitoring-grafana-prometheus/" rel="noopener noreferrer"&gt;Grafana dashboard&lt;/a&gt; – there you see creeping degradation before it hurts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-measure after changes.&lt;/strong&gt; A server migration, a product switch or a new netcup generation changes the figures. Keep your benchmark outputs (a simple text file in the backup is enough), then you have a basis for comparison.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stay honest:&lt;/strong&gt; benchmark numbers age and fluctuate. They're an orientation, not a promise – the shared vCores mean the real performance always also depends on the neighbors on the host. For the entry-level price, though, the VPS 1000 G12 delivers a remarkably well-rounded performance.&lt;/li&gt;
&lt;/ul&gt;




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

</description>
      <category>benchmark</category>
      <category>vps</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>HitKeep: self-host privacy-friendly web analytics</title>
      <dc:creator>serverkueche.de</dc:creator>
      <pubDate>Sun, 20 Sep 2026 17:54:04 +0000</pubDate>
      <link>https://dev.to/serverkueche/hitkeep-self-host-privacy-friendly-web-analytics-5f11</link>
      <guid>https://dev.to/serverkueche/hitkeep-self-host-privacy-friendly-web-analytics-5f11</guid>
      <description>&lt;p&gt;Everyone knows Google Analytics – and that's exactly the problem: it sends your visitors' data to Google, requires a cookie banner and makes you accountable to explain it. HitKeep turns that around: cookieless statistics on &lt;strong&gt;your&lt;/strong&gt; server, under &lt;strong&gt;your&lt;/strong&gt; domain, without a single byte ever going to third parties.&lt;/p&gt;

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

&lt;p&gt;By the end, &lt;strong&gt;HitKeep 2.13.18&lt;/strong&gt; runs as a single container behind your Traefik, reachable at &lt;code&gt;https://YOUR_DOMAIN&lt;/code&gt;. You get a dashboard with page views, visitors, time on page, referrers and devices – fed by a tiny JavaScript snippet you embed in your website. HitKeep works &lt;strong&gt;cookieless&lt;/strong&gt; (no consent banner needed) and stores everything locally in an embedded DuckDB database. The image is a distroless image (about 68 MB to download, a good 230 MB unpacked on disk) that gets by entirely without an external database – ideal for a small VPS.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;ℹ️ Note&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;HitKeep is "cookieless" because it recognizes visitors via a daily-changing hash instead of a set cookie. That's significantly more privacy-friendly than classic tracking, but is &lt;strong&gt;no&lt;/strong&gt; substitute for legal advice. Whether you can do entirely without consent depends on your specific use – when in doubt, clarify it with a data protection officer.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;ul&gt;
&lt;li&gt;A server with &lt;strong&gt;Debian 13&lt;/strong&gt; and running Docker (tested on a netcup VPS).&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;&lt;a href="https://serverkueche.de/en/tutorials/traefik-reverse-proxy/" rel="noopener noreferrer"&gt;reverse proxy with Traefik&lt;/a&gt;&lt;/strong&gt; that fetches TLS certificates via Let's Encrypt. HitKeep brings no own HTTPS server – Traefik handles the encryption. This tutorial assumes the &lt;code&gt;proxy&lt;/code&gt; network and the resolver &lt;code&gt;le&lt;/code&gt; described there.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;(sub)domain&lt;/strong&gt; that points to your server (A/AAAA record). In the example we use &lt;code&gt;YOUR_DOMAIN&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The website you want to measure – HitKeep measures every page into which you embed the snippet.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  Step 1: Generate a JWT secret
&lt;/h3&gt;

&lt;p&gt;HitKeep signs the login sessions with a secret key. Generate a random 32-byte value – &lt;strong&gt;don't&lt;/strong&gt; make one up, generate it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 32
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get a 64-character hex string. Copy it – it goes into the configuration shortly. If this secret changes later, all open logins become invalid; so keep it stable and secret.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Create the Compose file
&lt;/h3&gt;

&lt;p&gt;Create a folder for the stack and change into 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;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /opt/hitkeep &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; /opt/hitkeep
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create the file &lt;code&gt;compose.yaml&lt;/code&gt;. Replace &lt;code&gt;YOUR_DOMAIN&lt;/code&gt; with your real domain and &lt;code&gt;YOUR_JWT_SECRET&lt;/code&gt; with the value from step 1:&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;hitkeep&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;pascalebeier/hitkeep:2.13.18&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;hitkeep&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;HITKEEP_PUBLIC_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://YOUR_DOMAIN&lt;/span&gt;
      &lt;span class="na"&gt;HITKEEP_JWT_SECRET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;YOUR_JWT_SECRET&lt;/span&gt;
      &lt;span class="na"&gt;HITKEEP_TRUSTED_PROXIES&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;172.16.0.0/12&lt;/span&gt;
      &lt;span class="na"&gt;HITKEEP_DB_PATH&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/lib/hitkeep/data/hitkeep.db&lt;/span&gt;
      &lt;span class="na"&gt;HITKEEP_DATA_PATH&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/lib/hitkeep/data&lt;/span&gt;
      &lt;span class="na"&gt;HITKEEP_ARCHIVE_PATH&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/lib/hitkeep/data/archive&lt;/span&gt;
      &lt;span class="na"&gt;HITKEEP_BACKUP_PATH&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/lib/hitkeep/data/backups&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;hitkeep_data:/var/lib/hitkeep/data&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.hitkeep.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.hitkeep.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.hitkeep.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.hitkeep.loadbalancer.server.port=8080"&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;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;hitkeep_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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The most important points in detail:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;HITKEEP_PUBLIC_URL&lt;/code&gt;&lt;/strong&gt; is the public address under which HitKeep is reachable. The interface later builds the tracking snippet and the links from it. It must match &lt;strong&gt;exactly&lt;/strong&gt; the URL under which you call HitKeep – otherwise you end up in a login loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;HITKEEP_TRUSTED_PROXIES&lt;/code&gt;&lt;/strong&gt; is the crux behind a reverse proxy: by default (&lt;code&gt;*&lt;/code&gt;) HitKeep trusts the &lt;code&gt;X-Forwarded-For&lt;/code&gt; header from &lt;strong&gt;any&lt;/strong&gt; sender – so every visitor could claim someone else's IP. &lt;code&gt;172.16.0.0/12&lt;/code&gt; narrows that down to the Docker networks (the &lt;code&gt;proxy&lt;/code&gt; network sits inside it, &lt;code&gt;172.19.0.0/16&lt;/code&gt; in our test), so only Traefik gets to set the visitor IP. Important: the range really has to contain your proxy's IP – if it doesn't, HitKeep only ever sees Traefik's container IP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;loadbalancer.server.port=8080&lt;/code&gt;&lt;/strong&gt; tells Traefik that HitKeep listens internally on port

&lt;ol&gt;
&lt;li&gt;The container itself publishes no ports to the outside – access runs exclusively via Traefik.&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;The four &lt;strong&gt;&lt;code&gt;_PATH&lt;/code&gt; variables&lt;/strong&gt; store the database, data, archive and backups all below &lt;code&gt;/var/lib/hitkeep/data&lt;/code&gt; – deliberately in &lt;strong&gt;one&lt;/strong&gt; volume. The container runs as a non-root user (UID 65532), and that single directory is the only one the image ships with matching ownership. If you attach extra volumes for archive and backups on their own paths, Docker creates them owned by root, and HitKeep answers in the log with &lt;code&gt;Initial backup run failed&lt;/code&gt; and &lt;code&gt;permission_denied&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 3: Start the stack and wait for TLS
&lt;/h3&gt;

&lt;p&gt;Start the 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;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the status after a few 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 compose ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see the container as &lt;code&gt;healthy&lt;/code&gt; – HitKeep brings its own healthcheck:&lt;br&gt;
&lt;/p&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
hitkeep   pascalebeier/hitkeep:2.13.18   "hitkeep"   hitkeep   40 seconds ago   Up 40 seconds (healthy)   7946/tcp, 8080/tcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Traefik now fetches the Let's Encrypt certificate for your domain in the background. Check from your own machine that the tracking script is served:&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/hk.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected output (shortened) – status 200 and a &lt;code&gt;text/javascript&lt;/code&gt; type, aggressively cached:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP/2 200
content-type: text/javascript; charset=utf-8
cache-control: public, max-age=31536000, immutable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;If you get a &lt;code&gt;404&lt;/code&gt; from Traefik or a certificate warning here, wait a minute (Let's Encrypt needs a moment) and check that the A/AAAA record of your domain really points to the server. As long as the certificate isn't in place, the snippet won't load in the browser either.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 4: Create the admin account
&lt;/h3&gt;

&lt;p&gt;Open &lt;code&gt;https://YOUR_DOMAIN&lt;/code&gt; in the browser. On the very first start, HitKeep greets you with the initial setup. Create your administrator account here – name, email address and a password. Take a long passphrase or a random password generated by a password manager; this account sees all statistics and must not hang on a weak password:&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%2F7eejnxa21wj6t8up1pwk.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%2F7eejnxa21wj6t8up1pwk.png" alt="HitKeep initial setup: form to create the administrator account with first and last name, email address and password." 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;This initial setup is only open on the very first call. Still: set up the admin account &lt;strong&gt;immediately&lt;/strong&gt; after the start and don't leave a freshly started HitKeep wizard unsecured on the net.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 5: Create a website and get the tracking code
&lt;/h3&gt;

&lt;p&gt;After logging in, click the plus next to &lt;strong&gt;Sites&lt;/strong&gt; at the top left and create your website – as the domain, enter the domain of the site you want to measure (e.g. &lt;code&gt;YOUR_WEBSITE&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Then use the &lt;code&gt;&amp;lt;/&amp;gt;&lt;/code&gt; icon below the site name to open the site settings on the &lt;strong&gt;Tracking&lt;/strong&gt; tab. At the top you find the &lt;strong&gt;live tracking verifier&lt;/strong&gt; waiting for the first hit, and below it, under &lt;strong&gt;Install HitKeep on …&lt;/strong&gt;, the installation methods: &lt;strong&gt;Script tag&lt;/strong&gt;, &lt;strong&gt;npm&lt;/strong&gt; (a typed tracker for React/Vue/Angular/Astro), &lt;strong&gt;WordPress&lt;/strong&gt; and &lt;strong&gt;Server-side&lt;/strong&gt;. All of them report into the same dashboard; we take the script tag:&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%2Fy4m52trtwco7cjjldifx.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%2Fy4m52trtwco7cjjldifx.png" alt="HitKeep tracking settings: live verifier waits for the first hit, below it the installation methods Script tag, npm, WordPress and Server-side with the ready-made snippet." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The code to embed consists of a single line. HitKeep needs &lt;strong&gt;no site ID&lt;/strong&gt; in the snippet – the assignment happens automatically via the domain of the page on which the script runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;async&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://YOUR_DOMAIN/hk.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;ℹ️ Note&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The difference between the &lt;strong&gt;tracker host&lt;/strong&gt; and the &lt;strong&gt;measured domain&lt;/strong&gt; is important: &lt;code&gt;hk.js&lt;/code&gt; is loaded from your HitKeep domain (&lt;code&gt;YOUR_DOMAIN&lt;/code&gt;), but the hit is assigned to the domain of the visited page (&lt;code&gt;YOUR_WEBSITE&lt;/code&gt;). Both may be different – the site created in HitKeep only has to match the hostname of the visited page.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Automatic event tracking (outbound clicks, downloads, form submissions) is active by default. Under &lt;strong&gt;Advanced options&lt;/strong&gt; you can adjust the snippet – for example enable "Web Vitals" to also measure load times (LCP, INP, CLS, FCP, TTFB), or "Collect DNT" if you also want to count visitors with "Do Not Track". From a privacy perspective the default (respect DNT) is the cleaner one. Important: these switches are &lt;strong&gt;not&lt;/strong&gt; stored in HitKeep, they only write additional &lt;code&gt;data-&lt;/code&gt; attributes into the snippet, which you then have to copy again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;async&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://YOUR_DOMAIN/hk.js"&lt;/span&gt; &lt;span class="na"&gt;data-enable-web-vitals=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 6: Embed the tracking code in the website
&lt;/h3&gt;

&lt;p&gt;Add the snippet line from step 5 into the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; of your website – for a static page directly into the HTML template, for a CMS into the header area or a "Custom HTML" field. Thanks to the &lt;code&gt;async&lt;/code&gt; attribute, the script doesn't block the page build.&lt;/p&gt;

&lt;p&gt;Then open a page of your website in the browser. The &lt;strong&gt;live tracking verifier&lt;/strong&gt; from step 5 should jump from "Waiting" to a first hit within a few seconds – that's the confirmation that the chain website → &lt;code&gt;hk.js&lt;/code&gt; → HitKeep is in place.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 7: Check the dashboard
&lt;/h3&gt;

&lt;p&gt;As soon as hits trickle in, the dashboard fills up. Under &lt;strong&gt;Dashboard&lt;/strong&gt; you see your website's key figures – live visitors, page views, unique sessions, bounce rate, time on page and the traffic trend:&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%2Fobsc9t5t47jd14nx8jdv.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%2Fobsc9t5t47jd14nx8jdv.png" alt="HitKeep dashboard with metric tiles (live visitors, page views, unique sessions, bounce rate) and a traffic trend chart for the current day." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Further down, &lt;strong&gt;Latest Hits&lt;/strong&gt; lists the individual calls with path, time, referrer and device – here you see at a glance which search engines and referrals your visitors come from:&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%2F4wej38xeurkechpmtcx8.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%2F4wej38xeurkechpmtcx8.png" alt="HitKeep table " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With that your self-hosted statistics are in place: every call to your website lands directly in your own database, without a detour via third parties.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 8: More than just page views
&lt;/h3&gt;

&lt;p&gt;For the start, page views and referrers are enough – but HitKeep can do considerably more, and you find the building blocks in the left navigation. A few that are worth it for most sites:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Goals:&lt;/strong&gt; define an event as a goal – e.g. the submission of a contact form or a click on "Buy". This way you measure not only &lt;em&gt;how many&lt;/em&gt; come, but &lt;em&gt;how many do what you want&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Funnels:&lt;/strong&gt; chain several steps (home page → product page → cart) and see at which point visitors drop off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Events:&lt;/strong&gt; besides the automatically captured events (outbound clicks, downloads, forms), you can send your own events from your frontend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web Vitals:&lt;/strong&gt; if you enable them on the tracking tab, you see real load times of your visitors (LCP, INP, CLS) instead of synthetic lab values.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UTM:&lt;/strong&gt; campaign parameters (&lt;code&gt;utm_source&lt;/code&gt;, &lt;code&gt;utm_medium&lt;/code&gt;, &lt;code&gt;utm_campaign&lt;/code&gt;) are evaluated – handy to tell newsletter from social reach.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reporting:&lt;/strong&gt; have a summary sent to you regularly by email instead of having to look into the dashboard yourself (this needs the &lt;code&gt;HITKEEP_MAIL_*&lt;/code&gt; variables with your mail server's credentials).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A single HitKeep instance also manages &lt;strong&gt;any number of websites&lt;/strong&gt;: via the plus next to &lt;strong&gt;Sites&lt;/strong&gt; you create more, each with its own snippet and its own dashboard. So you don't need a second container if you want to measure several projects.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;The live verifier stays on "Waiting" / no hits in the dashboard.&lt;/strong&gt; Check in the browser (dev tools → Network) whether &lt;code&gt;hk.js&lt;/code&gt; is loaded at all and the send request afterwards comes back with status 2xx. Most common causes: the snippet isn't in the HTML, the site domain created in HitKeep doesn't match the hostname of the visited page, or an ad/tracking blocker filters the call. Since you host under your own domain (first-party), most blockers don't apply – but some lists know the path &lt;code&gt;hk.js&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;All visitors seemingly come from a single IP, country and provider show "(Unknown)".&lt;/strong&gt; Then &lt;code&gt;HITKEEP_TRUSTED_PROXIES&lt;/code&gt; isn't taking effect: the range you gave doesn't contain your proxy's IP, and HitKeep only evaluates Traefik's container IP. Check which subnet the proxy sits in with &lt;code&gt;docker network inspect proxy&lt;/code&gt; – &lt;code&gt;172.16.0.0/12&lt;/code&gt; covers the usual Docker networks – and restart the stack (&lt;code&gt;docker compose up -d&lt;/code&gt;). After that the real client IP from the &lt;code&gt;X-Forwarded-For&lt;/code&gt; header counts again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Traefik returns 404 or 502.&lt;/strong&gt; A &lt;code&gt;404&lt;/code&gt; usually means the router rule isn't matching – check that &lt;code&gt;Host(...)&lt;/code&gt; contains your real domain and the container is on the &lt;code&gt;proxy&lt;/code&gt; network. A &lt;code&gt;502&lt;/code&gt; indicates the wrong port: HitKeep listens internally on &lt;strong&gt;8080&lt;/strong&gt;, so &lt;code&gt;loadbalancer.server.port=8080&lt;/code&gt; must be set.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After login you land on the login page again (login loop).&lt;/strong&gt; That's almost always a mismatch in &lt;code&gt;HITKEEP_PUBLIC_URL&lt;/code&gt;: the value must match exactly the address through which you call HitKeep (including &lt;code&gt;https://&lt;/code&gt;, without a trailing slash). Correct the variable and restart the container.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The container won't start or isn't &lt;code&gt;healthy&lt;/code&gt;.&lt;/strong&gt; Look at the logs: &lt;code&gt;docker compose logs -f hitkeep&lt;/code&gt;. A missing or empty &lt;code&gt;HITKEEP_JWT_SECRET&lt;/code&gt; is a typical start blocker – generate one as in step 1 and enter it.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Updates:&lt;/strong&gt; HitKeep moves along briskly in the 2.x series – less than seven weeks passed between 2.12.0 and 2.13.18. So check the &lt;a href="https://github.com/pascalebeier/hitkeep/releases" rel="noopener noreferrer"&gt;releases&lt;/a&gt; about monthly. For an update, set the new tag in the &lt;code&gt;compose.yaml&lt;/code&gt; (replace &lt;code&gt;2.13.18&lt;/code&gt; with the new version) and pull it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker 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;Because the data lives in volumes, your statistics are preserved. Deliberately pin the version to a fixed tag instead of &lt;code&gt;latest&lt;/code&gt;, so a restart doesn't slip you an unplanned new major version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backups:&lt;/strong&gt; Your entire statistics live in an embedded DuckDB file (&lt;code&gt;hitkeep.db&lt;/code&gt; plus the write-ahead log &lt;code&gt;hitkeep.db.wal&lt;/code&gt;) under &lt;code&gt;/var/lib/hitkeep/data&lt;/code&gt;. So don't back up a single file, but the &lt;strong&gt;complete &lt;code&gt;hitkeep_data&lt;/code&gt; volume&lt;/strong&gt; regularly – cleanest with &lt;a href="https://serverkueche.de/en/tutorials/restic-backups/" rel="noopener noreferrer"&gt;Restic&lt;/a&gt;. HitKeep itself drops an hourly snapshot into &lt;code&gt;backups/&lt;/code&gt; inside that volume and keeps the last 24; in the log you see it as &lt;code&gt;Database backup completed&lt;/code&gt;. Because the live files are written during operation, you back them up most consistently either from those finished snapshots or from the volume while the container is briefly stopped (&lt;code&gt;docker compose stop&lt;/code&gt;). A backup you've never restored is just a glimmer of hope: test the restoration once on a test system.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cleanup:&lt;/strong&gt; The database grows with the traffic. Keep an eye on the size of the volumes (&lt;code&gt;docker system df -v&lt;/code&gt;) and plan for enough storage with a lot of traffic.&lt;/p&gt;




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

</description>
      <category>selfhosted</category>
      <category>analytics</category>
      <category>privacy</category>
    </item>
    <item>
      <title>Keeping your whole Docker stack safely up to date</title>
      <dc:creator>serverkueche.de</dc:creator>
      <pubDate>Sun, 20 Sep 2026 17:53:14 +0000</pubDate>
      <link>https://dev.to/serverkueche/keeping-your-whole-docker-stack-safely-up-to-date-4i8m</link>
      <guid>https://dev.to/serverkueche/keeping-your-whole-docker-stack-safely-up-to-date-4i8m</guid>
      <description>&lt;p&gt;A self-hosted stack is never "done". Container images get security updates, apps get new features – and whoever doesn't keep up eventually runs vulnerable software. This recipe turns "I should update sometime" into a reliable routine: informed, controlled and with a safety net.&lt;/p&gt;

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

&lt;p&gt;Not a tool, but &lt;strong&gt;update discipline&lt;/strong&gt; for your entire Docker stack. By the end you have: &lt;strong&gt;pinned versions&lt;/strong&gt; (never &lt;code&gt;latest&lt;/code&gt; again), an &lt;strong&gt;update notifier&lt;/strong&gt; (Diun) that tells you &lt;em&gt;when&lt;/em&gt; something new is available, a &lt;strong&gt;safe rollout routine&lt;/strong&gt; with a backup beforehand and a rollback plan – and a cleanup rhythm so old images don't clutter your disk. Tested with &lt;strong&gt;Diun v4.33.0&lt;/strong&gt; on Docker 29 / Compose v5.3.&lt;/p&gt;

&lt;p&gt;The guiding idea is deliberately &lt;strong&gt;not&lt;/strong&gt; "update everything automatically". With stateful apps (databases, Nextcloud, Immich) an unattended update in the middle of the night can trigger a migration that goes wrong – and nobody is there. The safe way is: &lt;strong&gt;pin → get notified → apply deliberately → verify&lt;/strong&gt;. We treat blind auto-updates honestly at the end (step 6): they have their place, but a small one.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Basic knowledge of &lt;a href="https://serverkueche.de/en/tutorials/docker-compose-basics/" rel="noopener noreferrer"&gt;Docker Compose&lt;/a&gt; – you should be able to read tags, volumes and &lt;code&gt;compose.yaml&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A running stack you want to maintain (e.g. the apps behind your &lt;a href="https://serverkueche.de/en/tutorials/traefik-reverse-proxy/" rel="noopener noreferrer"&gt;reverse proxy&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;A working &lt;a href="https://serverkueche.de/en/tutorials/restic-backups/" rel="noopener noreferrer"&gt;backup strategy with Restic&lt;/a&gt; – it's the safety net that makes updates relaxed in the first place.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  Step 1: Never &lt;code&gt;latest&lt;/code&gt; again – pin versions deliberately
&lt;/h3&gt;

&lt;p&gt;The most common mistake appears in countless guides: &lt;code&gt;image: nextcloud:latest&lt;/code&gt;. The problem: &lt;code&gt;latest&lt;/code&gt; is a moving target. A &lt;code&gt;docker compose pull&lt;/code&gt; can pull you a new major version with breaking changes at any time, unasked – you never know what you're running, and a rollback is barely possible.&lt;/p&gt;

&lt;p&gt;Pin every service to a &lt;strong&gt;fixed tag&lt;/strong&gt; instead. Two sensible levels:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Major tag&lt;/strong&gt; (&lt;code&gt;postgres:18&lt;/code&gt;, &lt;code&gt;nextcloud:34-apache&lt;/code&gt;): automatically gets patch and minor updates of the same major version on the next &lt;code&gt;pull&lt;/code&gt;, but &lt;strong&gt;never&lt;/strong&gt; a major jump. A good compromise for most services.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exact version&lt;/strong&gt; (&lt;code&gt;vaultwarden/server:1.37.2&lt;/code&gt;): full control, nothing changes without your involvement. Ideal for delicate, stateful apps.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What you're running &lt;strong&gt;right now&lt;/strong&gt; is shown by Compose per project:&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; ~/YOUR_APP &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker compose images
&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;CONTAINER           REPOSITORY          TAG                 PLATFORM            IMAGE ID            SIZE                CREATED
diun-demo-whoami    traefik/whoami      v1.11.0             linux/amd64         200689790a0a        3.04MB              17 months ago
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;latest&lt;/code&gt; appears anywhere in the &lt;code&gt;TAG&lt;/code&gt; column, that's your first candidate to pin. Enter the specific tag into the &lt;code&gt;compose.yaml&lt;/code&gt; – that's the foundation for everything else.&lt;/p&gt;

&lt;p&gt;Which version is currently &lt;strong&gt;stable&lt;/strong&gt; you look up at the source, not by gut feeling: the &lt;strong&gt;tags overview on Docker Hub&lt;/strong&gt; (or GHCR) of the image, or the &lt;strong&gt;release notes&lt;/strong&gt; on GitHub. For official images this also works via API:&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="s2"&gt;"https://hub.docker.com/v2/repositories/library/postgres/tags/?page_size=20"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'"name":"[0-9.]+"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Take the highest stable version of the same series you want to run – pre-releases (&lt;code&gt;rc&lt;/code&gt;, &lt;code&gt;beta&lt;/code&gt;) stay out.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: See what's outdated – the update notifier Diun
&lt;/h3&gt;

&lt;p&gt;Pinning means updates no longer come on their own. So you need someone to &lt;strong&gt;let you know&lt;/strong&gt; when a new image is available. That's exactly what &lt;strong&gt;Diun&lt;/strong&gt; (Docker Image Update Notifier) does – it updates &lt;strong&gt;nothing&lt;/strong&gt;, it only watches and notifies. Give it its own 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;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/diun &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; ~/diun
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &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;diun&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;crazymax/diun:4.33.0&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;serve&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="s2"&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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DIUN_WATCH_SCHEDULE=0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;8&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;          &lt;span class="c1"&gt;# daily at 8 a.m.&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DIUN_PROVIDERS_DOCKER=true"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DIUN_PROVIDERS_DOCKER_WATCHBYDEFAULT=false"&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;./data:/data&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/run/docker.sock:/var/run/docker.sock:ro&lt;/span&gt;
    &lt;span class="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;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;DIUN_PROVIDERS_DOCKER=true&lt;/code&gt;&lt;/strong&gt; lets Diun discover your running containers via the Docker socket.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;WATCHBYDEFAULT=false&lt;/code&gt;&lt;/strong&gt; means Diun only watches containers you &lt;strong&gt;explicitly&lt;/strong&gt; mark – so you don't get a flood about things that don't interest you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;DIUN_WATCH_SCHEDULE&lt;/code&gt;&lt;/strong&gt; is a cron expression; once a day is plenty and spares the registry rate limits.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠️ The Docker socket is not a harmless read access&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We mount the socket here with &lt;code&gt;:ro&lt;/code&gt;, and that sounds reassuring – but it's only half true. The &lt;code&gt;:ro&lt;/code&gt; merely prevents the &lt;em&gt;file&lt;/em&gt; &lt;code&gt;docker.sock&lt;/code&gt; from being overwritten; through the Docker API behind it you can still do anything: start containers with &lt;code&gt;--privileged&lt;/code&gt;, mount the host filesystem, in short &lt;strong&gt;root on the host&lt;/strong&gt;. Whoever has access to the socket is practically root-equivalent – regardless of the &lt;code&gt;:ro&lt;/code&gt;. For a pure notifier like Diun this is a deliberately accepted risk; if you want to defuse it, don't attach Diun directly to the socket, but to an upstream &lt;strong&gt;&lt;a href="https://github.com/Tecnativa/docker-socket-proxy" rel="noopener noreferrer"&gt;docker-socket-proxy&lt;/a&gt;&lt;/strong&gt; that only passes through the few endpoints (list containers, read images) Diun really needs.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Mark the containers Diun should keep an eye on via a label in &lt;strong&gt;their&lt;/strong&gt; &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;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;diun.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;diun.watch_repo=true"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;diun.enable=true&lt;/code&gt; switches on monitoring. &lt;code&gt;diun.watch_repo=true&lt;/code&gt; lets Diun search the &lt;strong&gt;whole repository&lt;/strong&gt; for newer tags (e.g. whether a &lt;code&gt;19&lt;/code&gt; already exists for your &lt;code&gt;postgres:18&lt;/code&gt;) – without this label, Diun only checks whether your &lt;em&gt;exact&lt;/em&gt; tag got a new image. Start Diun and watch the first run:&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="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt; diun
&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;INF Found 1 image(s) to analyze provider=docker
INF New image found      image=docker.io/traefik/whoami:v1.11.0 provider=docker
INF New image found      image=docker.io/traefik/whoami:v1.12.0 provider=docker
INF New image found      image=docker.io/traefik/whoami:latest-arm64 provider=docker
INF Jobs completed       added=97 failed=0 skipped=0 unchanged=0 updated=0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;strong&gt;first&lt;/strong&gt; run is just the inventory: Diun writes every tag it finds into its database as "New image found" (&lt;code&gt;added&lt;/code&gt;). That &lt;strong&gt;one&lt;/strong&gt; watched container yields 97 entries is down to &lt;code&gt;watch_repo&lt;/code&gt; – Diun then fetches the manifest for &lt;em&gt;every&lt;/em&gt; tag in the repository, including architecture variants like &lt;code&gt;latest-arm64&lt;/code&gt; or &lt;code&gt;v1.10.0-armv7&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two things follow from this that are easy to get wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The first run does not notify.&lt;/strong&gt; The option &lt;code&gt;watch.firstCheckNotif&lt;/code&gt; defaults to &lt;code&gt;false&lt;/code&gt;, and that applies per image tag. Everything Diun sees for the first time lands in the database silently. It only reports once something &lt;strong&gt;changes&lt;/strong&gt; later on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;watch_repo&lt;/code&gt; costs registry requests.&lt;/strong&gt; 97 manifest queries per run almost blow the Docker Hub limit without a login (100 requests per IPv4 address or IPv6 &lt;code&gt;/64&lt;/code&gt; subnet) on their own. On the second run of the same day our test ran straight into it – &lt;code&gt;StatusCode: 429&lt;/code&gt; and &lt;code&gt;failed=36&lt;/code&gt; in the log. How much budget is left – and which window the registry is currently counting in – it tells you itself:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  &lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://auth.docker.io/token?service=registry.docker.io&amp;amp;scope=repository:ratelimitpreview/test:pull"&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="s1"&gt;'"'&lt;/span&gt; &lt;span class="nt"&gt;-f4&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  curl &lt;span class="nt"&gt;-sI&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://registry-1.docker.io/v2/ratelimitpreview/test/manifests/latest | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"^ratelimit"&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;  ratelimit-limit: 100;w=3600
  ratelimit-remaining: 98;w=3600
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;w&lt;/code&gt; is the window width in seconds. Docker &lt;strong&gt;documents&lt;/strong&gt; a six-hour window; our test VPS consistently got &lt;code&gt;w=3600&lt;/code&gt;, i.e. one hour, in August 2026. So rely on the header rather than on a remembered number – and note that this query itself costs a request. Beyond that, narrow the tags down to what you actually care about – real release tags:&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;diun.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;diun.watch_repo=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;diun.include_tags=^v1&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;.1[12]&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;d+$$"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;diun.sort_tags=semver"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;include_tags&lt;/code&gt; is a regular expression. Two pitfalls hide in the notation: the backslashes have to be &lt;strong&gt;doubled&lt;/strong&gt; in YAML, and the trailing &lt;code&gt;$&lt;/code&gt; is written as &lt;code&gt;$$&lt;/code&gt; – otherwise Compose tries to substitute an environment variable. With this label, exactly the two real release tags remain out of the 97 entries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;INF Found 1 image(s) to analyze provider=docker
INF New image found      image=docker.io/traefik/whoami:v1.11.0 provider=docker
INF New image found      image=docker.io/traefik/whoami:v1.12.0 provider=docker
INF Jobs completed       added=2 failed=0 skipped=0 unchanged=0 updated=0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So that the notice doesn't just sit in the log, you attach a &lt;strong&gt;notification&lt;/strong&gt;. Diun can do email, Telegram, ntfy, Gotify and many more. For Telegram – you may already have set up the bot in the &lt;a href="https://serverkueche.de/en/tutorials/uptime-kuma-monitoring/" rel="noopener noreferrer"&gt;Uptime Kuma tutorial&lt;/a&gt; – two lines in the &lt;code&gt;environment&lt;/code&gt; block are enough:&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;DIUN_NOTIF_TELEGRAM_TOKEN=YOUR_BOT_TOKEN"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DIUN_NOTIF_TELEGRAM_CHATIDS=YOUR_CHAT_ID"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All other channels are in the &lt;a href="https://crazymax.dev/diun/config/notif/" rel="noopener noreferrer"&gt;Diun documentation&lt;/a&gt;. This is what the notice looks like – here via&lt;br&gt;
&lt;a href="https://serverkueche.de/en/tutorials/ntfy-push-notifications/" rel="noopener noreferrer"&gt;ntfy&lt;/a&gt; in the browser, as soon as a newer version appears for one of your pinned images:&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%2Fka983yd83a3yni22u0xt.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%2Fka983yd83a3yni22u0xt.png" alt="The ntfy web interface shows a Diun notification titled " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 Test the notification without waiting for weeks&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because the first run deliberately stays silent, you don't know after setting things up whether the notification path works at all – in the worst case you find out only after missing an important notice. So set &lt;code&gt;DIUN_WATCH_FIRSTCHECKNOTIF=true&lt;/code&gt; in Diun's &lt;code&gt;environment&lt;/code&gt; &lt;strong&gt;once&lt;/strong&gt;, delete &lt;code&gt;./data&lt;/code&gt; and restart: then Diun sends notifications for the inventory too – exactly one per tag found. Channel verified, remove the variable again afterwards (otherwise the next &lt;code&gt;watch_repo&lt;/code&gt; run floods your inbox).&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;
  
  
  Step 3: Apply an update safely (the routine)
&lt;/h3&gt;

&lt;p&gt;Diun reports an update – now comes the actual discipline. &lt;strong&gt;Never&lt;/strong&gt; just pull blindly. The fixed order for every service:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Read the release notes.&lt;/strong&gt; On a major jump (e.g. Nextcloud 34 → 35) this is mandatory: are there breaking changes, migration steps, removed options? Two minutes here save hours of debugging.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Back up first.&lt;/strong&gt; An update is the classic moment for something to break. Make a &lt;a href="https://serverkueche.de/en/tutorials/restic-backups/" rel="noopener noreferrer"&gt;Restic backup&lt;/a&gt; of the data (for big jumps, additionally a &lt;a href="https://serverkueche.de/en/tutorials/netcup-snapshots-scp/" rel="noopener noreferrer"&gt;netcup snapshot&lt;/a&gt;). Then a failure is only a setback, not data loss.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Bump the tag and pull.&lt;/strong&gt; Set the new tag in the &lt;code&gt;compose.yaml&lt;/code&gt; and fetch the image:&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 pull
&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; Image traefik/whoami:v1.12.0 Pulled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. Restart.&lt;/strong&gt; Compose only replaces the affected container, the volumes stay:&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Container diun-demo-whoami Recreated
 Container diun-demo-whoami Started
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;5. Verify.&lt;/strong&gt; Does the container run cleanly (&lt;code&gt;docker compose ps&lt;/code&gt;, &lt;code&gt;docker compose logs&lt;/code&gt;), and does the app still do what it should? Stateful apps may now run a database migration – take a look at the log until it's through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Have a rollback in mind.&lt;/strong&gt; If something goes wrong, set the tag in the &lt;code&gt;compose.yaml&lt;/code&gt; back to the old version and do &lt;code&gt;docker compose up -d&lt;/code&gt; again. Because your data lives in the &lt;strong&gt;volume&lt;/strong&gt; (not in the container), for most apps this is a clean step back. Only if the new version has &lt;strong&gt;already migrated the database&lt;/strong&gt; does a tag rollback no longer help – then you need the backup from step 2. That's exactly what it's for.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 One app at a time&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Don't update the whole stack at once. Go service by service and check each time that everything runs before moving to the next. If something breaks then, you immediately know which update was to blame.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 4: Don't forget the base – host, engine, hidden images
&lt;/h3&gt;

&lt;p&gt;Your apps are only half the battle. Equally important:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Operating system &amp;amp; Docker engine.&lt;/strong&gt; The OS and security packages of Debian you best install &lt;a href="https://serverkueche.de/en/tutorials/unattended-upgrades-automatic-updates/" rel="noopener noreferrer"&gt;automatically with unattended-upgrades&lt;/a&gt;. The &lt;strong&gt;Docker engine is deliberately left out&lt;/strong&gt; of that: unattended-upgrades by default only pulls the Debian sources (&lt;code&gt;origin=Debian&lt;/code&gt;), &lt;strong&gt;not&lt;/strong&gt; the Docker APT repo (&lt;code&gt;origin=Docker&lt;/code&gt;, &lt;code&gt;download.docker.com&lt;/code&gt;). So the engine isn't updated in the background and won't unexpectedly restart all your containers at night – because an engine update briefly &lt;strong&gt;takes running containers down&lt;/strong&gt;. You'd better determine that moment yourself and update the engine specifically by hand when it fits:
&lt;/li&gt;
&lt;/ul&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="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt upgrade 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;Afterwards check with &lt;code&gt;docker compose ps&lt;/code&gt; that all stacks are up again.&lt;/p&gt;

&lt;p&gt;If a brief container restart at night doesn't bother you, you can also let the engine come along &lt;strong&gt;with&lt;/strong&gt; unattended-upgrades: allow the Docker repo in &lt;code&gt;/etc/apt/apt.conf.d/50unattended-upgrades&lt;/code&gt; as an additional origins pattern. What matters is &lt;code&gt;archive=&lt;/code&gt; – the Docker repo sets no &lt;code&gt;Codename&lt;/code&gt; field, the often-recommended &lt;code&gt;codename=${distro_codename}&lt;/code&gt; would run into the void and Docker would stay out despite the entry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;  &lt;span class="err"&gt;"&lt;/span&gt;&lt;span class="py"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Docker,archive=${distro_codename}";&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hidden images.&lt;/strong&gt; Many stacks contain databases, caches and helper services (&lt;code&gt;postgres&lt;/code&gt;, &lt;code&gt;redis&lt;/code&gt;, &lt;code&gt;mariadb&lt;/code&gt;, &lt;code&gt;gotenberg&lt;/code&gt; …) that nobody thinks of. Diun watches them automatically as soon as the respective container carries the &lt;code&gt;diun.enable&lt;/code&gt; label – so give it to &lt;strong&gt;all&lt;/strong&gt; long-lived services, not just the visible main app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extensions of your reverse proxy.&lt;/strong&gt; Pinned plugin versions too (e.g. the &lt;a href="https://serverkueche.de/en/tutorials/crowdsec-setup/" rel="noopener noreferrer"&gt;CrowdSec bouncer&lt;/a&gt;) or Traefik itself want to be updated deliberately.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 5: Clean up – reclaim storage
&lt;/h3&gt;

&lt;p&gt;Every update leaves the &lt;strong&gt;old&lt;/strong&gt; image behind. After a few months this adds up. What you're using is shown by:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker system &lt;span class="nb"&gt;df&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;TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images          26        4         14.75GB   14.16GB (95%)
Containers      4         4         45.06kB   0B (0%)
Local Volumes   0         0         0B        0B
Build Cache     36        0         828.2MB   828.2MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unused, &lt;strong&gt;untagged&lt;/strong&gt; ("dangling") images are removed by the safe default command:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Careful: this only removes dangling images. An &lt;strong&gt;old, still tagged&lt;/strong&gt; version (like &lt;code&gt;whoami:v1.11.0&lt;/code&gt; after the update to &lt;code&gt;v1.12.0&lt;/code&gt;) doesn't count as dangling and stays behind. You reclaim that specifically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker rmi traefik/whoami:v1.11.0
&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;Untagged: traefik/whoami:v1.11.0
Deleted: sha256:200689790a0a0ea48ca45992e0450bc26ccab5307375b41c84dfc4f2475937ab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whoever wants to clean up more radically uses &lt;code&gt;docker image prune -a&lt;/code&gt; (removes &lt;strong&gt;all&lt;/strong&gt; images no running container currently uses). That's powerful, but pulls everything anew on the next start – use it deliberately, not in cron.&lt;/p&gt;

&lt;p&gt;The safe &lt;code&gt;docker image prune -f&lt;/code&gt; (dangling only), on the other hand, you can run regularly without worry – e.g. weekly via a systemd timer or cron. That way no junk piles up between your update sessions in the first place, and you reclaim the storage automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6: Auto-update – when it's okay (and when not)
&lt;/h3&gt;

&lt;p&gt;The question remains: why not everything fully automatic? The best-known tool for that is &lt;strong&gt;Watchtower&lt;/strong&gt; – it pulls new images and restarts containers on its own. Two catches:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The upstream is orphaned.&lt;/strong&gt; The official &lt;code&gt;containrrr/watchtower&lt;/code&gt; hasn't had a release since &lt;strong&gt;v1.7.1 (2023-11-11)&lt;/strong&gt; – and the repository was &lt;strong&gt;archived at the end of 2025 (2025-12-17)&lt;/strong&gt;, i.e. mothballed for good. Whoever uses it better reaches for a maintained fork like &lt;code&gt;nickfedor/watchtower&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto-update is dangerous for stateful apps.&lt;/strong&gt; Lifting a database or Nextcloud over a major jump unattended at night is the opposite of "safe".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Auto-update makes sense at most for &lt;strong&gt;uncritical, stateless&lt;/strong&gt; services – and even then rather in &lt;strong&gt;monitor mode&lt;/strong&gt; (&lt;code&gt;WATCHTOWER_MONITOR_ONLY=true&lt;/code&gt;), which only reports instead of updating. For everything else, the deliberate routine from step 3 beats any automation tool. That's exactly why &lt;strong&gt;Diun (notify) + manual update&lt;/strong&gt; is the recommendation here, not Watchtower (act).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 Safe automation for Git users: Renovate&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If your &lt;code&gt;compose.yaml&lt;/code&gt; files live in a Git repo, there's a third way that combines automation and control: &lt;strong&gt;Renovate&lt;/strong&gt; (or Dependabot) detects the pinned &lt;code&gt;image:&lt;/code&gt; tags and automatically opens a &lt;strong&gt;pull request&lt;/strong&gt; that bumps the tag – complete with a link to the release notes. So the update doesn't happen secretly on the server, but as a &lt;strong&gt;change you review and merge&lt;/strong&gt; before rolling it out with &lt;code&gt;docker compose up -d&lt;/code&gt;. That way the safe "apply deliberately" step is preserved, only the tedious checking is gone.&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;docker compose pull&lt;/code&gt; pulls no new image, even though a new version exists.&lt;/strong&gt; Your tag points to a fixed version (&lt;code&gt;:1.37.2&lt;/code&gt;) or to a major tag (&lt;code&gt;:18&lt;/code&gt;), under which there'd only be a new &lt;em&gt;major&lt;/em&gt;. &lt;code&gt;pull&lt;/code&gt; only fetches what the &lt;strong&gt;same tag&lt;/strong&gt; now points to. For a version jump you have to bump the tag in the &lt;code&gt;compose.yaml&lt;/code&gt; yourself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The app no longer starts after the update or throws database errors.&lt;/strong&gt; Usually a breaking change or a failed migration. Check &lt;code&gt;docker compose logs&lt;/code&gt;, compare with the release notes. Set the tag back to the old version and &lt;code&gt;up -d&lt;/code&gt;; if the new version already migrated the data, restore the backup from step 3.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Diun reports nothing, even though updates exist.&lt;/strong&gt; Check that the containers carry the label &lt;code&gt;diun.enable=true&lt;/code&gt; and Diun can read the socket (&lt;code&gt;DIUN_PROVIDERS_DOCKER=true&lt;/code&gt;, socket mounted). For newer &lt;strong&gt;version tags&lt;/strong&gt; the container additionally needs &lt;code&gt;diun.watch_repo=true&lt;/code&gt; – without it, Diun only sees changes to the exactly pinned tag. And: whatever Diun sees for the &lt;strong&gt;first&lt;/strong&gt; time is only written to the database, not reported (&lt;code&gt;watch.firstCheckNotif&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt;) – to test the notification path, set &lt;code&gt;DIUN_WATCH_FIRSTCHECKNOTIF=true&lt;/code&gt; once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The disk fills up, even though you regularly run &lt;code&gt;docker image prune&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;docker image prune&lt;/code&gt; only clears dangling images, not the old &lt;strong&gt;tagged&lt;/strong&gt; versions. Remove them specifically with &lt;code&gt;docker rmi &amp;lt;image&amp;gt;:&amp;lt;tag&amp;gt;&lt;/code&gt; or – with care – &lt;code&gt;docker image prune -a&lt;/code&gt;. The build cache (&lt;code&gt;docker builder prune&lt;/code&gt;) can grow too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On pulling you get &lt;code&gt;toomanyrequests&lt;/code&gt; / a Docker Hub rate limit.&lt;/strong&gt; &lt;code&gt;diun.watch_repo=true&lt;/code&gt; on many images queries many tags. Set the watch interval less often (e.g. once daily) and narrow with &lt;code&gt;diun.include_tags&lt;/code&gt; to relevant versions instead of scanning the whole repo.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The rhythm.&lt;/strong&gt; Diun reports → you read the release notes → backup → update → verify. In practice that's a manageable appointment &lt;strong&gt;once a month&lt;/strong&gt;; critical security holes you patch immediately. Honestly: safe self-hosting doesn't work entirely without manual work – but 15 minutes a month is the deal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fast movers first.&lt;/strong&gt; Some projects release frequently and with breaking changes – &lt;a href="https://serverkueche.de/en/tutorials/self-host-immich-photos/" rel="noopener noreferrer"&gt;Immich&lt;/a&gt;, mailcow, &lt;a href="https://serverkueche.de/en/tutorials/crowdsec-setup/" rel="noopener noreferrer"&gt;CrowdSec&lt;/a&gt;, &lt;a href="https://serverkueche.de/en/tutorials/monitoring-grafana-prometheus/" rel="noopener noreferrer"&gt;Grafana&lt;/a&gt; and &lt;a href="https://serverkueche.de/en/tutorials/self-host-nextcloud/" rel="noopener noreferrer"&gt;Nextcloud&lt;/a&gt;. Those you look at first and more often, quiet candidates (databases, Redis) less so.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backups are the core of this recipe.&lt;/strong&gt; An update without a backup is a gamble; with &lt;a href="https://serverkueche.de/en/tutorials/restic-backups/" rel="noopener noreferrer"&gt;Restic&lt;/a&gt; at your back, every update becomes relaxed. Before big jumps, additionally a &lt;a href="https://serverkueche.de/en/tutorials/netcup-snapshots-scp/" rel="noopener noreferrer"&gt;snapshot&lt;/a&gt; – it brings the whole server back in one go.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version your &lt;code&gt;compose.yaml&lt;/code&gt; files.&lt;/strong&gt; Whoever keeps their Compose files in a Git repo can roll back not only data but also the &lt;strong&gt;configuration&lt;/strong&gt; to any earlier state – and sees in the history exactly which tag was bumped when.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the tools up to date too.&lt;/strong&gt; Diun itself and your reverse-proxy plugins belong in the update round as well – otherwise the maintainer doesn't maintain itself.&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/keep-docker-stack-updated/" 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>
    </item>
    <item>
      <title>Docker volumes vs. bind mounts: where your data really lives</title>
      <dc:creator>serverkueche.de</dc:creator>
      <pubDate>Sun, 20 Sep 2026 17:52:43 +0000</pubDate>
      <link>https://dev.to/serverkueche/docker-volumes-vs-bind-mounts-where-your-data-really-lives-58h7</link>
      <guid>https://dev.to/serverkueche/docker-volumes-vs-bind-mounts-where-your-data-really-lives-58h7</guid>
      <description>&lt;p&gt;A container is ephemeral: delete it, and its data is gone. For your database, your Nextcloud files or your configuration to survive a &lt;code&gt;docker compose down&lt;/code&gt;, you need volumes. This guide clears up the often confusing difference between &lt;strong&gt;named volumes&lt;/strong&gt; and &lt;strong&gt;bind mounts&lt;/strong&gt;.&lt;/p&gt;

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

&lt;p&gt;No new service this time, but a solid foundation: by the end you understand why container data normally disappears, know the two ways to store it permanently, and know for each service which to use. All examples are tested with &lt;strong&gt;Docker 29&lt;/strong&gt; on Debian 13 and work with any current Docker version. You need this knowledge for every app tutorial – there, a &lt;code&gt;volumes:&lt;/code&gt; block appears in almost every &lt;code&gt;compose.yaml&lt;/code&gt;.&lt;/p&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/install-docker/" rel="noopener noreferrer"&gt;Docker installed&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Basic understanding of &lt;a href="https://serverkueche.de/en/tutorials/docker-compose-basics/" rel="noopener noreferrer"&gt;Docker Compose&lt;/a&gt; (services, &lt;code&gt;compose.yaml&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Terminal access to the server – a pure command-line exercise, no web interface&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  Step 1: Why container data disappears
&lt;/h3&gt;

&lt;p&gt;Everything a container writes into its own filesystem only lives as long as the container. That's intended – containers are meant to be replaceable. For anything that should persist (databases, uploads, configuration), you have to tell Docker explicitly where it lands &lt;em&gt;outside&lt;/em&gt; the container. That's exactly what the two tools are for: &lt;strong&gt;named volumes&lt;/strong&gt; and &lt;strong&gt;bind mounts&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Named volumes – managed by Docker
&lt;/h3&gt;

&lt;p&gt;A named volume is a storage area that &lt;strong&gt;Docker itself&lt;/strong&gt; manages. You only give it a name; Docker determines the location. Create one:&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 create sk-demo-vol
&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;sk-demo-vol
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now we mount the volume into a throwaway container (&lt;code&gt;--rm&lt;/code&gt; deletes it afterwards) at &lt;code&gt;/data&lt;/code&gt; and write a file into it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; sk-demo-vol:/data alpine:3 sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"echo hallo-von-serverkueche &amp;gt; /data/notiz.txt &amp;amp;&amp;amp; ls -l /data"&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;total 4
-rw-r--r--    1 root     root            23 Jul 20 22:31 notiz.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The container is long gone – the data isn't. Where does it live? Ask Docker:&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 inspect sk-demo-vol &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s2"&gt;"{{ .Mountpoint }}"&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;/var/lib/docker/volumes/sk-demo-vol/_data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This path belongs to Docker. You &lt;em&gt;can&lt;/em&gt; view it as root, but shouldn't edit it directly:&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;cat&lt;/span&gt; /var/lib/docker/volumes/sk-demo-vol/_data/notiz.txt
&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;hallo-von-serverkueche
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The proof of persistence: a completely new container sees the same data:&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; &lt;span class="nt"&gt;-v&lt;/span&gt; sk-demo-vol:/data alpine:3 &lt;span class="nb"&gt;cat&lt;/span&gt; /data/notiz.txt
&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;hallo-von-serverkueche
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Remember:&lt;/strong&gt; named volumes are the default for application data – databases, uploads, everything the app manages itself. Docker takes care of location and permissions, and the volume survives &lt;code&gt;docker compose down&lt;/code&gt; (only &lt;code&gt;down -v&lt;/code&gt; deletes it too).&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Bind mounts – a folder from the host
&lt;/h3&gt;

&lt;p&gt;With a bind mount you mount a &lt;strong&gt;specific folder from your server&lt;/strong&gt; into the container. You determine the path, and changes are immediately visible on both sides:&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/sk-demo-bind
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; /opt/sk-demo-bind:/data alpine:3 sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"echo aus-dem-container &amp;gt; /data/host.txt"&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; /opt/sk-demo-bind
&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;total 4
-rw-r--r-- 1 root root 18 Jul 21 00:31 host.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The container wrote the file, and it lives directly in your host folder – without the detour through &lt;code&gt;/var/lib/docker&lt;/code&gt;. That's the point of bind mounts: &lt;strong&gt;files you want to edit yourself.&lt;/strong&gt; Classic cases are configuration files (&lt;code&gt;traefik.yml&lt;/code&gt;, &lt;code&gt;nginx.conf&lt;/code&gt;) or a &lt;code&gt;compose.yaml&lt;/code&gt; that reads in a config.&lt;/p&gt;

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

&lt;p&gt;Rule of thumb: &lt;strong&gt;named volume for data the app manages&lt;/strong&gt; (database, uploads). &lt;strong&gt;Bind mount for files you manage&lt;/strong&gt; (configuration). When in doubt, named volume – it causes the fewest permission problems.&lt;/p&gt;

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

&lt;p&gt;Never blindly mount sensitive host paths into a container: not &lt;code&gt;/&lt;/code&gt;, not &lt;code&gt;/etc&lt;/code&gt;, no home directories – and especially not &lt;code&gt;/var/run/docker.sock&lt;/code&gt;. Anyone who gets the Docker socket mounted can start arbitrary containers and is thus effectively root on the entire host. Always mount only the one folder the service really needs.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 4: Both in the &lt;code&gt;compose.yaml&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;In Compose the difference looks like this. A named volume is declared at the bottom under &lt;code&gt;volumes:&lt;/code&gt; and referenced by name above; a bind mount is simply a host path:&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&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;app-daten:/var/lib/app&lt;/span&gt;                &lt;span class="c1"&gt;# named volume (Docker-managed)&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./config.yml:/etc/app/config.yml:ro&lt;/span&gt;   &lt;span class="c1"&gt;# bind mount (your file, read-only)&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;app-daten&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;:ro&lt;/code&gt; at the end makes the bind mount &lt;strong&gt;read-only&lt;/strong&gt; – the container can read the configuration but not change it. For mounted configs that's a good habit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: The trap – anonymous volumes
&lt;/h3&gt;

&lt;p&gt;If you leave out the name with &lt;code&gt;-v&lt;/code&gt; (&lt;code&gt;-v /data&lt;/code&gt; instead of &lt;code&gt;-v name:/data&lt;/code&gt;), or an image brings a &lt;code&gt;VOLUME&lt;/code&gt; instruction in its Dockerfile, an &lt;strong&gt;anonymous volume&lt;/strong&gt; with a random ID is created. This is what the first case looks like:&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;--name&lt;/span&gt; demo &lt;span class="nt"&gt;-v&lt;/span&gt; /data alpine:3 sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"echo test &amp;gt; /data/x.txt"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We deliberately run the container here without &lt;code&gt;--rm&lt;/code&gt; – otherwise Docker would immediately delete the anonymous volume on exit. The everyday problem: such volumes stick around, pile up unnoticed, and you can no longer figure out later which data belongs where:&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     9f8c1a2b3c4d5e6f70819a0b1c2d3e4f5061a2b3c4d5e6f70819a0b1c2d3e4f5
local     sk-demo-vol
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cryptic line is an anonymous volume. You can now remove the throwaway container – the anonymous volume &lt;strong&gt;still&lt;/strong&gt; stays behind:&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;rm &lt;/span&gt;demo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Always give your volumes a name&lt;/strong&gt; – then it stays clear what belongs to what.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6: Managing volumes
&lt;/h3&gt;

&lt;p&gt;The most important everyday commands:&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;span class="c"&gt;# list all volumes&lt;/span&gt;
docker volume inspect NAME &lt;span class="c"&gt;# details, including the mountpoint&lt;/span&gt;
docker volume &lt;span class="nb"&gt;rm &lt;/span&gt;NAME      &lt;span class="c"&gt;# delete a volume (data gone!)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We save cleaning up our demo until after the backup section – we still need &lt;code&gt;sk-demo-vol&lt;/code&gt; there.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;The container writes, but the bind-mount folder on the host stays empty.&lt;/strong&gt; You used a relative path that points somewhere other than intended, or Docker created the path anew as an empty folder. with &lt;code&gt;docker run&lt;/code&gt;, always give bind mounts an &lt;strong&gt;absolute path&lt;/strong&gt; (&lt;code&gt;/opt/app/config&lt;/code&gt;, not &lt;code&gt;config&lt;/code&gt;). If this host path doesn't exist yet, Docker silently creates it as an empty folder – so check with &lt;code&gt;ls&lt;/code&gt; that you really hit the right one. In the &lt;code&gt;compose.yaml&lt;/code&gt;, &lt;code&gt;./&lt;/code&gt; paths are perfectly fine, on the other hand – they're relative to the &lt;code&gt;compose.yaml&lt;/code&gt; and thus unambiguous. Note also: &lt;code&gt;docker run -v config:/data&lt;/code&gt; (without &lt;code&gt;/&lt;/code&gt; or &lt;code&gt;./&lt;/code&gt; in front) is &lt;strong&gt;not&lt;/strong&gt; a bind mount, but creates a named volume called &lt;code&gt;config&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Permission denied&lt;/code&gt; as soon as the container tries to write into the mount.&lt;/strong&gt; The process in the container runs under a different UID than the owner of the host folder – typical with bind mounts. with named volumes this rarely happens (Docker sets the permissions). With bind mounts, give the folder to the matching user (&lt;code&gt;chown -R 1000:1000 /opt/app/data&lt;/code&gt;) or use the &lt;code&gt;user:&lt;/code&gt; setting of the Compose in the image.&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; You used &lt;code&gt;docker compose down -v&lt;/code&gt; – the &lt;code&gt;-v&lt;/code&gt; deletes the named volumes too. for a normal restart, work &lt;strong&gt;without&lt;/strong&gt; &lt;code&gt;-v&lt;/code&gt;. Use &lt;code&gt;-v&lt;/code&gt; deliberately only when you really want to start from scratch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;docker system prune&lt;/code&gt; deleted data.&lt;/strong&gt; &lt;code&gt;docker volume prune&lt;/code&gt; or &lt;code&gt;docker system prune --volumes&lt;/code&gt; removes volumes that no container is currently attached to. With &lt;code&gt;docker volume prune&lt;/code&gt;, &lt;strong&gt;named&lt;/strong&gt; volumes are protected by default – only &lt;strong&gt;anonymous&lt;/strong&gt; volumes are deleted; named ones only go with &lt;code&gt;--all&lt;/code&gt;/&lt;code&gt;-a&lt;/code&gt;. (With &lt;code&gt;docker system prune&lt;/code&gt;, on the other hand, &lt;code&gt;-a&lt;/code&gt;/&lt;code&gt;--all&lt;/code&gt; controls the images, not the named volumes.) another strong argument for naming – an &lt;code&gt;sk-demo-vol&lt;/code&gt; survives an accidental &lt;code&gt;docker volume prune&lt;/code&gt;, an anonymous volume doesn't. Still, only run &lt;code&gt;prune&lt;/code&gt; with volumes when all important stacks are active, or remove specifically with &lt;code&gt;docker volume rm&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;Volumes are not a backup – they live on the &lt;strong&gt;same&lt;/strong&gt; disk as your server. If it fails, container &lt;em&gt;and&lt;/em&gt; volume are gone. You back up a named volume by packing its contents into an archive via a throwaway container:&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/backups
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; sk-demo-vol:/data &lt;span class="nt"&gt;-v&lt;/span&gt; /opt/backups:/backup alpine:3 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;tar &lt;/span&gt;czf /backup/sk-demo-vol.tar.gz &lt;span class="nt"&gt;-C&lt;/span&gt; /data &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restoring works the same way around – archive in, unpack into the volume. Create the target volume beforehand (&lt;code&gt;docker volume create sk-demo-vol&lt;/code&gt;) and stop the associated container so nothing writes into the open volume:&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; &lt;span class="nt"&gt;-v&lt;/span&gt; sk-demo-vol:/data &lt;span class="nt"&gt;-v&lt;/span&gt; /opt/backups:/backup alpine:3 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;tar &lt;/span&gt;xzf /backup/sk-demo-vol.tar.gz &lt;span class="nt"&gt;-C&lt;/span&gt; /data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This archive – and all bind-mount folders under &lt;code&gt;/opt&lt;/code&gt; – belong in your &lt;a href="https://serverkueche.de/en/tutorials/restic-backups/" rel="noopener noreferrer"&gt;encrypted off-site backup with Restic&lt;/a&gt;. That way your database survives even a total failure of the server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In everyday use&lt;/strong&gt; it pays to stay tidy: name volumes descriptively (&lt;code&gt;nextcloud-db&lt;/code&gt;, not &lt;code&gt;db&lt;/code&gt;), clean up anonymous volumes occasionally, and check with &lt;code&gt;docker system df&lt;/code&gt; how much space your volumes take up – with databases and media services in particular, this grows noticeably over time.&lt;/p&gt;

&lt;p&gt;Finally, tear down the demo again – now that backup and all examples are done:&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;rm &lt;/span&gt;sk-demo-vol &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /opt/sk-demo-bind /opt/backups
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






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

</description>
      <category>docker</category>
      <category>selfhosted</category>
      <category>linux</category>
    </item>
    <item>
      <title>Self-hosting Nextcloud: your own cloud behind Traefik</title>
      <dc:creator>serverkueche.de</dc:creator>
      <pubDate>Sat, 25 Jul 2026 12:16:37 +0000</pubDate>
      <link>https://dev.to/serverkueche/self-hosting-nextcloud-your-own-cloud-behind-traefik-9hb</link>
      <guid>https://dev.to/serverkueche/self-hosting-nextcloud-your-own-cloud-behind-traefik-9hb</guid>
      <description>&lt;p&gt;Your files, your calendar, your contacts – but on your server instead of at a cloud corporation. &lt;strong&gt;Nextcloud&lt;/strong&gt; is the flagship of self-hosting: a full-featured cloud you run yourself. In this recipe we set it up cleanly behind Traefik, with its own database, caching and automatic HTTPS.&lt;/p&gt;

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

&lt;p&gt;By the end, &lt;strong&gt;Nextcloud 34.0.3&lt;/strong&gt; runs behind your Traefik proxy, reachable at &lt;code&gt;https://cloud.YOUR_DOMAIN&lt;/code&gt; with a valid Let's Encrypt certificate. Nextcloud is your private cloud: sync files (like Dropbox), calendar and contacts across all devices, photos, notes, office documents. You connect the official &lt;strong&gt;desktop sync client&lt;/strong&gt; and the &lt;strong&gt;phone apps&lt;/strong&gt; with your server – the data lives exclusively with you.&lt;/p&gt;

&lt;p&gt;We deliberately use the &lt;strong&gt;classic &lt;code&gt;nextcloud&lt;/code&gt; image&lt;/strong&gt; (not "Nextcloud All-in-One") plus &lt;strong&gt;MariaDB&lt;/strong&gt; as the database and &lt;strong&gt;Redis&lt;/strong&gt; for caching and file locking. This combination is stable, well documented and fits exactly into the Traefik pattern from the Serverküche.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;ℹ️ Why not Nextcloud AIO?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Nextcloud also offers an "All-in-One" package (AIO). But it brings its &lt;strong&gt;own TLS and own ports&lt;/strong&gt; and wants to handle the reverse-proxy part itself – that clashes with an existing Traefik. For our setup, the classic image is the right, controllable choice.&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 shared &lt;code&gt;proxy&lt;/code&gt; network and the Let's Encrypt resolver &lt;code&gt;le&lt;/code&gt; – set up 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;.&lt;/li&gt;
&lt;li&gt;A subdomain &lt;code&gt;cloud.YOUR_DOMAIN&lt;/code&gt; whose DNS record (A/AAAA) points 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;A working &lt;strong&gt;backup&lt;/strong&gt;. A cloud is the place where data loss hurts most – first set up &lt;a href="https://serverkueche.de/en/tutorials/restic-backups/" rel="noopener noreferrer"&gt;backups with Restic&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 How big does the server need to be?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Nextcloud is more frugal than its reputation: for private use or a small family (1–5 users), &lt;strong&gt;2 vCPU and 4 GB RAM&lt;/strong&gt; are quite enough – the tested VPS 1000 fits. The bottleneck is memory: with too little RAM the server starts swapping and gets sluggish. Redis (we build it in shortly) relieves this noticeably. For Nextcloud Office/Collabora or many parallel users you should rather plan for 8 GB.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;How big your server should be for your user count is estimated by the &lt;a href="https://serverkueche.de/en/server-calculator/" rel="noopener noreferrer"&gt;server calculator&lt;/a&gt; in a few clicks.&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;At your DNS provider, create an entry &lt;code&gt;cloud.YOUR_DOMAIN&lt;/code&gt; that points to your server IP (A record for IPv4, AAAA for IPv6). 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 cloud.YOUR_DOMAIN
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see your server IP. Only once the record is set can Traefik fetch the HTTPS certificate later.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Determine the proxy subnet
&lt;/h3&gt;

&lt;p&gt;Nextcloud sits behind Traefik. So that Nextcloud's brute-force protection recognizes the &lt;strong&gt;real&lt;/strong&gt; visitor IP (and doesn't ban the internal Traefik IP), we have to enter Traefik as a "trusted proxy". For that you need the subnet of your &lt;code&gt;proxy&lt;/code&gt; network:&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 inspect proxy &lt;span class="nt"&gt;-f&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;You get something like &lt;code&gt;172.18.0.0/16&lt;/code&gt;. &lt;strong&gt;Remember this value&lt;/strong&gt; – it goes into the configuration as &lt;code&gt;TRUSTED_PROXIES&lt;/code&gt; shortly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Create the compose.yaml
&lt;/h3&gt;

&lt;p&gt;Create a folder and change into 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;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/nextcloud &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; ~/nextcloud
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create the &lt;code&gt;compose.yaml&lt;/code&gt;. Replace &lt;code&gt;cloud.YOUR_DOMAIN&lt;/code&gt;, all passwords and the &lt;code&gt;TRUSTED_PROXIES&lt;/code&gt; subnet from step 2:&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;nc-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.4&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;nc-db&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;--transaction-isolation=READ-COMMITTED --log-bin=binlog --binlog-format=ROW&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_ROOT_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;A_STRONG_ROOT_PASSWORD&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;nextcloud&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;nextcloud&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;A_STRONG_DB_PASSWORD&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;nc_db:/var/lib/mysql&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;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;6&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;default&lt;/span&gt;&lt;span class="pi"&gt;]&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;nc-redis&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;redis:8-alpine&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;nc-redis&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis-server --requirepass A_STRONG_REDIS_PASSWORD&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;default&lt;/span&gt;&lt;span class="pi"&gt;]&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;nc-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;nextcloud:34-apache&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;nc-app&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;nc-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;nc-redis&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_started&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;MYSQL_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nc-db&lt;/span&gt;
      &lt;span class="na"&gt;MYSQL_DATABASE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nextcloud&lt;/span&gt;
      &lt;span class="na"&gt;MYSQL_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nextcloud&lt;/span&gt;
      &lt;span class="na"&gt;MYSQL_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;A_STRONG_DB_PASSWORD&lt;/span&gt;
      &lt;span class="na"&gt;REDIS_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nc-redis&lt;/span&gt;
      &lt;span class="na"&gt;REDIS_HOST_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;A_STRONG_REDIS_PASSWORD&lt;/span&gt;
      &lt;span class="na"&gt;NEXTCLOUD_TRUSTED_DOMAINS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cloud.YOUR_DOMAIN&lt;/span&gt;
      &lt;span class="na"&gt;OVERWRITEPROTOCOL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https&lt;/span&gt;
      &lt;span class="na"&gt;OVERWRITECLIURL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://cloud.YOUR_DOMAIN&lt;/span&gt;
      &lt;span class="na"&gt;TRUSTED_PROXIES&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;172.18.0.0/16&lt;/span&gt;
      &lt;span class="na"&gt;PHP_MEMORY_LIMIT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1024M&lt;/span&gt;
      &lt;span class="na"&gt;PHP_UPLOAD_LIMIT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10G&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;nc_html:/var/www/html&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.nc.rule=Host(`cloud.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.nc.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.nc.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.nc.middlewares=nc-dav"&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.nc.loadbalancer.server.port=80"&lt;/span&gt;
      &lt;span class="c1"&gt;# .well-known redirect for CalDAV/CardDAV (see below)&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-dav.redirectregex.regex=https://(.*)/.well-known/(?:card|cal)dav"&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-dav.redirectregex.replacement=https://cloud.YOUR_DOMAIN/remote.php/dav/"&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-dav.redirectregex.permanent=true"&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;default&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;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;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;nc_html&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;nc_db&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;default&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 matters here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Three containers:&lt;/strong&gt; &lt;code&gt;nc-app&lt;/code&gt; (Nextcloud), &lt;code&gt;nc-db&lt;/code&gt; (MariaDB), &lt;code&gt;nc-redis&lt;/code&gt;. Only &lt;code&gt;nc-app&lt;/code&gt; is on the &lt;strong&gt;&lt;code&gt;proxy&lt;/code&gt; network&lt;/strong&gt; (for Traefik) &lt;em&gt;and&lt;/em&gt; on the internal &lt;code&gt;default&lt;/code&gt; network. Database and Redis stay exclusively internal – they have &lt;strong&gt;no &lt;code&gt;ports:&lt;/code&gt;&lt;/strong&gt; and are not reachable from outside.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two named volumes:&lt;/strong&gt; &lt;code&gt;nc_html&lt;/code&gt; (program code, &lt;code&gt;config.php&lt;/code&gt;, user data) and &lt;code&gt;nc_db&lt;/code&gt; (the MariaDB data). The &lt;code&gt;nc_db&lt;/code&gt; volume is mandatory: without the entry, the database lands in an &lt;strong&gt;anonymous&lt;/strong&gt; volume, and a &lt;code&gt;docker compose down&lt;/code&gt; followed by &lt;code&gt;up&lt;/code&gt; would &lt;strong&gt;lose it irrecoverably&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;healthcheck&lt;/code&gt; + &lt;code&gt;depends_on: service_healthy&lt;/code&gt;:&lt;/strong&gt; Nextcloud starts faster than the database initializes. Without this healthcheck the initial install fails with a "Connection refused". This way the app container waits until MariaDB is really ready.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;NEXTCLOUD_TRUSTED_DOMAINS&lt;/code&gt;:&lt;/strong&gt; without your own domain here, Nextcloud greets you with "Access through untrusted domain".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;OVERWRITEPROTOCOL: https&lt;/code&gt;&lt;/strong&gt; tells Nextcloud it runs behind HTTPS – otherwise it builds internal links as &lt;code&gt;http://&lt;/code&gt; and logins/redirects break.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;TRUSTED_PROXIES&lt;/code&gt;:&lt;/strong&gt; the subnet from step 2. This lets Nextcloud recognize the real client IP; otherwise the brute-force protection bans the Traefik IP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;PHP_MEMORY_LIMIT&lt;/code&gt; / &lt;code&gt;PHP_UPLOAD_LIMIT&lt;/code&gt;:&lt;/strong&gt; the defaults (512 MB) are too small for large uploads. Set generously here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;loadbalancer.server.port=80&lt;/code&gt;:&lt;/strong&gt; the Apache image listens on port 80 in the container.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;nc-dav&lt;/code&gt; middleware&lt;/strong&gt; solves a classic Nextcloud problem – more on that shortly.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠️ CalDAV/CardDAV: the .well-known redirect&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Nextcloud wants to send calendar/contact clients via &lt;code&gt;/.well-known/caldav&lt;/code&gt; and &lt;code&gt;/.well-known/carddav&lt;/code&gt; to &lt;code&gt;/remote.php/dav/&lt;/code&gt;. Behind a reverse proxy, Nextcloud's own redirect doesn't work reliably – this later leads to a warning in the security check and to problems with calendar setup. The &lt;code&gt;nc-dav&lt;/code&gt; middleware above does the redirect directly in Traefik. Important: the pattern must match the &lt;strong&gt;full &lt;code&gt;https://…&lt;/code&gt; URL&lt;/strong&gt; – &lt;code&gt;redirectregex&lt;/code&gt; always checks the complete URL, not just the path. A pure path pattern like &lt;code&gt;^/.well-known/…&lt;/code&gt; therefore never matches.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 4: Start and run through the initial setup
&lt;/h3&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;On the first start, MariaDB initializes the database and Nextcloud unpacks itself – that takes a minute or two. Follow it in the log:&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 logs &lt;span class="nt"&gt;-f&lt;/span&gt; nc-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the first run the image unpacks Nextcloud into the volume; you see lines like &lt;code&gt;Initializing nextcloud 34.0.3.2 ...&lt;/code&gt; and finally &lt;code&gt;Initializing finished&lt;/code&gt;, followed by the Apache start. If &lt;code&gt;restarting&lt;/code&gt; appears repeatedly instead, take a look at the "When things go wrong" section. With &lt;code&gt;Ctrl+C&lt;/code&gt; you leave the log view again (the container keeps running).&lt;/p&gt;

&lt;p&gt;Then open &lt;code&gt;https://cloud.YOUR_DOMAIN&lt;/code&gt;. Traefik fetches the certificate on the first access (can take a few seconds). Because we already configured the database and Redis via environment variables, Nextcloud detects that automatically and only asks for an &lt;strong&gt;administration account&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%2Fhohw6ic9woh82gqzssb9.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%2Fhohw6ic9woh82gqzssb9.png" alt="The Nextcloud initial setup detects the autoconfig and only asks for the name and password of the administration account" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Set an admin name and a strong password and click &lt;strong&gt;Install&lt;/strong&gt;. Nextcloud sets up the instance – after that you land on the login page:&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%2Faeq2l2g1ozvrntp9xeaq.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%2Faeq2l2g1ozvrntp9xeaq.png" alt="The Nextcloud login page under your own HTTPS domain" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After the login, the &lt;strong&gt;dashboard&lt;/strong&gt; greets you, and under &lt;strong&gt;Files&lt;/strong&gt; you find your cloud with a few example files:&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%2Fv8afao4tjrb1oy6c4srb.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%2Fv8afao4tjrb1oy6c4srb.png" alt="The Nextcloud dashboard with the " width="800" height="450"&gt;&lt;/a&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%2Fm22kdjrh3ms8q608amli.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%2Fm22kdjrh3ms8q608amli.png" alt="The Files interface of Nextcloud with folders and example files" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Set up background jobs (cron)
&lt;/h3&gt;

&lt;p&gt;Nextcloud has to perform tasks regularly (cleanup, notifications, thumbnails). By default this happens via "AJAX" on every page load – which is unreliable. The recommended way is a &lt;strong&gt;cron sidecar&lt;/strong&gt;: a second container with the same image that only runs &lt;code&gt;cron.php&lt;/code&gt;. Add a service to the &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;nc-cron&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;nextcloud:34-apache&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;nc-cron&lt;/span&gt;
    &lt;span class="na"&gt;entrypoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/cron.sh&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;nc-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;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;nc_html:/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;default&lt;/span&gt;&lt;span class="pi"&gt;]&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;The sidecar shares the &lt;code&gt;nc_html&lt;/code&gt; volume with the app and runs the jobs every five minutes. Apply the change and set the mode in Nextcloud to "Cron":&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 &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; www-data nc-app php occ background:cron
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under &lt;strong&gt;Administration → Basic settings&lt;/strong&gt;, "Cron" should now be active.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 occ – the command-line tool&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;occ&lt;/code&gt; is Nextcloud's admin tool. You always call it as the user &lt;code&gt;www-data&lt;/code&gt; in the app container: &lt;code&gt;docker exec -u www-data nc-app php occ &amp;lt;command&amp;gt;&lt;/code&gt;. Two useful cleanup commands right after installation:&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 db:add-missing-indices
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 default_phone_region &lt;span class="nt"&gt;--value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;DE
&lt;/code&gt;&lt;/pre&gt;

&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 6: Connect the clients
&lt;/h3&gt;

&lt;p&gt;Now the real benefit. There are three ways to use your cloud:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Desktop sync client&lt;/strong&gt; (Windows/macOS/Linux): install the "Nextcloud Desktop" client, enter &lt;code&gt;https://cloud.YOUR_DOMAIN&lt;/code&gt; as the server address, log in and choose a local folder. Files are synced like with Dropbox. With &lt;strong&gt;virtual files&lt;/strong&gt; (Windows/macOS) they only take up space on the disk when you open them – handy when your cloud is larger than the local disk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phone app&lt;/strong&gt; (Android/iOS): the official Nextcloud app, same server address. Ideal for automatic &lt;strong&gt;photo upload&lt;/strong&gt; from the smartphone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Calendar &amp;amp; contacts:&lt;/strong&gt; in your device's system settings, create a CalDAV/CardDAV account with the address &lt;code&gt;https://cloud.YOUR_DOMAIN&lt;/code&gt; – thanks to the &lt;code&gt;.well-known&lt;/code&gt; middleware from step 3, automatic detection works.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Features like calendar, contacts or mail are separate &lt;strong&gt;apps&lt;/strong&gt; you install with one click under &lt;strong&gt;Administration → Apps&lt;/strong&gt; – so Nextcloud is extensible without you touching anything on the server.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠️ Don't work with the admin account&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The administration account from step 4 is meant for &lt;strong&gt;management&lt;/strong&gt;, not for everyday use. Create a normal user account under &lt;strong&gt;Administration → Accounts&lt;/strong&gt; and sync your files through it. That way your powerful admin account isn't permanently logged in on all devices – a simple but effective security gain. For family or team, create further accounts here; each gets its own encrypted area.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;&lt;strong&gt;"Access through untrusted domain" instead of the login page.&lt;/strong&gt; Your domain isn't in &lt;code&gt;trusted_domains&lt;/code&gt;. Check &lt;code&gt;NEXTCLOUD_TRUSTED_DOMAINS&lt;/code&gt; in the Compose file. To set it afterwards, use occ: &lt;code&gt;docker exec -u www-data nc-app php occ config:system:set trusted_domains 1 --value=cloud.YOUR_DOMAIN&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The security check reports "Your web server is not set up properly to resolve .well-known/caldav".&lt;/strong&gt; The CalDAV/CardDAV redirect isn't working. Check the &lt;code&gt;nc-dav&lt;/code&gt; middleware labels (step 3) and that the router includes them via &lt;code&gt;...routers.nc.middlewares=nc-dav&lt;/code&gt;. The regex must match the full &lt;code&gt;https://…&lt;/code&gt; URL – &lt;code&gt;redirectregex&lt;/code&gt; checks the complete URL, not just the path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Warning "The 'Strict-Transport-Security' HTTP header is not set".&lt;/strong&gt; The HSTS header is missing. Set it as a Traefik middleware and attach it to the router: &lt;code&gt;traefik.http.middlewares.nc-secure.headers.stsSeconds=15552000&lt;/code&gt;. Attach it additionally to the &lt;code&gt;nc-dav&lt;/code&gt; middleware on the router (&lt;code&gt;...routers.nc.middlewares=nc-dav,nc-secure&lt;/code&gt;). The header must come from the proxy, not from Nextcloud. In &lt;a href="https://serverkueche.de/en/tutorials/harden-optimize-nextcloud/" rel="noopener noreferrer"&gt;part 2&lt;/a&gt; we set up exactly this &lt;code&gt;nc-secure&lt;/code&gt; middleware fully (including &lt;code&gt;includeSubdomains&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Large uploads break off or end with a timeout / "413".&lt;/strong&gt; The PHP limit is too small. Increase &lt;code&gt;PHP_UPLOAD_LIMIT&lt;/code&gt; and &lt;code&gt;PHP_MEMORY_LIMIT&lt;/code&gt; (step 3) and restart the container. In rare cases these variables don't take effect – then mount your own &lt;code&gt;php.ini&lt;/code&gt; snippet into the image.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On the first start the installation aborts with "MySQL server has gone away" or "Connection refused".&lt;/strong&gt; The app container was faster than the database. That's exactly what the &lt;code&gt;healthcheck&lt;/code&gt; with &lt;code&gt;depends_on: condition: service_healthy&lt;/code&gt; is for – check that both are present in your Compose file, and restart with &lt;code&gt;docker compose up -d&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;Backups are mandatory for a cloud.&lt;/strong&gt; A consistent backup needs &lt;strong&gt;three things&lt;/strong&gt;: the &lt;strong&gt;database&lt;/strong&gt; (MariaDB dump), the &lt;strong&gt;data directory&lt;/strong&gt; (your files) and the &lt;strong&gt;configuration&lt;/strong&gt; (&lt;code&gt;config.php&lt;/code&gt;). Put Nextcloud into maintenance mode briefly before the backup so the database and files match each other:
&lt;/li&gt;
&lt;/ul&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:mode &lt;span class="nt"&gt;--on&lt;/span&gt;
  docker &lt;span class="nb"&gt;exec &lt;/span&gt;nc-db mariadb-dump &lt;span class="nt"&gt;-u&lt;/span&gt; root &lt;span class="nt"&gt;-pA_STRONG_ROOT_PASSWORD&lt;/span&gt; nextcloud &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; nextcloud-db.sql
  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:mode &lt;span class="nt"&gt;--off&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Back up the dump together with the &lt;code&gt;nc_html&lt;/code&gt; volume &lt;strong&gt;encrypted and off-site&lt;/strong&gt; with &lt;a href="https://serverkueche.de/en/tutorials/restic-backups/" rel="noopener noreferrer"&gt;Restic&lt;/a&gt; – thanks to the dump you don't need to additionally back up the raw &lt;code&gt;nc_db&lt;/code&gt; volume. The &lt;code&gt;nc_html&lt;/code&gt; volume contains both the program code and the &lt;code&gt;config.php&lt;/code&gt; as well as – under &lt;code&gt;data/&lt;/code&gt; – &lt;strong&gt;your actual user files&lt;/strong&gt;. If your cloud grows a lot, you can later move this &lt;code&gt;data/&lt;/code&gt; directory to a separate, larger volume; for now one volume for everything is enough.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Updates: only one major version per step.&lt;/strong&gt; From 34 to 35, never directly to 36. Bump the image tag (&lt;code&gt;nextcloud:35-apache&lt;/code&gt;), then &lt;code&gt;docker compose pull&lt;/code&gt; and &lt;code&gt;docker compose up -d&lt;/code&gt;. The container runs the necessary &lt;code&gt;occ upgrade&lt;/code&gt; &lt;strong&gt;automatically&lt;/strong&gt; on start. &lt;strong&gt;Always make a backup first.&lt;/strong&gt; Bump the cron sidecar to the same version.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check security.&lt;/strong&gt; After setup, run the official &lt;a href="https://scan.nextcloud.com/" rel="noopener noreferrer"&gt;Nextcloud Security Scan&lt;/a&gt; against your domain (target: grade A) and work through the warnings from the admin overview. The overview under &lt;strong&gt;Administration → Overview&lt;/strong&gt; runs automatic checks and shows exactly what's still missing. Keep Nextcloud up to date – security holes are closed promptly, but only if you update.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Honest about the effort:&lt;/strong&gt; a self-hosted cloud wants to be maintained. Reckon with an update pass roughly monthly and a regular look at the admin overview. In return, the data then really belongs to you.&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/self-host-nextcloud/" rel="noopener noreferrer"&gt;serverkueche.de&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>docker</category>
      <category>privacy</category>
    </item>
    <item>
      <title>Self-hosting Immich: your private photo backup behind Traefik</title>
      <dc:creator>serverkueche.de</dc:creator>
      <pubDate>Sat, 25 Jul 2026 07:06:44 +0000</pubDate>
      <link>https://dev.to/serverkueche/self-hosting-immich-your-private-photo-backup-behind-traefik-48ma</link>
      <guid>https://dev.to/serverkueche/self-hosting-immich-your-private-photo-backup-behind-traefik-48ma</guid>
      <description>&lt;p&gt;Thousands of photos and videos from your phone – and all of them at Google or Apple. &lt;strong&gt;Immich&lt;/strong&gt; brings them back to your server: a self-hosted photo service that comes astonishingly close to Google Photos, including automatic phone upload, timeline, search and face recognition. In this recipe we set up Immich cleanly behind Traefik.&lt;/p&gt;

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

&lt;p&gt;By the end, &lt;strong&gt;Immich v3&lt;/strong&gt; runs behind your Traefik proxy, reachable at &lt;code&gt;https://photos.YOUR_DOMAIN&lt;/code&gt; with valid HTTPS. The official &lt;strong&gt;Immich phone app&lt;/strong&gt; then backs up your new photos and videos automatically to your server – like the cloud backup of Google Photos, except the images live exclusively with you. Via the web interface you browse your timeline, search by full text ("beach", "dog") and let Immich group faces.&lt;/p&gt;

&lt;p&gt;Immich consists of &lt;strong&gt;four containers&lt;/strong&gt;: the server (web + API), a machine-learning service (for search and face recognition), a PostgreSQL database with a vector extension, and a cache (Valkey). Sounds like a lot – but the official template takes most of it off your hands, and we only hang the server onto Traefik.&lt;/p&gt;

&lt;p&gt;The appeal over Google Photos: the images don't leave your server, there are no storage subscriptions and no automatic analysis of your shots by a corporation. In return, you bear responsibility for operation and – very importantly – for &lt;strong&gt;backups&lt;/strong&gt;: if the server dies and you have no backup, the photos are gone. That's exactly why the backup below is not optional but mandatory.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠️ Immich moves fast – pin the version&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Immich develops rapidly. Database migrations run automatically on update and are &lt;strong&gt;not backward-compatible&lt;/strong&gt; – a downgrade is no longer cleanly possible afterwards. So &lt;strong&gt;always pin a fixed version&lt;/strong&gt; (here &lt;code&gt;v3.1.0&lt;/code&gt;) instead of &lt;code&gt;release&lt;/code&gt; or &lt;code&gt;latest&lt;/code&gt;, and make a &lt;strong&gt;backup before every update&lt;/strong&gt;. That way you decide when to update.&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 Let's Encrypt 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;photos.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;A &lt;strong&gt;backup&lt;/strong&gt;. Your photo collection is irreplaceable – first set up &lt;a href="https://serverkueche.de/en/tutorials/restic-backups/" rel="noopener noreferrer"&gt;backups with Restic&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 How big does the server need to be?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Immich is the most resource-hungry service in the Serverküche. Officially, &lt;strong&gt;at least 6 GB RAM (8 recommended)&lt;/strong&gt; are required – the machine-learning service for face recognition and search in particular needs memory. The tested &lt;strong&gt;VPS 1000 with 8 GB RAM&lt;/strong&gt; just meets that but has little reserve. For a large library or several users, a server with &lt;strong&gt;16 GB RAM&lt;/strong&gt; (e.g. VPS 2000) is more relaxed. On a 4 GB server, Immich only runs with &lt;strong&gt;ML turned off&lt;/strong&gt; (see "When things go wrong"). And: photos need space – plan for enough disk.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;How much RAM your photo library including AI search really needs is calculated by the &lt;a href="https://serverkueche.de/en/server-calculator/" rel="noopener noreferrer"&gt;server calculator&lt;/a&gt;.&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 an entry &lt;code&gt;photos.YOUR_DOMAIN&lt;/code&gt; that points to your server IP, and check it:&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 photos.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: Prepare the folder and .env
&lt;/h3&gt;

&lt;p&gt;Immich is configured via a &lt;code&gt;.env&lt;/code&gt; file. Create the project:&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; ~/immich &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; ~/immich
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create a &lt;code&gt;.env&lt;/code&gt; with the core values (set a strong DB password – &lt;strong&gt;letters and digits only&lt;/strong&gt;, no special characters, Immich's DB init doesn't like those):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;UPLOAD_LOCATION&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;./library&lt;/span&gt;
&lt;span class="py"&gt;DB_DATA_LOCATION&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;./postgres&lt;/span&gt;
&lt;span class="py"&gt;DB_PASSWORD&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;YOUR_DB_PASSWORD&lt;/span&gt;
&lt;span class="py"&gt;DB_USERNAME&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;postgres&lt;/span&gt;
&lt;span class="py"&gt;DB_DATABASE_NAME&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;immich&lt;/span&gt;
&lt;span class="py"&gt;IMMICH_VERSION&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;v3.1.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;UPLOAD_LOCATION&lt;/code&gt;&lt;/strong&gt; – this is where your &lt;strong&gt;photos and videos&lt;/strong&gt; land. It's the most important directory for the backup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;DB_DATA_LOCATION&lt;/code&gt;&lt;/strong&gt; – the PostgreSQL data. Must be on a &lt;strong&gt;local disk&lt;/strong&gt; (no network drive/NFS – the DB resents that).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;IMMICH_VERSION&lt;/code&gt;&lt;/strong&gt; – the pinned version.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 3: Create the compose.yaml
&lt;/h3&gt;

&lt;p&gt;We adopt the official Immich template and only add the Traefik labels on the &lt;code&gt;immich-server&lt;/code&gt;. Create &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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;immich&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;immich-server&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;ghcr.io/immich-app/immich-server:${IMMICH_VERSION}&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;${UPLOAD_LOCATION}:/data&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/etc/localtime:/etc/localtime:ro&lt;/span&gt;
    &lt;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.env&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;DB_HOSTNAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;database&lt;/span&gt;
      &lt;span class="na"&gt;REDIS_HOSTNAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis&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;redis&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_started&lt;/span&gt;
      &lt;span class="na"&gt;database&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;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.immich.rule=Host(`photos.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.immich.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.immich.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.immich.loadbalancer.server.port=2283"&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;default&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;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;immich-machine-learning&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;ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION}&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;model-cache:/cache&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;default&lt;/span&gt;&lt;span class="pi"&gt;]&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;redis&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;docker.io/valkey/valkey:9&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;redis-cli&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ping&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;||&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;exit&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1"&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="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;]&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;database&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;ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0&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;${DB_PASSWORD}&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DB_USERNAME}&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DB_DATABASE_NAME}&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_INITDB_ARGS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--data-checksums'&lt;/span&gt;
    &lt;span class="na"&gt;shm_size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;128mb&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_LOCATION}:/var/lib/postgresql/data&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;${DB_USERNAME}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;${DB_DATABASE_NAME}"&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;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;default&lt;/span&gt;&lt;span class="pi"&gt;]&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;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;model-cache&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;default&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;Important to understand:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Only &lt;code&gt;immich-server&lt;/code&gt; is on the &lt;code&gt;proxy&lt;/code&gt; network&lt;/strong&gt; and carries Traefik labels. The ML service, Valkey and the database stay exclusively on the internal &lt;code&gt;default&lt;/code&gt; network – not reachable from outside.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;loadbalancer.server.port=2283&lt;/code&gt;&lt;/strong&gt; – Immich listens on port 2283 in the container.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;database&lt;/strong&gt; is deliberately the prebuilt Immich Postgres image with the vector extension &lt;em&gt;VectorChord&lt;/em&gt; (for image search). Don't just use a standard &lt;code&gt;postgres&lt;/code&gt; – the extension is then missing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;POSTGRES_INITDB_ARGS&lt;/code&gt; and &lt;code&gt;shm_size&lt;/code&gt;&lt;/strong&gt; are in the official template too: &lt;code&gt;--data-checksums&lt;/code&gt; makes Postgres write checksums when the database is created so that silent data corruption gets noticed; &lt;code&gt;shm_size: 128mb&lt;/code&gt; raises Docker's tight 64 MB default for &lt;code&gt;/dev/shm&lt;/code&gt;, on which larger queries would otherwise fail. The checksums only take effect on a &lt;strong&gt;freshly created&lt;/strong&gt; database – on an instance that is already running, the entry changes nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Valkey&lt;/strong&gt; is the Redis successor; the service is still called &lt;code&gt;redis&lt;/code&gt; in the Immich template for compatibility reasons.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;healthcheck&lt;/strong&gt; on the database reports when Postgres is really ready. Via &lt;code&gt;depends_on: … condition: service_healthy&lt;/code&gt;, &lt;code&gt;immich-server&lt;/code&gt; only starts then – this prevents migration errors and the "database not reachable" start on the first boot.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 Applying changes to the .env&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you later change something in the &lt;code&gt;.env&lt;/code&gt;, a &lt;code&gt;docker compose restart&lt;/code&gt; is &lt;strong&gt;not&lt;/strong&gt; enough – Docker only reads the environment variables when &lt;strong&gt;recreating&lt;/strong&gt; the containers. Use &lt;code&gt;docker compose up -d&lt;/code&gt; then; Compose detects the change and rebuilds the affected containers.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 4: Allow large uploads (Traefik timeout)
&lt;/h3&gt;

&lt;p&gt;This is &lt;strong&gt;the&lt;/strong&gt; stumbling block with Immich behind Traefik: by default Traefik aborts connections after &lt;strong&gt;60 seconds&lt;/strong&gt; (&lt;code&gt;readTimeout&lt;/code&gt;). When uploading large videos from the phone this leads to aborted uploads (error 502/499). Increase the timeout on the &lt;code&gt;websecure&lt;/code&gt; entrypoint in your &lt;strong&gt;Traefik configuration&lt;/strong&gt; (the &lt;code&gt;traefik&lt;/code&gt; service from the &lt;a href="https://serverkueche.de/en/tutorials/traefik-reverse-proxy/" rel="noopener noreferrer"&gt;Traefik tutorial&lt;/a&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;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# ... your existing lines ...&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--entrypoints.websecure.transport.respondingTimeouts.readTimeout=600s"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restart Traefik afterwards (&lt;code&gt;docker compose up -d&lt;/code&gt; in the Traefik folder). Unlike nginx, Traefik has no fixed size limit for uploads – only this timeout has to be high.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Start and run through the initial setup
&lt;/h3&gt;

&lt;p&gt;Pull the images (several GB – this takes a while) and 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 compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt; immich-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The images are several gigabytes in size – the first &lt;code&gt;pull&lt;/code&gt; takes a few minutes depending on your connection. Then check that all four containers are running:&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;p&gt;All four services have to show up in the &lt;code&gt;SERVICE&lt;/code&gt; column – &lt;code&gt;immich-server&lt;/code&gt;, &lt;code&gt;immich-machine-learning&lt;/code&gt;, &lt;code&gt;redis&lt;/code&gt; and &lt;code&gt;database&lt;/code&gt;. Under &lt;code&gt;STATUS&lt;/code&gt;, the server, the ML service and the database read &lt;code&gt;Up … (healthy)&lt;/code&gt;; &lt;code&gt;redis&lt;/code&gt; does too, because we adopted the healthcheck from the official template. Now wait until &lt;code&gt;Immich Server is listening on http://[::1]:2283 [v3.1.0] [production]&lt;/code&gt; appears in the log, then open &lt;code&gt;https://photos.YOUR_DOMAIN&lt;/code&gt;. Traefik fetches the Let's Encrypt certificate on the first access (be patient for a moment). On the very first start, Immich greets you with &lt;strong&gt;"Welcome to Immich"&lt;/strong&gt; and the choice of whether you &lt;strong&gt;start fresh&lt;/strong&gt; or restore a backup:&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/immich-willkommen.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/immich-willkommen.png" title="First launch: choose \" alt="The welcome screen of Immich with the options " width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Click &lt;strong&gt;Getting Started&lt;/strong&gt; (you only need Restore if you're importing a backup). Then you create the &lt;strong&gt;administrator account&lt;/strong&gt; – as the first user you automatically become admin:&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%2F6er9q1b9my6g7oe45xdg.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%2F6er9q1b9my6g7oe45xdg.png" alt="The Immich form " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After the first login, a short &lt;strong&gt;setup wizard&lt;/strong&gt; guides you through theme, language and basic privacy settings:&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%2Fn09i1ywxmofik6cstiwv.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%2Fn09i1ywxmofik6cstiwv.png" alt="The Immich setup wizard greets the new administrator" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After that you land on your (still empty) &lt;strong&gt;timeline&lt;/strong&gt; – the heart of Immich:&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%2Fwv16v1wgxejy9sk3368y.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%2Fwv16v1wgxejy9sk3368y.png" alt="The empty photo timeline of Immich with the prompt to upload the first photo" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6: Connect the phone app
&lt;/h3&gt;

&lt;p&gt;The real benefit comes from the app. Install &lt;strong&gt;Immich&lt;/strong&gt; from the App Store or Play Store (or F-Droid). At launch it asks for the &lt;strong&gt;server address&lt;/strong&gt; – enter &lt;code&gt;https://photos.YOUR_DOMAIN&lt;/code&gt; and log in with your account. Then, in the app settings, enable the &lt;strong&gt;backup&lt;/strong&gt; and choose the albums to be uploaded.&lt;/p&gt;

&lt;p&gt;From now on your phone backs up new photos automatically to your server – in the background and over Wi-Fi. The first pass of a large library takes a while; after that only new shots are added.&lt;/p&gt;

&lt;p&gt;After the upload, Immich keeps working in the background: it generates thumbnails, reads the capture metadata (date, location) and lets the ML service &lt;strong&gt;recognize faces&lt;/strong&gt; and index the images for &lt;strong&gt;smart search&lt;/strong&gt;. These jobs run for some time after the first big import – so faces and search hits only appear gradually. You see the progress under &lt;strong&gt;Administration → Job Queues&lt;/strong&gt;. In the sidebar you then find the typical photo features: &lt;strong&gt;Explore&lt;/strong&gt; (by people and places), the &lt;strong&gt;map&lt;/strong&gt; with geolocation, &lt;strong&gt;sharing&lt;/strong&gt; and &lt;strong&gt;albums&lt;/strong&gt;. The &lt;strong&gt;memories&lt;/strong&gt; ("a year ago") show up by themselves as soon as older shots are in the library. How to tame the ML jobs, switch to a better search model and connect the mobile app properly is covered in &lt;a href="https://serverkueche.de/en/tutorials/optimize-immich-mobile/" rel="noopener noreferrer"&gt;Optimize Immich and use it on mobile&lt;/a&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;ℹ️ Videos &amp;amp; transcoding on the VPS&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Immich converts videos for playback in the browser (transcoding). On a normal VPS &lt;strong&gt;without a graphics card&lt;/strong&gt; this happens via the CPU – with many or long videos this is noticeably slower and maxes out cores. For a private phone backup this is usually no problem; whoever has a lot of video should choose the server correspondingly larger.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;💡 More users&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For the family, create further accounts under &lt;strong&gt;Administration → Users&lt;/strong&gt; – each gets its own separate library. Registration from outside is off by default; new users only arise via the admin account.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⚠️ Your photos are on the internet&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;As soon as Immich is reachable via &lt;code&gt;photos.YOUR_DOMAIN&lt;/code&gt;, the login page is open on the net. So set a &lt;strong&gt;long, unique password&lt;/strong&gt; for the admin and all user accounts. Immich v3 has &lt;strong&gt;no&lt;/strong&gt; built-in two-factor authentication – the account settings only offer a password and a PIN code for the locked folder. You only get a second factor through an upstream login service via OAuth/OIDC, for example with&lt;br&gt;
Authentik (Tutorial expected in October). Whoever wants to be extra safe makes Immich reachable only via a &lt;a href="https://serverkueche.de/en/tutorials/wireguard-vpn-setup/" rel="noopener noreferrer"&gt;WireGuard VPN&lt;/a&gt; – but for automatic phone upload, direct HTTPS reachability is usually the more practical way.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;&lt;strong&gt;Uploads of large videos abort after about a minute (error 502 or 499).&lt;/strong&gt; Traefik's &lt;code&gt;readTimeout&lt;/code&gt; (default 60 s) kicks in. Set it to &lt;code&gt;600s&lt;/code&gt; (or higher) as in step 4 and restart Traefik. This is by far the most common Immich-behind-proxy error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;immich-machine-learning&lt;/code&gt; container crashes or exits with "exit 137", search and face recognition don't work.&lt;/strong&gt; Too little RAM – the container was killed by the system (out of memory). Give the server more memory, or &lt;strong&gt;disable ML&lt;/strong&gt; by removing the service &lt;code&gt;immich-machine-learning&lt;/code&gt; from the &lt;code&gt;compose.yaml&lt;/code&gt;. Immich then runs without face recognition and smart search, but upload and timeline work normally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After an update the containers no longer start or report migration errors.&lt;/strong&gt; Server, machine learning and database must run on the &lt;strong&gt;same&lt;/strong&gt; version. Set the new &lt;code&gt;IMMICH_VERSION&lt;/code&gt; in the &lt;code&gt;.env&lt;/code&gt; and update &lt;strong&gt;all&lt;/strong&gt; containers together (&lt;code&gt;docker compose pull &amp;amp;&amp;amp; docker compose up -d&lt;/code&gt;). A downgrade after a migration isn't possible – only a reset from the backup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Immich shows "maintenance mode" / "temporarily unavailable".&lt;/strong&gt; Immich v3 starts into a maintenance mode under certain database states. The log (&lt;code&gt;docker compose logs immich-server&lt;/code&gt;) then contains a URL with a &lt;strong&gt;one-time token&lt;/strong&gt; (&lt;code&gt;…/maintenance?token=…&lt;/code&gt;) – through it you log into the maintenance mode and choose "restart" or end it. After that the normal interface is back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Container won't start, "permission denied" on the database or upload directory.&lt;/strong&gt; &lt;code&gt;UPLOAD_LOCATION&lt;/code&gt; or &lt;code&gt;DB_DATA_LOCATION&lt;/code&gt; have the wrong access rights or are on a network drive. Put both on a local disk and make sure Docker may write into them.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Two things belong in the backup – consistently together.&lt;/strong&gt; Your &lt;strong&gt;photos&lt;/strong&gt; (&lt;code&gt;UPLOAD_LOCATION&lt;/code&gt;, i.e. &lt;code&gt;~/immich/library&lt;/code&gt;) and the &lt;strong&gt;database&lt;/strong&gt;. The DB dump contains only the metadata; without the matching files it's worthless, and vice versa. Cleanest is to stop Immich briefly and back up both together:
&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 stop immich-server
  docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; database pg_dump &lt;span class="nt"&gt;--clean&lt;/span&gt; &lt;span class="nt"&gt;--if-exists&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--dbname&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;immich &lt;span class="nt"&gt;--username&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;postgres | &lt;span class="nb"&gt;gzip&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; immich-db.sql.gz
  docker compose start immich-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Back up the dump together with the &lt;code&gt;library&lt;/code&gt; folder &lt;strong&gt;encrypted and off-site&lt;/strong&gt; with &lt;a href="https://serverkueche.de/en/tutorials/restic-backups/" rel="noopener noreferrer"&gt;Restic&lt;/a&gt;. On top of that, Immich creates regular database dumps of its own under &lt;strong&gt;Administration → Settings → Database Dump Settings&lt;/strong&gt; (they land in &lt;code&gt;UPLOAD_LOCATION/backups&lt;/code&gt;) – you still have to back up the photos separately, and these dump jobs are not monitored: Immich does not notify you when one of them fails.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rehearse the worst case once.&lt;/strong&gt; A backup you've never restored is just a hope. To restore, you import the DB dump into a fresh Immich instance of the same version and place the &lt;code&gt;library&lt;/code&gt; folder in the same spot – Immich offers the option &lt;strong&gt;"Restore From Backup"&lt;/strong&gt; on the first start for this (the second button from step 5). What matters is the &lt;strong&gt;same version&lt;/strong&gt;: the database state and the program must match.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backup before every update.&lt;/strong&gt; Because migrations aren't reversible, the backup is your only way back. Back up first, then raise &lt;code&gt;IMMICH_VERSION&lt;/code&gt;, then &lt;code&gt;docker compose pull &amp;amp;&amp;amp; docker compose up -d&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Honest about the effort:&lt;/strong&gt; Immich often brings several releases per month. You don't have to follow every one – but before a jump across several versions, read the release notes, and stick to the pinned version until you deliberately update.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep an eye on disk space.&lt;/strong&gt; Photos and videos grow steadily; Immich additionally creates thumbnails and converted videos. Monitor the disk usage (e.g. with &lt;a href="https://serverkueche.de/en/tutorials/uptime-kuma-monitoring/" rel="noopener noreferrer"&gt;Uptime Kuma&lt;/a&gt;) so the server doesn't fill up.&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/self-host-immich-photos/" rel="noopener noreferrer"&gt;serverkueche.de&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>docker</category>
      <category>privacy</category>
    </item>
    <item>
      <title>Installing Uptime Kuma: server monitoring behind Traefik</title>
      <dc:creator>serverkueche.de</dc:creator>
      <pubDate>Fri, 24 Jul 2026 21:44:39 +0000</pubDate>
      <link>https://dev.to/serverkueche/installing-uptime-kuma-server-monitoring-behind-traefik-15af</link>
      <guid>https://dev.to/serverkueche/installing-uptime-kuma-server-monitoring-behind-traefik-15af</guid>
      <description>&lt;p&gt;Traefik is up – now we hang the first real app behind it. &lt;strong&gt;Uptime Kuma&lt;/strong&gt; is ideal for that: tiny, immediately useful and without a database. It monitors your services and raises the alarm when one fails – from now on &lt;em&gt;you&lt;/em&gt; notice first that something is stuck, not your users. Along the way you learn the pattern every further app repeats.&lt;/p&gt;

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

&lt;p&gt;By the end, &lt;strong&gt;Uptime Kuma 2.5.3&lt;/strong&gt; runs at &lt;code&gt;https://status.YOUR_DOMAIN&lt;/code&gt;, secured via Traefik with automatic HTTPS. You've set up the first monitor (which checks every minute whether a service responds), connected a notification and optionally a public status page. For the first time we bind a service that &lt;strong&gt;doesn't listen on port 80&lt;/strong&gt; – an important detail for all the following apps.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;A running &lt;a href="https://serverkueche.de/en/tutorials/traefik-reverse-proxy/" rel="noopener noreferrer"&gt;Traefik reverse proxy&lt;/a&gt; including the &lt;code&gt;proxy&lt;/code&gt; network and a working Let's Encrypt resolver &lt;code&gt;le&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A DNS record &lt;code&gt;status.YOUR_DOMAIN&lt;/code&gt; that &lt;a href="https://serverkueche.de/en/tutorials/connect-domain-to-server/" rel="noopener noreferrer"&gt;points to the server&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

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

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

&lt;p&gt;Own folder, own file:&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; ~/uptime-kuma &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; ~/uptime-kuma
&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;uptime-kuma&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/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="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;kuma-data:/app/data&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;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.kuma.rule=Host(`status.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.kuma.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.kuma.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.kuma.loadbalancer.server.port=3001"&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;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;kuma-data&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You know this from the Traefik tutorial – except for &lt;strong&gt;one new, decisive line&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.services.kuma.loadbalancer.server.port=3001"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Uptime Kuma listens internally on &lt;strong&gt;port 3001&lt;/strong&gt;, not on 80. This line tells Traefik which port to forward the requests to. Without it, Traefik guesses wrong and you get a &lt;code&gt;Bad Gateway&lt;/code&gt;. Remember the label – every app that doesn't run on port 80 needs it.&lt;/p&gt;

&lt;p&gt;The rest is the familiar pattern: &lt;strong&gt;no &lt;code&gt;ports:&lt;/code&gt;&lt;/strong&gt; (only reachable via Traefik), &lt;code&gt;proxy&lt;/code&gt; network, named volume for the data.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Start and first login
&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;
docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt; uptime-kuma
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When Kuma is ready, the log ends with &lt;code&gt;Waiting for user action...&lt;/code&gt; – from here the service waits for the initial setup in the browser (&lt;code&gt;Ctrl+C&lt;/code&gt; only ends the following):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Welcome to Uptime Kuma
Your Node.js version: 22.22.3
2026-09-09T10:53:28Z [SERVER] INFO: Uptime Kuma Version: 2.5.3
2026-09-09T10:53:30Z [SETUP-DATABASE] INFO: Starting Setup Database
2026-09-09T10:53:30Z [SETUP-DATABASE] INFO: Listening on:
2026-09-09T10:53:30Z [SETUP-DATABASE] INFO: -  http://localhost:3001
2026-09-09T10:53:30Z [SETUP-DATABASE] INFO: Waiting for user action...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kuma does &lt;strong&gt;not&lt;/strong&gt; create the database yet – it deliberately waits for your choice in the browser. So open &lt;code&gt;https://status.YOUR_DOMAIN&lt;/code&gt;. Uptime Kuma 2.x first asks for the &lt;strong&gt;database&lt;/strong&gt; – for a setup like ours, &lt;strong&gt;SQLite&lt;/strong&gt; is the right, simplest choice (select it, click &lt;strong&gt;Next&lt;/strong&gt;). Right after that you create the &lt;strong&gt;admin account&lt;/strong&gt; (username + strong password).&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%2Fopbkqvm3p8aongkaasw5.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%2Fopbkqvm3p8aongkaasw5.png" alt="Uptime Kuma's initial setup: choose the language and create the admin account" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠️ Create the admin account immediately&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;As long as no admin account exists, &lt;strong&gt;anyone&lt;/strong&gt; who opens the page can create one. Do this right after the first start – not "later".&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 3: Create the first monitor
&lt;/h3&gt;

&lt;p&gt;A &lt;strong&gt;monitor&lt;/strong&gt; is a recurring check. Click &lt;strong&gt;Add New Monitor&lt;/strong&gt; and create one for Traefik itself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Monitor type:&lt;/strong&gt; &lt;code&gt;HTTP(s)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Friendly name:&lt;/strong&gt; &lt;code&gt;Traefik Dashboard&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;URL:&lt;/strong&gt; &lt;code&gt;https://traefik.YOUR_DOMAIN&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Heartbeat interval:&lt;/strong&gt; &lt;code&gt;60&lt;/code&gt; seconds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authentication:&lt;/strong&gt; the Traefik dashboard is protected by basic auth (step 7 of the &lt;a href="https://serverkueche.de/en/tutorials/traefik-reverse-proxy/" rel="noopener noreferrer"&gt;Traefik tutorial&lt;/a&gt;) – so at the very bottom, in the &lt;strong&gt;Authentication&lt;/strong&gt; section, pick &lt;strong&gt;HTTP Basic Auth&lt;/strong&gt; under &lt;strong&gt;Method&lt;/strong&gt; and enter the user and password. Without credentials, Kuma only gets &lt;code&gt;401&lt;/code&gt; and reports the monitor as &lt;strong&gt;Down&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Save – after a few seconds the monitor is &lt;strong&gt;Up&lt;/strong&gt; (green) and shows the response time.&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%2Ftsbjbr4fy5bz2hupc0nc.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%2Ftsbjbr4fy5bz2hupc0nc.png" alt="The Uptime Kuma dashboard with two running monitors – both green with 100% availability and " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Besides the simple &lt;code&gt;HTTP(s)&lt;/code&gt; check, it pays to choose the matching &lt;strong&gt;monitor type&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;HTTP(s) – Keyword:&lt;/strong&gt; additionally checks whether a certain word appears in the response text. This detects "the server responds, but shows an error page".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TCP port:&lt;/strong&gt; for services without a web interface (e.g. a database, an SSH port). Only checks whether the port is open.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ping:&lt;/strong&gt; the simplest reachability test via ICMP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker container:&lt;/strong&gt; checks the container status directly via the Docker socket – handy for internal services without their own domain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Push:&lt;/strong&gt; here &lt;em&gt;the monitored service&lt;/em&gt; calls Kuma regularly. Ideal for cron jobs and backups: if the script doesn't report in time, Kuma raises the alarm ("dead man's switch").&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Create the matching monitor for every important service.&lt;/p&gt;

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

&lt;p&gt;Check &lt;strong&gt;public services via their real domain&lt;/strong&gt; (&lt;code&gt;https://…&lt;/code&gt;), not via &lt;code&gt;localhost&lt;/code&gt;. This way you simultaneously test that Traefik and the certificate work from outside – not just that the container is running.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For the &lt;code&gt;HTTP(s)&lt;/code&gt; monitor, the option &lt;strong&gt;"Certificate Expiry Notification"&lt;/strong&gt; (in the &lt;strong&gt;Advanced&lt;/strong&gt; section) is also worth it: Kuma then warns in good time before a TLS certificate expires. For services behind Traefik, Let's Encrypt renews automatically – but this very automation occasionally fails silently (a DNS record is changed, port 80 accidentally closed). The monitor is your safety net and reports in while there are still days to fix it, instead of visitors suddenly facing a certificate warning.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Set up notifications (email and Telegram)
&lt;/h3&gt;

&lt;p&gt;A monitor without an alarm is just a pretty chart. Under &lt;strong&gt;Settings → Notifications → Set Up Notification&lt;/strong&gt; you create a channel. Uptime Kuma supports over 90 – we set up the two most common completely: &lt;strong&gt;email&lt;/strong&gt; for the classic message and &lt;strong&gt;Telegram&lt;/strong&gt; for push straight to the phone.&lt;/p&gt;

&lt;h4&gt;
  
  
  Email (SMTP)
&lt;/h4&gt;

&lt;p&gt;Choose "Email (SMTP)" as the &lt;strong&gt;notification type&lt;/strong&gt; and enter your mail provider's data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hostname / Port:&lt;/strong&gt; e.g. &lt;code&gt;smtp.YOUR_PROVIDER.com&lt;/code&gt; and &lt;code&gt;587&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security:&lt;/strong&gt; exactly two entries to pick from – &lt;code&gt;None / STARTTLS (25, 587)&lt;/code&gt; for port 587 (Kuma negotiates STARTTLS itself) and &lt;code&gt;TLS (465)&lt;/code&gt; for port 465&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Username / Password:&lt;/strong&gt; your SMTP credentials&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;From Email / To Email:&lt;/strong&gt; which address the warning comes from and which it's sent to&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvnw5gm2sqd9sp7rzuijc.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%2Fvnw5gm2sqd9sp7rzuijc.png" alt="The email notification (SMTP) in Uptime Kuma: hostname, port, security and from address" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Click &lt;strong&gt;Test&lt;/strong&gt; – within a few seconds a test message lands in the mailbox. Only when it really arrives are the credentials and port correct. Don't forget to save.&lt;/p&gt;

&lt;h4&gt;
  
  
  Telegram
&lt;/h4&gt;

&lt;p&gt;Telegram is ideal for instant push alerts to the phone – without your own mail server. You need two pieces of information:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Bot Token:&lt;/strong&gt; message &lt;a href="https://t.me/BotFather" rel="noopener noreferrer"&gt;@BotFather&lt;/a&gt; in Telegram, send &lt;code&gt;/newbot&lt;/code&gt;, assign a name – BotFather replies with the &lt;strong&gt;token&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chat ID:&lt;/strong&gt; send your new bot any message, then open &lt;code&gt;https://api.telegram.org/bot&amp;lt;YOUR_TOKEN&amp;gt;/getUpdates&lt;/code&gt; in the browser and read the &lt;code&gt;chat.id&lt;/code&gt; from the response.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Enter the token and chat ID into the Telegram notification (Uptime Kuma links both helpers directly in the dialog) and click &lt;strong&gt;Test&lt;/strong&gt; – the message should appear in the chat immediately.&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%2Fnx5ppuzma38wtlqzhe5i.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%2Fnx5ppuzma38wtlqzhe5i.png" alt="The Telegram notification in Uptime Kuma: bot token and chat ID, with a direct link to the BotFather" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠️ Otherwise the alarm stays silent&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A configured notification does &lt;strong&gt;not&lt;/strong&gt; take effect automatically. Enable it in each monitor (checkbox in the monitor form) or, in the notification dialog, turn on "Default enabled" and "Apply on all existing monitors".&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you want push messages entirely under your own control (without a Telegram server), take&lt;br&gt;
&lt;a href="https://serverkueche.de/en/tutorials/ntfy-push-notifications/" rel="noopener noreferrer"&gt;ntfy&lt;/a&gt; – self-hosted, with its own recipe in the series. Kuma knows it as the built-in notification type &lt;strong&gt;Ntfy&lt;/strong&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 5: A public status page (optional)
&lt;/h3&gt;

&lt;p&gt;Kuma can publish a &lt;strong&gt;status page&lt;/strong&gt; where visitors see whether your services are running. Here's how:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Status Pages → New Status Page&lt;/strong&gt;, then assign a &lt;strong&gt;name&lt;/strong&gt; (e.g. "Serverküche Status") and a &lt;strong&gt;slug&lt;/strong&gt; (the URL, e.g. &lt;code&gt;serverkueche&lt;/code&gt;) and click &lt;strong&gt;Next&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;In the editor, &lt;strong&gt;Add Group&lt;/strong&gt; (e.g. "Services"), and below it, add the desired &lt;strong&gt;monitors&lt;/strong&gt; via the selection field.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Save&lt;/strong&gt; at the top right – done. The page is then publicly reachable at &lt;code&gt;https://status.YOUR_DOMAIN/status/serverkueche&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Only include what really everyone may see – better leave internal services out.&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%2Fzkvfb0dfijtar8ifbzhi.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%2Fzkvfb0dfijtar8ifbzhi.png" alt="The public status page " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 6: Avoid false alarms – fine-tune the alarm behavior
&lt;/h3&gt;

&lt;p&gt;A monitor that raises the alarm at every brief network hiccup is quickly ignored – and then you miss the real outage. In the monitor form you set the behavior appropriately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Retries:&lt;/strong&gt; the service only counts as "Down" after &lt;em&gt;n&lt;/em&gt; failed checks. &lt;code&gt;2&lt;/code&gt;–&lt;code&gt;3&lt;/code&gt; filters out individual dropouts without obscuring real outages for long.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Heartbeat Retry Interval:&lt;/strong&gt; Kuma may check more often in the error case (e.g. every 20 seconds) to detect recovery quickly. The field only appears once &lt;strong&gt;Retries&lt;/strong&gt; is greater than &lt;code&gt;0&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resend Notification if Down X times consecutively:&lt;/strong&gt; Kuma reminds you again every &lt;em&gt;X&lt;/em&gt; failed checks as long as a service is down – useful so an outage at night doesn't get lost in a single mail. &lt;code&gt;0&lt;/code&gt; turns resending off.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 Plan maintenance windows&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you plan an update with downtime, create a &lt;strong&gt;maintenance window&lt;/strong&gt; under &lt;strong&gt;Maintenance&lt;/strong&gt;. Kuma then pauses the alarms for the affected monitors – so you (and the status page) don't get false alarms while you're at work.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;
  
  
  Step 7: Monitor cron jobs with a push monitor
&lt;/h3&gt;

&lt;p&gt;Classic monitors check from outside whether a service &lt;em&gt;responds&lt;/em&gt;. For things that &lt;strong&gt;run silently in the background&lt;/strong&gt; – a nightly backup, a sync script, a cron job – the &lt;strong&gt;push monitor&lt;/strong&gt; flips the principle: not Kuma asks, but &lt;em&gt;your script reports in&lt;/em&gt;. If the report fails to come, Kuma raises the alarm – the classic "dead man's switch".&lt;/p&gt;

&lt;p&gt;Create a monitor of type &lt;strong&gt;Push&lt;/strong&gt;. Kuma then shows you a unique &lt;strong&gt;push URL&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://status.YOUR_DOMAIN/api/push/YOUR_TOKEN?status=up&amp;amp;msg=OK&amp;amp;ping=
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You call this URL at the end of your script – for example after a successfully completed backup:&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="c"&gt;# ... your backup command ...&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="s2"&gt;"https://status.YOUR_DOMAIN/api/push/YOUR_TOKEN?status=up&amp;amp;msg=Backup+OK"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set the &lt;strong&gt;check interval&lt;/strong&gt; in Kuma a bit more generously than your cron cadence (if the backup runs hourly, give Kuma e.g. 90 minutes of tolerance). If no &lt;code&gt;curl&lt;/code&gt; comes in that time, the monitor goes to &lt;strong&gt;Down&lt;/strong&gt; and you're notified – so you learn about a backup that did &lt;em&gt;not&lt;/em&gt; run, not only when you urgently need it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 8: Secure the admin login with 2FA
&lt;/h3&gt;

&lt;p&gt;Your Kuma login protects access to all monitors, the stored notification credentials and the status-page configuration – and it's publicly on the net. So enable &lt;strong&gt;two-factor authentication&lt;/strong&gt;: under &lt;strong&gt;Settings → Security → Two Factor Authentication → 2FA Settings&lt;/strong&gt;. Kuma first asks for your &lt;strong&gt;current password&lt;/strong&gt;; after &lt;strong&gt;Enable 2FA&lt;/strong&gt; the QR code appears, which you scan with an authenticator app (e.g. Aegis or 2FAS). Enter the generated code, click &lt;strong&gt;Verify Token&lt;/strong&gt; and then &lt;strong&gt;Save&lt;/strong&gt; – without that last click, 2FA stays inactive.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 Secure the recovery&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Keep the TOTP secret or a second authenticator in a safe place (password manager) – in the same dialog, &lt;strong&gt;Show URI&lt;/strong&gt; reveals the secret as an &lt;code&gt;otpauth://&lt;/code&gt; URI you can store there. If you lose your phone &lt;strong&gt;and&lt;/strong&gt; have no copy, you can otherwise only get back in via the database in the &lt;code&gt;kuma-data&lt;/code&gt; volume.&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;Bad Gateway&lt;/code&gt; (502) when opening &lt;code&gt;status.YOUR_DOMAIN&lt;/code&gt;.&lt;/strong&gt; Almost always the port label &lt;code&gt;traefik.http.services.kuma.loadbalancer.server.port=3001&lt;/code&gt; is missing or has a wrong port. Traefik then reaches the container but knocks on the wrong port. Check the label and run &lt;code&gt;docker compose up -d&lt;/code&gt; again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;404 page not found&lt;/code&gt; instead of Kuma.&lt;/strong&gt; As with every app behind Traefik: &lt;code&gt;traefik.enable=true&lt;/code&gt; set? Container on the &lt;code&gt;proxy&lt;/code&gt; network? Is the domain in the &lt;code&gt;Host(...)&lt;/code&gt; rule correct and does the DNS record &lt;code&gt;status.YOUR_DOMAIN&lt;/code&gt; point to the server? The Traefik dashboard shows under "HTTP Routers" whether &lt;code&gt;kuma&lt;/code&gt; is registered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The interface loads, but the live update stutters / breaks off.&lt;/strong&gt; Kuma uses WebSockets. Traefik forwards those correctly by default – if the problem still occurs, it's usually an upstream CDN/proxy (e.g. Cloudflare in "proxy" mode) that blocks WebSockets. For direct operation behind Traefik, no extra configuration is needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After a re-setup all monitors are gone.&lt;/strong&gt; The &lt;code&gt;kuma-data&lt;/code&gt; volume was deleted (e.g. by &lt;code&gt;docker compose down -v&lt;/code&gt;). All configuration and history lives solely in this volume – that's why it's at the top of the next section.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Back up:&lt;/strong&gt; the complete heart of Kuma is the &lt;code&gt;kuma-data&lt;/code&gt; volume (a SQLite database). Back it up regularly – if it's gone, all monitors and the history are gone. We build the off-site backup for it in the &lt;a href="https://serverkueche.de/en/tutorials/restic-backups/" rel="noopener noreferrer"&gt;Restic tutorial&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Updates:&lt;/strong&gt; the tag &lt;code&gt;:2&lt;/code&gt; stays on the 2.x series and brings bug fixes with &lt;code&gt;docker compose pull &amp;amp;&amp;amp; docker compose up -d&lt;/code&gt;. Before a jump to a new major version (e.g. later &lt;code&gt;:3&lt;/code&gt;), read the release notes and back up the volume first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coming from 1.x?&lt;/strong&gt; The switch to &lt;code&gt;:2&lt;/code&gt; &lt;strong&gt;migrates the SQLite database automatically on the first start&lt;/strong&gt; – that can take a moment, and going back to &lt;code&gt;:1&lt;/code&gt; is not intended afterwards. So back up the &lt;code&gt;kuma-data&lt;/code&gt; volume &lt;strong&gt;beforehand&lt;/strong&gt;, then you're on the safe side. New installations (like above) aren't affected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Honest limitation:&lt;/strong&gt; a monitor that runs &lt;strong&gt;on the same server&lt;/strong&gt; as the monitored services can't warn you when the whole server fails – then Kuma is offline too. For that case, add an &lt;strong&gt;external&lt;/strong&gt; watcher. Two cheap ways: a &lt;strong&gt;second Uptime Kuma&lt;/strong&gt; on a small server (or at home) that only monitors this instance via HTTP – or a &lt;strong&gt;free external ping service&lt;/strong&gt; that pings your public status page. This way you also get a notice when the whole host is gone – the only case a local monitor inherently can't cover.&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/uptime-kuma-monitoring/" rel="noopener noreferrer"&gt;serverkueche.de&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>monitoring</category>
      <category>docker</category>
    </item>
    <item>
      <title>Vaultwarden: your own password manager behind Traefik</title>
      <dc:creator>serverkueche.de</dc:creator>
      <pubDate>Fri, 24 Jul 2026 21:36:20 +0000</pubDate>
      <link>https://dev.to/serverkueche/vaultwarden-your-own-password-manager-behind-traefik-b1c</link>
      <guid>https://dev.to/serverkueche/vaultwarden-your-own-password-manager-behind-traefik-b1c</guid>
      <description>&lt;p&gt;Passwords belong in a password manager – but do they have to live in the cloud of some third-party provider? With &lt;strong&gt;Vaultwarden&lt;/strong&gt; you host your own, encrypted and under your control. It speaks the Bitwarden language, so you use the familiar Bitwarden apps and browser extensions – they just point at &lt;strong&gt;your&lt;/strong&gt; server.&lt;/p&gt;

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

&lt;p&gt;By the end, &lt;strong&gt;Vaultwarden 1.37&lt;/strong&gt; runs in a container behind your Traefik proxy, reachable at &lt;code&gt;https://vault.YOUR_DOMAIN&lt;/code&gt; with a valid HTTPS certificate. Vaultwarden is a lean reimplementation of the Bitwarden server written in Rust: API-compatible, but frugal enough to run on a small VPS. You connect the official &lt;strong&gt;Bitwarden clients&lt;/strong&gt; (browser extension, phone app, desktop) with your server, store your credentials – end-to-end encrypted – and manage the service via a secured &lt;strong&gt;admin panel&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Your passwords thus live on your server, in your country, under your backup – and you pay no subscription fee for features like 2FA or attachments.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;ℹ️ Vaultwarden ≠ Bitwarden&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Vaultwarden is an &lt;strong&gt;unofficial&lt;/strong&gt;, community-driven project that reimplements the Bitwarden server API. It's not affiliated with Bitwarden Inc. For private and small-team use it's excellent; the clients (apps, extensions) are the real, official ones from Bitwarden.&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 shared &lt;code&gt;proxy&lt;/code&gt; network and the Let's Encrypt resolver &lt;code&gt;le&lt;/code&gt; – exactly the setup from the tutorial &lt;a href="https://serverkueche.de/en/tutorials/traefik-reverse-proxy/" rel="noopener noreferrer"&gt;reverse proxy with Traefik&lt;/a&gt;. Without Traefik this recipe doesn't work: Vaultwarden &lt;strong&gt;requires HTTPS&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;A subdomain, e.g. &lt;code&gt;vault.YOUR_DOMAIN&lt;/code&gt;, whose DNS record (A/AAAA) points 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;A working &lt;strong&gt;backup&lt;/strong&gt; of your server. A password manager is the place where data loss hurts most – if you haven't yet, first set up &lt;a href="https://serverkueche.de/en/tutorials/restic-backups/" rel="noopener noreferrer"&gt;backups with Restic&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to combine several services later, the &lt;a href="https://serverkueche.de/en/server-calculator/" rel="noopener noreferrer"&gt;server calculator&lt;/a&gt; helps with the right server size.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Step 1: Generate the ADMIN_TOKEN
&lt;/h3&gt;

&lt;p&gt;Vaultwarden has an admin panel at &lt;code&gt;/admin&lt;/code&gt;. Access to it is protected by an &lt;code&gt;ADMIN_TOKEN&lt;/code&gt;. You should &lt;strong&gt;not&lt;/strong&gt; store it in plaintext, but as a hash – then your panel password appears nowhere readable in the Compose file.&lt;/p&gt;

&lt;p&gt;Vaultwarden brings its own command for this. We run it briefly in a throwaway 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 run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; vaultwarden/server:1.37.2 /vaultwarden &lt;span class="nb"&gt;hash&lt;/span&gt; &lt;span class="nt"&gt;--preset&lt;/span&gt; owasp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command asks you twice for a password (input stays invisible) and then outputs an &lt;strong&gt;Argon2id hash&lt;/strong&gt; – a long string that starts like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Generate an Argon2id PHC string using the 'owasp' preset:

Password:
Confirm Password:

ADMIN_TOKEN='$argon2id$v=19$m=19456,t=2,p=1$FkxFEQ64Wy4zlQOWMI1fJ...$TxULe6MSND3By6GPVPKB1...'

Generation of the Argon2id PHC string took: 21.709159ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copy out the complete string between the quotes (including the &lt;code&gt;$argon2id$…&lt;/code&gt; parts) – you need it in a moment. The password you entered here is your &lt;strong&gt;admin panel password&lt;/strong&gt;; store it in your password manager (in the old one, for now).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 Why the detour via the hash?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You could also set the &lt;code&gt;ADMIN_TOKEN&lt;/code&gt; as a plaintext password. The hash is safer, though: even someone who gets hold of your &lt;code&gt;compose.yaml&lt;/code&gt; can't compute your panel password back from it. &lt;code&gt;--preset owasp&lt;/code&gt; chooses Argon2 parameters per the current OWASP recommendation.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 2: Create the compose.yaml
&lt;/h3&gt;

&lt;p&gt;Create a dedicated folder and change into 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;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/vaultwarden &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; ~/vaultwarden
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create the &lt;code&gt;compose.yaml&lt;/code&gt;. Replace &lt;code&gt;vault.YOUR_DOMAIN&lt;/code&gt; with your real subdomain and the &lt;code&gt;ADMIN_TOKEN&lt;/code&gt; with the hash from step 1:&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;vaultwarden&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;vaultwarden/server:1.37.2&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;./vw-data:/data&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;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;https://vault.YOUR_DOMAIN"&lt;/span&gt;
      &lt;span class="na"&gt;SIGNUPS_ALLOWED&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
      &lt;span class="na"&gt;ADMIN_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$$argon2id$$v=19$$m=19456,t=2,p=1$$FkxFE...$$TxULe..."&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.vaultwarden.rule=Host(`vault.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.vaultwarden.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.vaultwarden.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.vaultwarden.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="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;proxy&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;The most important lines in detail:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;image: vaultwarden/server:1.37.2&lt;/code&gt;&lt;/strong&gt; – we deliberately pin the version instead of taking &lt;code&gt;latest&lt;/code&gt;. That way you update in a controlled manner (see "Maintenance"). For an even smaller image there's also &lt;code&gt;:1.37.2-alpine&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;volumes: ./vw-data:/data&lt;/code&gt;&lt;/strong&gt; – this is where your database (&lt;code&gt;db.sqlite3&lt;/code&gt;), the encryption keys and the attachments live. &lt;strong&gt;This volume is mandatory.&lt;/strong&gt; Without a volume Vaultwarden deliberately refuses to start, so you don't lose your data in an ephemeral container.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;DOMAIN&lt;/code&gt;&lt;/strong&gt; – the full HTTPS URL. Vaultwarden needs it for WebAuthn/2FA and invitation links, among other things. Must match your router rule exactly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SIGNUPS_ALLOWED: "true"&lt;/code&gt;&lt;/strong&gt; – allows registration for now so you can create your first account. We turn that off again shortly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ADMIN_TOKEN&lt;/code&gt;&lt;/strong&gt; – your hash from step 1. &lt;strong&gt;Important:&lt;/strong&gt; in a Compose file every dollar sign must be &lt;strong&gt;doubled&lt;/strong&gt; (&lt;code&gt;$&lt;/code&gt; → &lt;code&gt;$$&lt;/code&gt;), otherwise Compose tries to interpret it as a variable. So &lt;code&gt;$argon2id$…&lt;/code&gt; becomes &lt;code&gt;$$argon2id$$…&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;loadbalancer.server.port=80&lt;/code&gt;&lt;/strong&gt; – Vaultwarden listens on &lt;strong&gt;port 80&lt;/strong&gt; in the container. This line tells Traefik explicitly where to forward internally, instead of guessing the target port from the image – the same pattern as with every app behind Traefik.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;ports:&lt;/code&gt;&lt;/strong&gt; – as with every app behind Traefik, Vaultwarden is only reachable via the proxy, never directly from outside.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 3: Start it and the first call
&lt;/h3&gt;

&lt;p&gt;Start the 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;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the first start, Vaultwarden creates the data directory and initializes the database. Take a look at the log:&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 logs &lt;span class="nt"&gt;-f&lt;/span&gt; vaultwarden
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At the end you should see this line – it confirms the service is running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[2026-08-01 23:35:51.515][start][INFO] Rocket has launched from http://0.0.0.0:80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;Ctrl+C&lt;/code&gt; you leave the log view again (the container keeps running).&lt;/p&gt;

&lt;p&gt;The Vaultwarden image ships its own healthcheck, and Traefik only forwards to containers that count as &lt;code&gt;healthy&lt;/code&gt;. Right after the start your domain therefore answers with a bare &lt;code&gt;404 page not found&lt;/code&gt; from Traefik for up to a minute – that's normal, not a configuration error. Just check the status in the meantime:&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;p&gt;As long as it still says &lt;code&gt;(health: starting)&lt;/code&gt;, Traefik is waiting. After the first successful healthcheck (it runs on a 60-second interval) the line looks like this:&lt;br&gt;
&lt;/p&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
vaultwarden-vaultwarden-1   vaultwarden/server:1.37.2   "/start.sh"   vaultwarden   2 minutes ago   Up 2 minutes (healthy)   80/tcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now open &lt;code&gt;https://vault.YOUR_DOMAIN&lt;/code&gt; in the browser. On the first access Traefik fetches the Let's Encrypt certificate – that can take a few more seconds. After that the Bitwarden web interface appears.&lt;/p&gt;

&lt;p&gt;Click &lt;strong&gt;Create account&lt;/strong&gt; and create your first account – with your email address and a name:&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%2Fve64eaqtw40zuqjmrvwo.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%2Fve64eaqtw40zuqjmrvwo.png" alt="The Vaultwarden web interface shows the form to create a new account with email address and name" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the next step you set your &lt;strong&gt;master password&lt;/strong&gt;. That's the one key that unlocks your entire vault:&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%2Ft0zfcpchrnx2lsllv9up.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%2Ft0zfcpchrnx2lsllv9up.png" alt="The Vaultwarden page for setting a strong master password with a password field and strength indicator" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;🛑 The master password is not recoverable&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Your master password is &lt;strong&gt;never&lt;/strong&gt; transmitted to the server; it decrypts your vault locally. If you forget it, &lt;strong&gt;all&lt;/strong&gt; passwords stored in it are lost – no one, not even you, can recover them. Choose a long, unique passphrase and keep it in a safe place (e.g. printed out in a safe).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 4: Close registration again
&lt;/h3&gt;

&lt;p&gt;Once your account is set up, you want to &lt;strong&gt;prevent strangers from registering too&lt;/strong&gt; – your Vaultwarden is, after all, open on the internet. Set &lt;code&gt;SIGNUPS_ALLOWED&lt;/code&gt; to &lt;code&gt;false&lt;/code&gt; in the &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;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;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;https://vault.YOUR_DOMAIN"&lt;/span&gt;
      &lt;span class="na"&gt;SIGNUPS_ALLOWED&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false"&lt;/span&gt;
      &lt;span class="na"&gt;ADMIN_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$$argon2id$$..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And apply the change:&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;Compose detects the changed environment variable and recreates the container (&lt;code&gt;Container vaultwarden-vaultwarden-1 Recreated&lt;/code&gt;). As with the first start it then takes up to a minute until the healthcheck passes and Traefik picks up the new container – before that you see the &lt;code&gt;404 page not found&lt;/code&gt; again. From now on the login page rejects new registrations with &lt;code&gt;Registration not allowed or user already exists&lt;/code&gt;. Further users (e.g. for the family) you invite specifically via the admin panel when needed – more on that shortly.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠️ Don't forget&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An open &lt;code&gt;SIGNUPS_ALLOWED: "true"&lt;/code&gt; is the most common misconfiguration with self-hosted Vaultwarden. Anyone who knows your domain could otherwise create an account. This step is mandatory, not an extra.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 5: The admin panel
&lt;/h3&gt;

&lt;p&gt;Open &lt;code&gt;https://vault.YOUR_DOMAIN/admin&lt;/code&gt; and log in with the &lt;strong&gt;password&lt;/strong&gt; you entered in step 1 when generating the hash (not with the hash itself). You land in the management interface:&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%2Ftmb70zhq32mgdoofqyi5.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%2Ftmb70zhq32mgdoofqyi5.png" alt="The Vaultwarden admin panel with the areas General, SMTP Email and Backup Database" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here you control the service centrally:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Users&lt;/strong&gt; – view existing users, &lt;strong&gt;invite&lt;/strong&gt; new ones (even with &lt;code&gt;SIGNUPS_ALLOWED=false&lt;/code&gt;), deactivate accounts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Settings → SMTP Email Settings&lt;/strong&gt; – enter your mail server's credentials here so Vaultwarden can send invitations and 2FA codes by email. Without SMTP, invitation by link works too, but email-based 2FA doesn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diagnostics&lt;/strong&gt; – shows you whether your &lt;code&gt;DOMAIN&lt;/code&gt; is set correctly and whether Vaultwarden is reachable from outside – handy for troubleshooting.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 Settings in the panel vs. environment variables&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What you save in the admin panel is stored in &lt;code&gt;vw-data/config.json&lt;/code&gt; and &lt;strong&gt;overrides&lt;/strong&gt; the environment variables from the &lt;code&gt;compose.yaml&lt;/code&gt;. Decide per setting on one way to avoid confusion. For the basic configuration (domain, token, signups) we deliberately stick with the Compose file – it's versionable and reproducible.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 6: Connect the clients
&lt;/h3&gt;

&lt;p&gt;Now comes the real benefit. Install the &lt;strong&gt;official Bitwarden app&lt;/strong&gt; or browser extension (from the respective app or add-on store). Before you log in, you switch the server:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;In the app/extension, &lt;strong&gt;before logging in&lt;/strong&gt;, open the settings for the &lt;strong&gt;self-hosted environment&lt;/strong&gt; (gear icon or "Region: Self-hosted").&lt;/li&gt;
&lt;li&gt;Enter &lt;code&gt;https://vault.YOUR_DOMAIN&lt;/code&gt; as the &lt;strong&gt;server URL&lt;/strong&gt; and save.&lt;/li&gt;
&lt;li&gt;Now log in with your email and your master password – the app talks to your server from now on.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The nice part: it's the same mature clients as with commercial Bitwarden. Autofill, password generator, secure notes, attachments and 2FA storage work the same way – only the data lives on your server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Import existing passwords:&lt;/strong&gt; if you're switching from another manager (or the browser's password store), you don't have to retype everything. Export your entries there as CSV or JSON and import them via the &lt;strong&gt;web interface&lt;/strong&gt; under &lt;code&gt;Tools → Import&lt;/code&gt;. Vaultwarden understands the export formats of the common managers (KeePass, LastPass, 1Password, Chrome/Firefox, etc.) directly.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠️ Securely delete export files afterwards&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An export CSV contains all passwords &lt;strong&gt;in plaintext&lt;/strong&gt;. Delete the file immediately after a successful import – and empty the trash. Never leave it lying in Downloads or a cloud folder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;💡 Enable two-factor authentication&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In your vault's account settings, enable &lt;strong&gt;2FA&lt;/strong&gt; (e.g. via an authenticator app). That way, even with a cracked master password, your vault isn't immediately open. For email-based 2FA, SMTP must be set up in the admin panel first.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;&lt;strong&gt;The container exits again immediately, the log says &lt;code&gt;No persistent volume!&lt;/code&gt;.&lt;/strong&gt; The &lt;code&gt;volumes:&lt;/code&gt; mapping is missing. Vaultwarden then aborts with the box "It looks like you did not configure a persistent volume!" and exits with code 1, so your passwords don't end up in an ephemeral container. Add &lt;code&gt;./vw-data:/data&lt;/code&gt; as in step 2 and restart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The web interface shows "You need to enable HTTPS!" or the login fails with crypto errors.&lt;/strong&gt; Vaultwarden uses the browser's Web Crypto API, which is only available in a &lt;strong&gt;secure context&lt;/strong&gt; (real HTTPS). You opened the page over &lt;code&gt;http://&lt;/code&gt; or with an invalid certificate. Make sure Traefik fetched a valid Let's Encrypt certificate (check the Traefik log) and that you reach the page over &lt;code&gt;https://&lt;/code&gt;. The &lt;code&gt;DOMAIN&lt;/code&gt; variable must also start with &lt;code&gt;https://&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The admin panel rejects your password, even though it's correct.&lt;/strong&gt; Probably the dollar signs in the &lt;code&gt;ADMIN_TOKEN&lt;/code&gt; aren't doubled. In the &lt;code&gt;compose.yaml&lt;/code&gt; every &lt;code&gt;$&lt;/code&gt; must become &lt;code&gt;$$&lt;/code&gt;. Check with &lt;code&gt;docker compose exec vaultwarden printenv ADMIN_TOKEN&lt;/code&gt; how the token really arrives inside the container – there it has to be a &lt;strong&gt;single&lt;/strong&gt; &lt;code&gt;$&lt;/code&gt; again. (&lt;code&gt;docker compose config&lt;/code&gt; won't help: it shows the file with the doubled &lt;code&gt;$$&lt;/code&gt;.) Also remember: at login you enter the &lt;strong&gt;password&lt;/strong&gt;, not the hash.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The phone app can't find the server or reports "Server URL invalid".&lt;/strong&gt; The server URL must be the full &lt;code&gt;https://&lt;/code&gt; address without a trailing path (&lt;code&gt;https://vault.YOUR_DOMAIN&lt;/code&gt;). Also check whether the domain is reachable from outside via a browser and the certificate is valid – apps are stricter about certificate errors than browsers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Despite &lt;code&gt;SIGNUPS_ALLOWED=false&lt;/code&gt;, someone was able to register.&lt;/strong&gt; The setting was probably set in the admin panel and overrides the environment variable, or the container wasn't restarted after the change. Check the value under &lt;strong&gt;Settings → General settings&lt;/strong&gt; in the panel and restart with &lt;code&gt;docker compose up -d&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;Backups are non-negotiable for a password manager.&lt;/strong&gt; Your entire vault is in the &lt;code&gt;vw-data/&lt;/code&gt; directory (SQLite database, keys, attachments). Back it up &lt;strong&gt;encrypted and off-site&lt;/strong&gt; with &lt;a href="https://serverkueche.de/en/tutorials/restic-backups/" rel="noopener noreferrer"&gt;Restic&lt;/a&gt; – add the folder &lt;code&gt;~/vaultwarden/vw-data&lt;/code&gt; to your backup sources. For a consistent database state, briefly run &lt;code&gt;docker compose stop&lt;/code&gt; before the backup, or use the "Backup Database" function in the admin panel. A mere file copy while the container is &lt;strong&gt;running&lt;/strong&gt; can catch an inconsistent state (SQLite writes into WAL files) – with a password manager that's not a risk worth taking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apply updates in a controlled way.&lt;/strong&gt; Because we pinned the version, you update deliberately: before the switch read the &lt;a href="https://github.com/dani-garcia/vaultwarden/releases" rel="noopener noreferrer"&gt;release notes&lt;/a&gt;, then raise the tag in the &lt;code&gt;compose.yaml&lt;/code&gt; (e.g. to the next &lt;code&gt;1.x&lt;/code&gt;) and pull anew:
&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 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;Afterwards check the &lt;code&gt;Rocket has launched&lt;/code&gt; line in the log again and test a login.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep registration closed.&lt;/strong&gt; Occasionally check that &lt;code&gt;SIGNUPS_ALLOWED&lt;/code&gt; is still &lt;code&gt;false&lt;/code&gt; – always invite new users specifically via the admin panel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rotate the ADMIN_TOKEN&lt;/strong&gt; if it might be compromised: generate a new hash with the &lt;code&gt;hash&lt;/code&gt; command from step 1, replace it in the &lt;code&gt;compose.yaml&lt;/code&gt;, restart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never lose the master password.&lt;/strong&gt; There is no recovery. In a team/family setup, an emergency access can make sense – you set that up per vault in the account settings.&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/vaultwarden-password-manager/" rel="noopener noreferrer"&gt;serverkueche.de&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>security</category>
      <category>docker</category>
    </item>
    <item>
      <title>Paperless-ngx selbst hosten: papierloses Büro mit OCR</title>
      <dc:creator>serverkueche.de</dc:creator>
      <pubDate>Mon, 20 Jul 2026 21:15:45 +0000</pubDate>
      <link>https://dev.to/serverkueche/paperless-ngx-selbst-hosten-papierloses-buro-mit-ocr-2f8l</link>
      <guid>https://dev.to/serverkueche/paperless-ngx-selbst-hosten-papierloses-buro-mit-ocr-2f8l</guid>
      <description>&lt;p&gt;Rechnungen, Verträge, Behördenpost – der Papierstapel wächst und wächst, und finden tust du&lt;br&gt;
am Ende doch nie etwas. &lt;strong&gt;Paperless-ngx&lt;/strong&gt; macht daraus ein durchsuchbares digitales Archiv: Du wirfst ein&lt;br&gt;
Dokument hinein, es wird per &lt;strong&gt;OCR&lt;/strong&gt; erkannt, verschlagwortet und ist über die&lt;br&gt;
Volltextsuche in Sekunden wieder da. In diesem Rezept setzen wir es hinter Traefik auf.&lt;/p&gt;
&lt;h2&gt;
  
  
  Was bauen wir?
&lt;/h2&gt;

&lt;p&gt;Am Ende läuft &lt;strong&gt;Paperless-ngx 2.20&lt;/strong&gt; hinter deinem Traefik-Proxy, erreichbar unter&lt;br&gt;
&lt;code&gt;https://paperless.DEINE_DOMAIN&lt;/code&gt; mit HTTPS. Paperless ist ein Dokumenten-Management-System:&lt;br&gt;
Du fütterst es mit Scans oder PDFs, es liest den Text per &lt;strong&gt;OCR&lt;/strong&gt; aus (auch aus reinen&lt;br&gt;
Bild-Scans), erkennt Datum und Inhalt und legt alles durchsuchbar ab. Über Tags,&lt;br&gt;
Korrespondenten und Dokumenttypen bringst du Ordnung hinein; die Volltextsuche findet&lt;br&gt;
später jedes Dokument.&lt;/p&gt;

&lt;p&gt;Der Clou ist der &lt;strong&gt;Consume-Ordner&lt;/strong&gt;: Alles, was du dort ablegst (z. B. vom Netzwerk-Scanner),&lt;br&gt;
wird automatisch importiert und verarbeitet. Paperless besteht aus &lt;strong&gt;fünf Containern&lt;/strong&gt; –&lt;br&gt;
dem Webserver, einer PostgreSQL-Datenbank, einem Redis-Broker für die&lt;br&gt;
Hintergrundverarbeitung sowie &lt;strong&gt;Gotenberg&lt;/strong&gt; und &lt;strong&gt;Tika&lt;/strong&gt;, die Office-Dokumente in PDF&lt;br&gt;
umwandeln. Die offizielle Vorlage liefert das komplette Gespann.&lt;/p&gt;

&lt;p&gt;Der Gewinn gegenüber einem Ordner voller PDFs auf der Festplatte: Paperless macht jedes&lt;br&gt;
Dokument &lt;strong&gt;durchsuchbar&lt;/strong&gt; (auch eingescanntes Papier), hält Original und Archivversion&lt;br&gt;
sauber getrennt und lässt sich per Regeln automatisieren. Deine Unterlagen bleiben dabei&lt;br&gt;
auf deinem Server – kein Cloud-Dienst liest mit, und du bist nicht an ein proprietäres&lt;br&gt;
Format gebunden.&lt;/p&gt;
&lt;h2&gt;
  
  
  Voraussetzungen
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Ein laufender &lt;strong&gt;Traefik-Reverse-Proxy&lt;/strong&gt; mit dem &lt;code&gt;proxy&lt;/code&gt;-Netzwerk und dem Resolver &lt;code&gt;le&lt;/code&gt;
– siehe &lt;a href="https://serverkueche.de/tutorials/reverse-proxy-traefik/" rel="noopener noreferrer"&gt;Reverse Proxy mit Traefik&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Eine Subdomain &lt;code&gt;paperless.DEINE_DOMAIN&lt;/code&gt; mit DNS-Record auf deine Server-IP – siehe
&lt;a href="https://serverkueche.de/tutorials/domain-mit-server-verbinden/" rel="noopener noreferrer"&gt;Domain mit Server verbinden&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Ein &lt;strong&gt;Backup&lt;/strong&gt;. In Paperless landen deine wichtigsten Unterlagen – richte zuerst
&lt;a href="https://serverkueche.de/tutorials/backups-mit-restic/" rel="noopener noreferrer"&gt;Backups mit Restic&lt;/a&gt; ein.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 Wie groß muss der Server sein?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Paperless selbst ist genügsam, aber es sind fünf Container, und die &lt;strong&gt;OCR-Verarbeitung ist&lt;br&gt;
CPU-lastig&lt;/strong&gt;: Beim Erkennen eines Scans läuft ein Kern für einige Sekunden bis Minuten auf&lt;br&gt;
Anschlag. Für den privaten Gebrauch reichen &lt;strong&gt;2 vCPU und 4 GB RAM&lt;/strong&gt; gut; der getestete VPS&lt;br&gt;
1000 (4 vCore / 8 GB) hat reichlich Luft. Beim Massenimport vieler Dokumente merkst du die&lt;br&gt;
CPU-Last – dann verarbeitet Paperless die Warteschlange eben nach und nach ab.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Unsicher, welche Server-Größe reicht? Der &lt;a href="https://serverkueche.de/serverempfehlung/" rel="noopener noreferrer"&gt;Server-Rechner&lt;/a&gt; rechnet dir&lt;br&gt;
RAM- und CPU-Bedarf für deine Dienste aus.&lt;/p&gt;
&lt;h2&gt;
  
  
  Schritt für Schritt
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Schritt 1: DNS-Record anlegen
&lt;/h3&gt;

&lt;p&gt;Lege &lt;code&gt;paperless.DEINE_DOMAIN&lt;/code&gt; an (A/AAAA auf deine Server-IP) und prüfe:&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 paperless.DEINE_DOMAIN
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Es muss deine Server-IP zurückkommen – sonst holt Traefik später kein Zertifikat.&lt;/p&gt;

&lt;h3&gt;
  
  
  Schritt 2: Einen Secret Key erzeugen
&lt;/h3&gt;

&lt;p&gt;Paperless verschlüsselt Sitzungen mit einem geheimen Schlüssel. Der Default ist &lt;strong&gt;öffentlich&lt;br&gt;
bekannt&lt;/strong&gt; – bei einer Instanz im Internet ein echtes Risiko. Erzeuge einen eigenen:&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;head&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; 50 /dev/urandom | &lt;span class="nb"&gt;base64&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kopiere die Ausgabe – sie kommt gleich als &lt;code&gt;PAPERLESS_SECRET_KEY&lt;/code&gt; in die Konfiguration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Schritt 3: Die compose.yaml anlegen
&lt;/h3&gt;

&lt;p&gt;Leg das Projekt an – &lt;strong&gt;inklusive&lt;/strong&gt; der beiden Bind-Mount-Ordner:&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; ~/paperless/&lt;span class="o"&gt;{&lt;/span&gt;consume,export&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; ~/paperless
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Die Unterordner &lt;code&gt;consume&lt;/code&gt; und &lt;code&gt;export&lt;/code&gt; legst du bewusst &lt;strong&gt;jetzt, als normaler&lt;br&gt;
Benutzer&lt;/strong&gt; an: Würde erst der Docker-Daemon sie beim Start erzeugen, gehörten sie&lt;br&gt;
&lt;code&gt;root&lt;/code&gt; – dann könnten weder du (&lt;code&gt;cp&lt;/code&gt; in den Consume-Ordner) noch Paperless selbst&lt;br&gt;
(läuft via &lt;code&gt;USERMAP_UID&lt;/code&gt; als UID 1000) hineinschreiben.&lt;/p&gt;

&lt;p&gt;Erstelle &lt;code&gt;compose.yaml&lt;/code&gt;. Ersetze &lt;code&gt;paperless.DEINE_DOMAIN&lt;/code&gt;, die Passwörter und den&lt;br&gt;
&lt;code&gt;PAPERLESS_SECRET_KEY&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;paperless&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;broker&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;docker.io/library/redis:8&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;redisdata:/data&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;default&lt;/span&gt;&lt;span class="pi"&gt;]&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;docker.io/library/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="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;paperless&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;paperless&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_STARKES_DB_PASSWORT&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;pgdata:/var/lib/postgresql&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;default&lt;/span&gt;&lt;span class="pi"&gt;]&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;gotenberg&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;docker.io/gotenberg/gotenberg:8.34&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;gotenberg"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--chromium-disable-javascript=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;--chromium-allow-list=file:///tmp/.*"&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;default&lt;/span&gt;&lt;span class="pi"&gt;]&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;tika&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;docker.io/apache/tika:3.3.1.0&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;default&lt;/span&gt;&lt;span class="pi"&gt;]&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;webserver&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;ghcr.io/paperless-ngx/paperless-ngx:2.20.15&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="nv"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;broker&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;gotenberg&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;tika&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;PAPERLESS_REDIS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis://broker:6379&lt;/span&gt;
      &lt;span class="na"&gt;PAPERLESS_DBHOST&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;PAPERLESS_DBUSER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;paperless&lt;/span&gt;
      &lt;span class="na"&gt;PAPERLESS_DBPASS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;EIN_STARKES_DB_PASSWORT&lt;/span&gt;
      &lt;span class="na"&gt;PAPERLESS_TIKA_ENABLED&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
      &lt;span class="na"&gt;PAPERLESS_TIKA_GOTENBERG_ENDPOINT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://gotenberg:3000&lt;/span&gt;
      &lt;span class="na"&gt;PAPERLESS_TIKA_ENDPOINT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://tika:9998&lt;/span&gt;
      &lt;span class="na"&gt;PAPERLESS_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://paperless.DEINE_DOMAIN&lt;/span&gt;
      &lt;span class="na"&gt;PAPERLESS_SECRET_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DEIN_LANGER_SECRET_KEY&lt;/span&gt;
      &lt;span class="na"&gt;PAPERLESS_OCR_LANGUAGE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deu&lt;/span&gt;
      &lt;span class="na"&gt;PAPERLESS_TIME_ZONE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Europe/Berlin&lt;/span&gt;
      &lt;span class="na"&gt;PAPERLESS_ADMIN_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;admin&lt;/span&gt;
      &lt;span class="na"&gt;PAPERLESS_ADMIN_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;EIN_STARKES_ADMIN_PASSWORT&lt;/span&gt;
      &lt;span class="na"&gt;USERMAP_UID&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"&lt;/span&gt;
      &lt;span class="na"&gt;USERMAP_GID&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"&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;data:/usr/src/paperless/data&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;media:/usr/src/paperless/media&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./export:/usr/src/paperless/export&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./consume:/usr/src/paperless/consume&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.ppl.rule=Host(`paperless.DEINE_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.ppl.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.ppl.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.ppl.loadbalancer.server.port=8000"&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;default&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;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;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;media&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pgdata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;redisdata&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;default&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;Die wichtigsten Punkte:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nur &lt;code&gt;webserver&lt;/code&gt; hängt im &lt;code&gt;proxy&lt;/code&gt;-Netz&lt;/strong&gt; und trägt Traefik-Labels (Port &lt;strong&gt;8000&lt;/strong&gt;). Die
vier Hilfsdienste (db, broker, gotenberg, tika) bleiben intern.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;PAPERLESS_URL&lt;/code&gt;&lt;/strong&gt; ist Pflicht hinter einem Proxy. Fehlt sie, weist Paperless den Login
mit einem CSRF-/„403 Forbidden"-Fehler ab. Sie setzt zugleich die erlaubten Hosts und die
vertrauenswürdigen Ursprünge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;PAPERLESS_OCR_LANGUAGE: deu&lt;/code&gt;&lt;/strong&gt; stellt die Texterkennung auf Deutsch. Deutsch, Englisch
und ein paar weitere Sprachen sind im Image bereits enthalten – kein Zusatzpaket nötig.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;PAPERLESS_ADMIN_USER&lt;/code&gt; / &lt;code&gt;_PASSWORD&lt;/code&gt;&lt;/strong&gt; legen beim ersten Start automatisch den
Superuser an, sodass du dich direkt anmelden kannst.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;USERMAP_UID&lt;/code&gt;/&lt;code&gt;GID&lt;/code&gt;&lt;/strong&gt; sollten zur Kennung deines Server-Benutzers passen (per &lt;code&gt;id -u&lt;/code&gt;
bzw. &lt;code&gt;id -g&lt;/code&gt; ermitteln, meist &lt;code&gt;1000&lt;/code&gt;). Sonst gibt es „Permission denied" im Consume-Ordner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;export&lt;/code&gt; und &lt;code&gt;consume&lt;/code&gt;&lt;/strong&gt; sind bewusst Ordner im Projektverzeichnis (Bind-Mounts):
&lt;code&gt;consume&lt;/code&gt; ist der Eingangskorb, &lt;code&gt;export&lt;/code&gt; das Ziel für Backups.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Paperless nutzt insgesamt &lt;strong&gt;vier Speicherbereiche&lt;/strong&gt;, die du auseinanderhalten solltest:&lt;br&gt;
&lt;code&gt;media&lt;/code&gt; enthält deine &lt;strong&gt;verarbeiteten Dokumente&lt;/strong&gt; (das Herzstück!), &lt;code&gt;data&lt;/code&gt; den Suchindex und&lt;br&gt;
Hilfsdaten, &lt;code&gt;consume&lt;/code&gt; ist der Eingangskorb und &lt;code&gt;export&lt;/code&gt; das Backup-Ziel. Fürs Backup zählen&lt;br&gt;
&lt;code&gt;media&lt;/code&gt; und &lt;code&gt;data&lt;/code&gt; sowie die Datenbank – genau das nimmt dir der &lt;code&gt;document_exporter&lt;/code&gt; weiter&lt;br&gt;
unten ab.&lt;/p&gt;
&lt;h3&gt;
  
  
  Schritt 4: Starten und anmelden
&lt;/h3&gt;

&lt;p&gt;Zieh die Images (mehrere GB) und starte:&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; webserver
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Beim ersten Start richtet Paperless die Datenbank ein (Migrationen) – das dauert einen&lt;br&gt;
Moment. Prüfe, dass alle fünf Container laufen:&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;p&gt;Du solltest &lt;code&gt;webserver&lt;/code&gt;, &lt;code&gt;db&lt;/code&gt;, &lt;code&gt;broker&lt;/code&gt;, &lt;code&gt;gotenberg&lt;/code&gt; und &lt;code&gt;tika&lt;/code&gt; mit Status &lt;code&gt;running&lt;/code&gt; sehen&lt;br&gt;
(der &lt;code&gt;webserver&lt;/code&gt; wird nach kurzer Zeit &lt;code&gt;healthy&lt;/code&gt;). Ist der Worker bereit (&lt;code&gt;celery@… ready&lt;/code&gt;&lt;br&gt;
im Log), ruf &lt;code&gt;https://paperless.DEINE_DOMAIN&lt;/code&gt; auf. Es erscheint die Anmeldeseite:&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%2Ff9vqwuttqrjr8mu88hxr.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%2Ff9vqwuttqrjr8mu88hxr.png" alt="Die Anmeldeseite von Paperless-ngx unter der eigenen Domain" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Melde dich mit &lt;code&gt;admin&lt;/code&gt; und deinem Passwort an. Du landest auf der &lt;strong&gt;Startseite&lt;/strong&gt; mit einer&lt;br&gt;
kurzen Willkommensmeldung und ersten Statistiken. Ganz unten links siehst du die laufende&lt;br&gt;
&lt;strong&gt;Version&lt;/strong&gt; – praktisch, um vor einem Update den Ausgangsstand zu kennen. Die Sprache&lt;br&gt;
kannst du bei Bedarf unter &lt;strong&gt;Einstellungen&lt;/strong&gt; umstellen; standardmäßig folgt Paperless der&lt;br&gt;
Sprache deines Browsers:&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%2F6e2ja4ovuxjco69rhmpj.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%2F6e2ja4ovuxjco69rhmpj.png" alt="Die Startseite von Paperless-ngx mit Willkommensmeldung und Statistik-Widget" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Schritt 5: Das erste Dokument einlesen
&lt;/h3&gt;

&lt;p&gt;Jetzt der Kern. Es gibt drei Wege, ein Dokument hineinzubekommen:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Über die Weboberfläche:&lt;/strong&gt; oben rechts auf &lt;strong&gt;Dokumente hochladen&lt;/strong&gt; und eine PDF- oder
Bilddatei auswählen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Über den Consume-Ordner:&lt;/strong&gt; Leg eine Datei in &lt;code&gt;~/paperless/consume&lt;/code&gt; – Paperless erkennt
sie automatisch, verarbeitet sie und löscht sie danach aus dem Ordner. Ideal für einen
Netzwerk-Scanner, der direkt dorthin scannt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Aus einem Postfach:&lt;/strong&gt; Paperless kann unter &lt;strong&gt;E-Mail&lt;/strong&gt; ein IMAP-Konto abrufen und
Anhänge automatisch einlesen – praktisch für Rechnungen, die dir ohnehin per Mail kommen.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; ~/eine-rechnung.pdf ~/paperless/consume/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Paperless nimmt PDFs, Bilder (JPG/PNG/TIFF) und – dank Gotenberg und Tika – auch&lt;br&gt;
Office-Dateien wie Word oder Excel an. Bei einem reinen Bild-Scan liest die &lt;strong&gt;OCR&lt;/strong&gt; den Text&lt;br&gt;
aus und legt ihn als durchsuchbare Ebene über das Dokument; bei einem PDF mit vorhandenem&lt;br&gt;
Textlayer überspringt Paperless die Erkennung und ist entsprechend schneller. Das Original&lt;br&gt;
bleibt dabei unangetastet erhalten – Paperless erzeugt zusätzlich eine durchsuchbare&lt;br&gt;
Archiv-Version.&lt;/p&gt;

&lt;p&gt;Nach ein paar Sekunden (OCR braucht etwas Zeit) taucht das Dokument unter &lt;strong&gt;Dokumente&lt;/strong&gt; auf –&lt;br&gt;
mit einer Vorschau und dem erkannten Titel:&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%2F1bziw41krn9huqxi2y7v.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%2F1bziw41krn9huqxi2y7v.png" alt="Die Dokumentenliste von Paperless-ngx mit einem verarbeiteten Dokument als Kachel" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Ein Klick öffnet die &lt;strong&gt;Detailansicht&lt;/strong&gt;: links die Metadaten (Titel, Datum, Korrespondent,&lt;br&gt;
Tags) und die Reiter für Inhalt, Metadaten und Verlauf, rechts das Dokument mit einer&lt;br&gt;
zoombaren Vorschau. Paperless hat aus dem Scan bereits das &lt;strong&gt;Datum erkannt&lt;/strong&gt; und&lt;br&gt;
schlägt es zur Bestätigung vor – genau das leistet die OCR im Hintergrund für dich:&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%2F1gjff1lghqxkhv7ht2a6.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%2F1gjff1lghqxkhv7ht2a6.png" alt="Die Detailansicht eines Dokuments in Paperless-ngx mit Metadaten links und dem erkannten Dokument rechts" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Schritt 6: Ordnung mit Tags, Korrespondenten &amp;amp; Dokumenttypen
&lt;/h3&gt;

&lt;p&gt;Damit die Suche später greift, vergibst du &lt;strong&gt;Tags&lt;/strong&gt; (z. B. &lt;code&gt;Steuer&lt;/code&gt;, &lt;code&gt;Versicherung&lt;/code&gt;),&lt;br&gt;
ordnest einen &lt;strong&gt;Korrespondenten&lt;/strong&gt; (den Absender) und einen &lt;strong&gt;Dokumenttyp&lt;/strong&gt; (Rechnung,&lt;br&gt;
Vertrag …) zu. Das kannst du von Hand machen – oder Paperless über &lt;strong&gt;Arbeitsabläufe&lt;/strong&gt;&lt;br&gt;
automatisieren: Regeln, die eingehende Dokumente anhand ihres Inhalts automatisch&lt;br&gt;
verschlagworten. So sortiert sich dein Archiv mit der Zeit von selbst.&lt;/p&gt;

&lt;p&gt;Die &lt;strong&gt;Volltextsuche&lt;/strong&gt; oben durchsucht danach nicht nur Titel und Tags, sondern den&lt;br&gt;
kompletten erkannten Text – eine Suche nach &lt;code&gt;Rechnungsbetrag&lt;/code&gt; oder einem Kundennamen findet&lt;br&gt;
das passende Dokument in Sekunden. Kombiniert mit den Filtern (Korrespondent, Zeitraum,&lt;br&gt;
Dokumenttyp) wird der Papierstapel endgültig zum durchsuchbaren Archiv.&lt;/p&gt;

&lt;p&gt;Ein einfaches Beispiel für einen &lt;strong&gt;Arbeitsablauf&lt;/strong&gt;: Enthält ein neues Dokument das Wort&lt;br&gt;
„Stromabrechnung", vergib automatisch den Tag &lt;code&gt;Energie&lt;/code&gt;, setz den Korrespondenten auf deinen&lt;br&gt;
Stromanbieter und den Dokumenttyp auf &lt;code&gt;Rechnung&lt;/code&gt;. Solche Regeln legst du unter&lt;br&gt;
&lt;strong&gt;Verwaltung → Arbeitsabläufe&lt;/strong&gt; an; sie greifen bei jedem eingehenden Dokument. Anfangs&lt;br&gt;
lohnt es sich, ein paar Dokumente von Hand zu sortieren – daraus siehst du schnell, welche&lt;br&gt;
Regeln sich wiederholen und automatisieren lassen. Nach ein paar Wochen landet der Großteil&lt;br&gt;
deiner Post ohne dein Zutun am richtigen Platz.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 Scanner direkt in den Consume-Ordner&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Der volle Komfort entsteht mit einem Dokumentenscanner. Richte auf dem Server eine&lt;br&gt;
Netzwerkfreigabe (z. B. Samba) ein, die auf &lt;code&gt;~/paperless/consume&lt;/code&gt; zeigt, und stelle deinen&lt;br&gt;
Scanner so ein, dass er dorthin scannt. Ab dann gilt: Blatt einlegen, Knopf drücken – wenige&lt;br&gt;
Sekunden später ist das Dokument erkannt, verschlagwortet und durchsuchbar im Archiv. Für&lt;br&gt;
unterwegs gibt es zudem Community-Apps (z. B. „Paperless Mobile"), die sich mit deiner&lt;br&gt;
Instanz verbinden.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⚠️ Deine Unterlagen hängen im Internet&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In Paperless liegen sensible Dokumente – Rechnungen, Verträge, Behördenpost. Sobald die&lt;br&gt;
Instanz über &lt;code&gt;paperless.DEINE_DOMAIN&lt;/code&gt; erreichbar ist, steht die Anmeldeseite offen im Netz.&lt;br&gt;
Vergib deshalb ein &lt;strong&gt;langes, einmaliges Passwort&lt;/strong&gt;, und aktiviere unter &lt;strong&gt;Einstellungen&lt;/strong&gt; die&lt;br&gt;
&lt;strong&gt;Zwei-Faktor-Authentifizierung&lt;/strong&gt;. Wer maximale Sicherheit will, macht Paperless nur über ein&lt;br&gt;
VPN erreichbar – für ein reines Privatarchiv, das nur du nutzt, ist das eine Überlegung wert.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Wenn es nicht funktioniert
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; Beim Login erscheint „&lt;strong&gt;Forbidden (403)&lt;/strong&gt;" oder „CSRF verification failed".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ursache &amp;amp; Lösung:&lt;/strong&gt; &lt;code&gt;PAPERLESS_URL&lt;/code&gt; ist nicht oder falsch gesetzt. Sie muss exakt deiner&lt;br&gt;
HTTPS-Adresse entsprechen (&lt;code&gt;https://paperless.DEINE_DOMAIN&lt;/code&gt;, ohne Schrägstrich am Ende).&lt;br&gt;
Nach der Korrektur &lt;code&gt;docker compose up -d&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; Dateien im Consume-Ordner werden nicht verarbeitet, „permission denied".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ursache &amp;amp; Lösung:&lt;/strong&gt; Häufigste Ursache: Die Ordner wurden nicht vorab angelegt&lt;br&gt;
(Schritt 3), sondern beim ersten Start vom Docker-Daemon erzeugt – dann gehören sie&lt;br&gt;
&lt;code&gt;root&lt;/code&gt;. Mit &lt;code&gt;sudo chown -R $(id -u):$(id -g) ~/paperless/consume ~/paperless/export&lt;/code&gt;&lt;br&gt;
gehören sie wieder dir. Ansonsten: &lt;code&gt;USERMAP_UID&lt;/code&gt;/&lt;code&gt;USERMAP_GID&lt;/code&gt; passen nicht zum&lt;br&gt;
Besitzer des Ordners – ermittle deine Kennung mit &lt;code&gt;id -u&lt;/code&gt; und &lt;code&gt;id -g&lt;/code&gt;, trag die Werte&lt;br&gt;
ein und starte neu.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; Hochgeladene Dokumente bleiben „in Bearbeitung" hängen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ursache &amp;amp; Lösung:&lt;/strong&gt; Die Hintergrundverarbeitung läuft über Redis. Prüfe, dass der&lt;br&gt;
&lt;code&gt;broker&lt;/code&gt;-Container läuft, und sieh unter &lt;strong&gt;Dateiaufgaben&lt;/strong&gt; nach der Fehlermeldung der&lt;br&gt;
fehlgeschlagenen Aufgabe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; Die Texterkennung liefert Unsinn oder erkennt nichts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ursache &amp;amp; Lösung:&lt;/strong&gt; Falsche OCR-Sprache. Setz &lt;code&gt;PAPERLESS_OCR_LANGUAGE=deu&lt;/code&gt; (oder&lt;br&gt;
&lt;code&gt;deu+eng&lt;/code&gt; für gemischte Dokumente). Nur installierte Sprachen funktionieren.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; Office-Dokumente (Word, Excel) werden nicht angenommen oder enden im Timeout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ursache &amp;amp; Lösung:&lt;/strong&gt; Dafür sind Gotenberg und Tika zuständig. Prüfe, dass beide Container&lt;br&gt;
laufen und &lt;code&gt;PAPERLESS_TIKA_ENABLED=1&lt;/code&gt; samt der beiden Endpoint-Variablen gesetzt ist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; Beim Massenimport wird der Server sehr langsam, die CPU ist dauerhaft am Anschlag.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ursache &amp;amp; Lösung:&lt;/strong&gt; OCR ist rechenintensiv, und Paperless nutzt standardmäßig alle Kerne.&lt;br&gt;
Auf kleinen Servern kannst du die Last drosseln, indem du die Zahl der Worker bzw. Threads&lt;br&gt;
begrenzt (&lt;code&gt;PAPERLESS_TASK_WORKERS&lt;/code&gt;, &lt;code&gt;PAPERLESS_THREADS_PER_WORKER&lt;/code&gt;). Dann dauert der Import&lt;br&gt;
länger, aber die Oberfläche bleibt bedienbar.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wartung &amp;amp; Backups
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Das saubere Backup macht der &lt;code&gt;document_exporter&lt;/code&gt;.&lt;/strong&gt; Er schreibt alle Dokumente,
Vorschaubilder, Metadaten und den Datenbankinhalt in den &lt;code&gt;export&lt;/code&gt;-Ordner – portabel und
wieder importierbar:
&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; webserver document_exporter ../export
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sichere den &lt;code&gt;export&lt;/code&gt;-Ordner anschließend &lt;strong&gt;verschlüsselt und off-site&lt;/strong&gt; mit&lt;br&gt;
  &lt;a href="https://serverkueche.de/tutorials/backups-mit-restic/" rel="noopener noreferrer"&gt;Restic&lt;/a&gt;. Alternativ sicherst du die Volumes &lt;code&gt;media&lt;/code&gt;,&lt;br&gt;
  &lt;code&gt;data&lt;/code&gt; und die Datenbank direkt – der Exporter ist aber der empfohlene, umzugssichere Weg.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Beim Wiederherstellen die gleiche Version verwenden.&lt;/strong&gt; Ein Export enthält ein Abbild
passend zum Datenbank-Schema; spiel ihn nur in eine Paperless-Instanz &lt;strong&gt;derselben Version&lt;/strong&gt;
ein (&lt;code&gt;document_importer ../export&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Updates.&lt;/strong&gt; Vor dem Update ein Backup ziehen, dann den Image-Tag erhöhen (z. B.
&lt;code&gt;2.20.15&lt;/code&gt; → nächste Version), &lt;code&gt;docker compose pull&lt;/code&gt; und &lt;code&gt;docker compose up -d&lt;/code&gt;. Die
Datenbank-Migrationen laufen beim Start automatisch. Bleib bei der stabilen &lt;code&gt;2.x&lt;/code&gt;-Reihe –
die &lt;code&gt;3.0&lt;/code&gt;-Beta ist noch nicht für den Produktivbetrieb gedacht.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backup automatisieren.&lt;/strong&gt; Den &lt;code&gt;document_exporter&lt;/code&gt; legst du am besten in einen täglichen
Cron-Job (z. B. nachts), der anschließend den &lt;code&gt;export&lt;/code&gt;-Ordner per Restic sichert. So hast
du jeden Morgen einen frischen, wiederherstellbaren Stand – ohne daran denken zu müssen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ehrlich zum Aufwand:&lt;/strong&gt; Paperless läuft danach sehr wartungsarm. Der eigentliche Aufwand
ist das Einsortieren neuer Dokumente – das nimmt dir mit etwas eingerichteter Automatik
aber zunehmend die Software ab. Plane einmal die grobe Tag-Struktur, dann trägt sich das
Archiv weitgehend selbst.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Dieser Beitrag erschien zuerst auf &lt;a href="https://serverkueche.de/tutorials/paperless-dokumentenverwaltung/" rel="noopener noreferrer"&gt;serverkueche.de&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>german</category>
      <category>paperless</category>
      <category>selfhosting</category>
      <category>docker</category>
    </item>
    <item>
      <title>Uptime Kuma installieren: Server-Monitoring hinter Traefik</title>
      <dc:creator>serverkueche.de</dc:creator>
      <pubDate>Sun, 19 Jul 2026 20:37:39 +0000</pubDate>
      <link>https://dev.to/serverkueche/uptime-kuma-installieren-server-monitoring-hinter-traefik-2inl</link>
      <guid>https://dev.to/serverkueche/uptime-kuma-installieren-server-monitoring-hinter-traefik-2inl</guid>
      <description>&lt;p&gt;Traefik steht – jetzt hängen wir die erste echte App dahinter. &lt;strong&gt;Uptime Kuma&lt;/strong&gt; ist&lt;br&gt;
dafür ideal: winzig, sofort nützlich und ohne Datenbank. Es überwacht deine Dienste&lt;br&gt;
und schlägt Alarm, wenn einer ausfällt – ab jetzt merkst &lt;em&gt;du&lt;/em&gt; zuerst, dass etwas&lt;br&gt;
klemmt, nicht deine Nutzer. Nebenbei lernst du das Muster, das jede weitere App&lt;br&gt;
wiederholt.&lt;/p&gt;
&lt;h2&gt;
  
  
  Was bauen wir?
&lt;/h2&gt;

&lt;p&gt;Am Ende läuft &lt;strong&gt;Uptime Kuma 2.4&lt;/strong&gt; unter &lt;code&gt;https://status.DEINE_DOMAIN&lt;/code&gt;, abgesichert&lt;br&gt;
über Traefik mit automatischem HTTPS. Du hast den ersten Monitor eingerichtet (der&lt;br&gt;
prüft im Minutentakt, ob ein Dienst antwortet), eine Benachrichtigung verbunden und&lt;br&gt;
optional eine öffentliche Status-Seite. Zum ersten Mal binden wir dabei einen Dienst&lt;br&gt;
an, der &lt;strong&gt;nicht auf Port 80&lt;/strong&gt; lauscht – ein wichtiges Detail für alle folgenden Apps.&lt;/p&gt;
&lt;h2&gt;
  
  
  Voraussetzungen
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Ein laufender &lt;a href="https://serverkueche.de/tutorials/reverse-proxy-traefik/" rel="noopener noreferrer"&gt;Traefik-Reverse-Proxy&lt;/a&gt; samt
&lt;code&gt;proxy&lt;/code&gt;-Netzwerk und funktionierendem Let's-Encrypt-Resolver &lt;code&gt;le&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Ein DNS-Record &lt;code&gt;status.DEINE_DOMAIN&lt;/code&gt;, der
&lt;a href="https://serverkueche.de/tutorials/domain-mit-server-verbinden/" rel="noopener noreferrer"&gt;auf den Server zeigt&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Schritt für Schritt
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Schritt 1: Die compose.yaml
&lt;/h3&gt;

&lt;p&gt;Eigener Ordner, eigene Datei:&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; ~/uptime-kuma &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; ~/uptime-kuma
&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;uptime-kuma&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/uptime-kuma:2&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;kuma-data:/app/data&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;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.kuma.rule=Host(`status.DEINE_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.kuma.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.kuma.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.kuma.loadbalancer.server.port=3001"&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;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;kuma-data&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Das kennst du aus dem Traefik-Tutorial – bis auf &lt;strong&gt;eine neue, entscheidende Zeile&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.services.kuma.loadbalancer.server.port=3001"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Uptime Kuma lauscht intern auf &lt;strong&gt;Port 3001&lt;/strong&gt;, nicht auf 80. Diese Zeile sagt Traefik,&lt;br&gt;
an welchen Port es die Anfragen weiterreichen soll. Ohne sie rät Traefik falsch und&lt;br&gt;
du bekommst einen &lt;code&gt;Bad Gateway&lt;/code&gt;. Merke dir das Label – jede App, die nicht auf Port&lt;br&gt;
80 läuft, braucht es.&lt;/p&gt;

&lt;p&gt;Der Rest ist das bekannte Muster: &lt;strong&gt;kein &lt;code&gt;ports:&lt;/code&gt;&lt;/strong&gt; (nur über Traefik erreichbar),&lt;br&gt;
&lt;code&gt;proxy&lt;/code&gt;-Netzwerk, Named Volume für die Daten.&lt;/p&gt;
&lt;h3&gt;
  
  
  Schritt 2: Starten und ersten Login
&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;
docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt; uptime-kuma
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Wenn Kuma bereit ist, endet das Log mit dieser Zeile – ab hier nimmt der Dienst&lt;br&gt;
Anfragen an (&lt;code&gt;Strg+C&lt;/code&gt; beendet nur das Mitlesen):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[SERVER] INFO: Welcome to Uptime Kuma
[SERVER] INFO: Uptime Kuma Version: 2.4.0
[SETUP-DATABASE] INFO: Listening on:
[SETUP-DATABASE] INFO: -  http://localhost:3001
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Beim ersten Start legt Kuma seine Datenbank im Volume an – das dauert einige&lt;br&gt;
Sekunden. Ruf dann &lt;code&gt;https://status.DEINE_DOMAIN&lt;/code&gt; im Browser auf. Uptime Kuma 2.x&lt;br&gt;
fragt zuerst die &lt;strong&gt;Datenbank&lt;/strong&gt; ab – für ein Setup wie unseres ist &lt;strong&gt;SQLite&lt;/strong&gt; die&lt;br&gt;
richtige, einfachste Wahl (auswählen, auf &lt;strong&gt;Weiter&lt;/strong&gt; klicken). Direkt danach legst du&lt;br&gt;
das &lt;strong&gt;Admin-Konto&lt;/strong&gt; an (Benutzername + starkes Passwort).&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%2Fx24yjwhdr4s96uv6lg4h.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%2Fx24yjwhdr4s96uv6lg4h.png" alt="Uptime Kumas Ersteinrichtung: Sprache wählen und das Admin-Konto anlegen" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠️ Sofort das Admin-Konto anlegen&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Solange kein Admin-Konto existiert, kann &lt;strong&gt;jeder&lt;/strong&gt;, der die Seite aufruft, eines&lt;br&gt;
anlegen. Erledige das direkt nach dem ersten Start – nicht „später".&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;
  
  
  Schritt 3: Den ersten Monitor anlegen
&lt;/h3&gt;

&lt;p&gt;Ein &lt;strong&gt;Monitor&lt;/strong&gt; ist eine wiederkehrende Prüfung. Klicke auf &lt;strong&gt;Neuen Monitor&lt;br&gt;
hinzufügen&lt;/strong&gt; und lege einen für Traefik selbst an:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Monitortyp:&lt;/strong&gt; &lt;code&gt;HTTP(s)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Freundlicher Name:&lt;/strong&gt; &lt;code&gt;Traefik Dashboard&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;URL:&lt;/strong&gt; &lt;code&gt;https://traefik.DEINE_DOMAIN&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prüfintervall:&lt;/strong&gt; &lt;code&gt;60&lt;/code&gt; Sekunden&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authentifizierung:&lt;/strong&gt; Das Traefik-Dashboard ist per Basic-Auth geschützt
(Schritt 7 des &lt;a href="https://serverkueche.de/tutorials/reverse-proxy-traefik/" rel="noopener noreferrer"&gt;Traefik-Tutorials&lt;/a&gt;) – wähle
deshalb unten &lt;strong&gt;HTTP Basic Auth&lt;/strong&gt; als Authentifizierungsmethode und trage
Benutzer und Passwort ein. Ohne Zugangsdaten bekommt Kuma nur &lt;code&gt;401&lt;/code&gt; und meldet
den Monitor als &lt;strong&gt;Down&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Speichern – nach wenigen Sekunden steht der Monitor auf &lt;strong&gt;Online&lt;/strong&gt; (grün) und zeigt&lt;br&gt;
die Antwortzeit.&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%2F6l4p68upk3ym2afn5ky0.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%2F6l4p68upk3ym2afn5ky0.png" alt="Das Uptime-Kuma-Dashboard mit zwei laufenden Monitoren – beide grün mit 100 % Verfügbarkeit und „200 – OK" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Neben dem einfachen &lt;code&gt;HTTP(s)&lt;/code&gt;-Check lohnt es sich, den passenden &lt;strong&gt;Monitortyp&lt;/strong&gt; zu&lt;br&gt;
wählen:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;HTTP(s) – Keyword:&lt;/strong&gt; prüft zusätzlich, ob ein bestimmtes Wort im Antworttext
steht. So erkennst du „Server antwortet zwar, zeigt aber eine Fehlerseite".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TCP-Port:&lt;/strong&gt; für Dienste ohne Web-Oberfläche (z. B. eine Datenbank, ein
SSH-Port). Prüft nur, ob der Port offen ist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ping:&lt;/strong&gt; einfachster Erreichbarkeitstest per ICMP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker-Container:&lt;/strong&gt; prüft direkt den Container-Status über den Docker-Socket –
praktisch für interne Dienste ohne eigene Domain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Push:&lt;/strong&gt; hier ruft &lt;em&gt;der überwachte Dienst&lt;/em&gt; Kuma regelmäßig an. Ideal für Cronjobs
und Backups: meldet sich das Skript nicht rechtzeitig, schlägt Kuma Alarm
(„Dead man's switch").&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Lege für jeden wichtigen Dienst den passenden Monitor an.&lt;/p&gt;

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

&lt;p&gt;Prüfe &lt;strong&gt;öffentliche Dienste über ihre echte Domain&lt;/strong&gt; (&lt;code&gt;https://…&lt;/code&gt;), nicht über&lt;br&gt;
&lt;code&gt;localhost&lt;/code&gt;. So testest du zugleich, dass Traefik und das Zertifikat von außen&lt;br&gt;
funktionieren – nicht nur, dass der Container läuft.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Beim &lt;code&gt;HTTP(s)&lt;/code&gt;-Monitor lohnt sich zusätzlich die Option&lt;br&gt;
&lt;strong&gt;„Zertifikatsablauf-Benachrichtigung"&lt;/strong&gt;: Kuma warnt dann rechtzeitig, bevor ein&lt;br&gt;
TLS-Zertifikat ausläuft. Bei Diensten hinter Traefik erneuert Let's Encrypt zwar&lt;br&gt;
automatisch – aber genau dieser Automatismus fällt gelegentlich leise aus (ein&lt;br&gt;
DNS-Record wird geändert, Port 80 versehentlich zugemacht). Der Monitor ist dein&lt;br&gt;
Sicherheitsnetz und meldet sich, solange noch Tage zum Reparieren bleiben, statt dass&lt;br&gt;
Besucher plötzlich vor einer Zertifikatswarnung stehen.&lt;/p&gt;
&lt;h3&gt;
  
  
  Schritt 4: Benachrichtigungen einrichten (E-Mail und Telegram)
&lt;/h3&gt;

&lt;p&gt;Ein Monitor ohne Alarm ist nur ein hübsches Diagramm. Unter &lt;strong&gt;Einstellungen →&lt;br&gt;
Benachrichtigungen → Benachrichtigung einrichten&lt;/strong&gt; legst du einen Kanal an. Uptime&lt;br&gt;
Kuma unterstützt über 90 – wir richten die zwei häufigsten komplett ein: &lt;strong&gt;E-Mail&lt;/strong&gt;&lt;br&gt;
für die klassische Nachricht und &lt;strong&gt;Telegram&lt;/strong&gt; für Push direkt aufs Handy.&lt;/p&gt;
&lt;h4&gt;
  
  
  E-Mail (SMTP)
&lt;/h4&gt;

&lt;p&gt;Wähle als &lt;strong&gt;Benachrichtigungsdienst&lt;/strong&gt; „E-Mail (SMTP)" und trage die Daten deines&lt;br&gt;
Mailanbieters ein:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hostname / Port:&lt;/strong&gt; z. B. &lt;code&gt;smtp.DEIN_ANBIETER.de&lt;/code&gt; und &lt;code&gt;587&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sicherheit:&lt;/strong&gt; &lt;code&gt;STARTTLS&lt;/code&gt; (Port 587) oder &lt;code&gt;TLS/SSL&lt;/code&gt; (Port 465)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Benutzername / Passwort:&lt;/strong&gt; deine SMTP-Zugangsdaten&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Absender- / Empfänger-Adresse:&lt;/strong&gt; von welcher Adresse die Warnung kommt und an
welche sie geschickt wird&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flqeddg6t2n6z4fjkhjxy.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%2Flqeddg6t2n6z4fjkhjxy.png" alt="Die E-Mail-Benachrichtigung (SMTP) in Uptime Kuma: Hostname, Port, Sicherheit und Absenderadresse" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Klick auf &lt;strong&gt;Test&lt;/strong&gt; – innerhalb weniger Sekunden landet eine Testnachricht im&lt;br&gt;
Postfach. Erst wenn die wirklich ankommt, stimmen Zugangsdaten und Port. Speichern&lt;br&gt;
nicht vergessen.&lt;/p&gt;
&lt;h4&gt;
  
  
  Telegram
&lt;/h4&gt;

&lt;p&gt;Telegram ist ideal für sofortige Push-Alarme aufs Handy – ohne eigenen Mailserver.&lt;br&gt;
Du brauchst zwei Angaben:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Bot-Token:&lt;/strong&gt; Schreib in Telegram &lt;a href="https://t.me/BotFather" rel="noopener noreferrer"&gt;@BotFather&lt;/a&gt; an, sende
&lt;code&gt;/newbot&lt;/code&gt;, vergib einen Namen – BotFather antwortet mit dem &lt;strong&gt;Token&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chat-ID:&lt;/strong&gt; Schreib deinem neuen Bot eine beliebige Nachricht, ruf dann
&lt;code&gt;https://api.telegram.org/bot&amp;lt;DEIN_TOKEN&amp;gt;/getUpdates&lt;/code&gt; im Browser auf und lies die
&lt;code&gt;chat.id&lt;/code&gt; aus der Antwort.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Trage Token und Chat-ID in die Telegram-Benachrichtigung ein (Uptime Kuma verlinkt&lt;br&gt;
beide Hilfen direkt im Dialog) und klick &lt;strong&gt;Test&lt;/strong&gt; – die Nachricht sollte sofort im&lt;br&gt;
Chat erscheinen.&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%2Fy366egb2w88ax7nvfid2.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%2Fy366egb2w88ax7nvfid2.png" alt="Die Telegram-Benachrichtigung in Uptime Kuma: Bot-Token und Chat-ID, mit Direktlink zum BotFather" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠️ Sonst bleibt der Alarm stumm&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Eine eingerichtete Benachrichtigung greift &lt;strong&gt;nicht automatisch&lt;/strong&gt;. Aktiviere sie in&lt;br&gt;
jedem Monitor (Häkchen im Monitor-Formular) oder schalte im Benachrichtigungs-Dialog&lt;br&gt;
„Standardmäßig aktiviert" ein und „Auf alle existierenden Monitore anwenden".&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Willst du Push-Nachrichten komplett in Eigenregie (ohne Telegram-Server), kommt&lt;br&gt;
später &lt;strong&gt;ntfy&lt;/strong&gt; dazu – selbst gehostet, mit eigenem Rezept in der Serie.&lt;/p&gt;
&lt;h3&gt;
  
  
  Schritt 5: Eine öffentliche Status-Seite (optional)
&lt;/h3&gt;

&lt;p&gt;Kuma kann eine &lt;strong&gt;Status-Seite&lt;/strong&gt; veröffentlichen, auf der Besucher sehen, ob deine&lt;br&gt;
Dienste laufen. So gehst du vor:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Statusseiten → Neue Status-Seite&lt;/strong&gt;, dann &lt;strong&gt;Name&lt;/strong&gt; (z. B. „Serverküche Status")
und &lt;strong&gt;Slug&lt;/strong&gt; (die URL, z. B. &lt;code&gt;serverkueche&lt;/code&gt;) vergeben und auf &lt;strong&gt;Weiter&lt;/strong&gt; klicken.&lt;/li&gt;
&lt;li&gt;Im Editor &lt;strong&gt;Gruppe hinzufügen&lt;/strong&gt; (z. B. „Dienste"), darunter über das Auswahlfeld
die gewünschten &lt;strong&gt;Monitore&lt;/strong&gt; hinzufügen.&lt;/li&gt;
&lt;li&gt;Oben rechts &lt;strong&gt;Speichern&lt;/strong&gt; – fertig. Öffentlich erreichbar ist die Seite dann unter
&lt;code&gt;https://status.DEINE_DOMAIN/status/serverkueche&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Nimm nur auf, was wirklich jeder sehen darf – interne Dienste besser weglassen.&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%2F4a51eyoxpmgqm4rkr7jj.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%2F4a51eyoxpmgqm4rkr7jj.png" alt="Die öffentliche Status-Seite „Serverküche Status" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Schritt 6: Fehlalarme vermeiden – das Alarm-Verhalten feinjustieren
&lt;/h3&gt;

&lt;p&gt;Ein Monitor, der bei jedem kurzen Netzwerk-Schluckauf Alarm schlägt, wird schnell&lt;br&gt;
ignoriert – und dann verpasst du den echten Ausfall. Im Monitor-Formular stellst du&lt;br&gt;
das Verhalten passend ein:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Wiederholungen (Retries):&lt;/strong&gt; Erst nach &lt;em&gt;n&lt;/em&gt; fehlgeschlagenen Prüfungen gilt der
Dienst als „Down". &lt;code&gt;2&lt;/code&gt;–&lt;code&gt;3&lt;/code&gt; filtert einzelne Aussetzer heraus, ohne echte Ausfälle
lange zu verschleiern.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Heartbeat-Intervall bei Ausfall:&lt;/strong&gt; Kuma darf im Fehlerfall häufiger prüfen
(z. B. alle 20 Sekunden), um die Erholung schnell zu erkennen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Erneut benachrichtigen:&lt;/strong&gt; Kuma kann dich alle &lt;em&gt;x&lt;/em&gt; Minuten erinnern, solange ein
Dienst down ist – nützlich, damit ein Ausfall nachts nicht in einer einzigen
Mail untergeht.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 Wartungsfenster einplanen&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Planst du ein Update mit Downtime, lege unter &lt;strong&gt;Wartung&lt;/strong&gt; ein &lt;strong&gt;Wartungsfenster&lt;/strong&gt; an.&lt;br&gt;
Kuma pausiert dann die Alarme für die betroffenen Monitore – so bekommst du (und die&lt;br&gt;
Status-Seite) keine Fehlalarme, während du selbst am Werk bist.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;
  
  
  Schritt 7: Cronjobs überwachen mit einem Push-Monitor
&lt;/h3&gt;

&lt;p&gt;Klassische Monitore prüfen von außen, ob ein Dienst &lt;em&gt;antwortet&lt;/em&gt;. Für Dinge, die&lt;br&gt;
&lt;strong&gt;still im Hintergrund laufen&lt;/strong&gt; – ein nächtliches Backup, ein Sync-Skript, ein&lt;br&gt;
Cronjob – dreht der &lt;strong&gt;Push-Monitor&lt;/strong&gt; das Prinzip um: Nicht Kuma fragt an, sondern&lt;br&gt;
&lt;em&gt;dein Skript meldet sich&lt;/em&gt;. Bleibt die Meldung aus, schlägt Kuma Alarm – der klassische&lt;br&gt;
„Dead man's switch".&lt;/p&gt;

&lt;p&gt;Lege einen Monitor vom Typ &lt;strong&gt;Push&lt;/strong&gt; an. Kuma zeigt dir dann eine eindeutige&lt;br&gt;
&lt;strong&gt;Push-URL&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://status.DEINE_DOMAIN/api/push/DEIN_TOKEN?status=up&amp;amp;msg=OK&amp;amp;ping=
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Diese URL rufst du am Ende deines Skripts auf – zum Beispiel nach einem erfolgreich&lt;br&gt;
durchgelaufenen Backup:&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="c"&gt;# ... dein Backup-Befehl ...&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="s2"&gt;"https://status.DEINE_DOMAIN/api/push/DEIN_TOKEN?status=up&amp;amp;msg=Backup+OK"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stell das &lt;strong&gt;Prüfintervall&lt;/strong&gt; in Kuma etwas großzügiger ein als deinen Cron-Takt (läuft&lt;br&gt;
das Backup stündlich, gib Kuma z. B. 90 Minuten Toleranz). Kommt in dieser Zeit kein&lt;br&gt;
&lt;code&gt;curl&lt;/code&gt;, geht der Monitor auf &lt;strong&gt;Down&lt;/strong&gt; und du wirst benachrichtigt – du erfährst also&lt;br&gt;
von einem &lt;em&gt;nicht&lt;/em&gt; gelaufenen Backup, nicht erst, wenn du es dringend brauchst.&lt;/p&gt;

&lt;h3&gt;
  
  
  Schritt 8: Den Admin-Login mit 2FA absichern
&lt;/h3&gt;

&lt;p&gt;Dein Kuma-Login schützt den Zugang zu allen Monitoren, den hinterlegten&lt;br&gt;
Benachrichtigungs-Zugangsdaten und der Status-Seiten-Konfiguration – und er hängt&lt;br&gt;
öffentlich im Netz. Schalte deshalb &lt;strong&gt;Zwei-Faktor-Authentifizierung&lt;/strong&gt; ein: unter&lt;br&gt;
&lt;strong&gt;Einstellungen → Sicherheit → Zwei-Faktor-Authentifizierung&lt;/strong&gt;. Kuma zeigt einen&lt;br&gt;
QR-Code, den du mit einer Authenticator-App (z. B. Aegis oder 2FAS) scannst; zum&lt;br&gt;
Aktivieren gibst du einmal den erzeugten Code ein.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 Wiederherstellung absichern&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Bewahre das TOTP-Secret bzw. einen zweiten Authenticator an einem sicheren Ort auf&lt;br&gt;
(Passwortmanager). Verlierst du dein Telefon &lt;strong&gt;und&lt;/strong&gt; hast keine Kopie, kommst du sonst&lt;br&gt;
nur noch über die Datenbank im &lt;code&gt;kuma-data&lt;/code&gt;-Volume wieder hinein.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Wenn es nicht funktioniert
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; &lt;code&gt;Bad Gateway&lt;/code&gt; (502) beim Aufruf von &lt;code&gt;status.DEINE_DOMAIN&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ursache &amp;amp; Lösung:&lt;/strong&gt; Fast immer fehlt das Port-Label&lt;br&gt;
&lt;code&gt;traefik.http.services.kuma.loadbalancer.server.port=3001&lt;/code&gt; oder es steht ein&lt;br&gt;
falscher Port drin. Traefik erreicht den Container dann zwar, klopft aber am&lt;br&gt;
falschen Port an. Label prüfen und &lt;code&gt;docker compose up -d&lt;/code&gt; erneut ausführen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; &lt;code&gt;404 page not found&lt;/code&gt; statt Kuma.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ursache &amp;amp; Lösung:&lt;/strong&gt; Wie bei jeder App hinter Traefik: &lt;code&gt;traefik.enable=true&lt;/code&gt;&lt;br&gt;
gesetzt? Container im &lt;code&gt;proxy&lt;/code&gt;-Netzwerk? Stimmt die Domain in der &lt;code&gt;Host(...)&lt;/code&gt;-Regel&lt;br&gt;
und zeigt der DNS-Record &lt;code&gt;status.DEINE_DOMAIN&lt;/code&gt; auf den Server? Das Traefik-Dashboard&lt;br&gt;
zeigt unter „HTTP Routers", ob &lt;code&gt;kuma&lt;/code&gt; registriert ist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; Die Oberfläche lädt, aber die Live-Aktualisierung ruckelt / bricht ab.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ursache &amp;amp; Lösung:&lt;/strong&gt; Kuma nutzt WebSockets. Traefik leitet die standardmäßig korrekt&lt;br&gt;
weiter – tritt das Problem trotzdem auf, liegt es meist an einem davorgeschalteten&lt;br&gt;
CDN/Proxy (z. B. Cloudflare im „Proxy"-Modus), der WebSockets blockt. Für den&lt;br&gt;
Direktbetrieb hinter Traefik ist keine Zusatzkonfiguration nötig.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; Nach einem Neuaufsetzen sind alle Monitore weg.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ursache &amp;amp; Lösung:&lt;/strong&gt; Das &lt;code&gt;kuma-data&lt;/code&gt;-Volume wurde gelöscht (z. B. durch&lt;br&gt;
&lt;code&gt;docker compose down -v&lt;/code&gt;). Alle Konfiguration und Historie liegt allein in diesem&lt;br&gt;
Volume – deshalb steht es im nächsten Abschnitt ganz oben.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wartung &amp;amp; Backups
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sichern:&lt;/strong&gt; Das komplette Herz von Kuma ist das Volume &lt;code&gt;kuma-data&lt;/code&gt; (eine
SQLite-Datenbank). Sichere es regelmäßig – ist es weg, sind alle Monitore und die
Historie weg. Das Off-Site-Backup dafür bauen wir im
&lt;a href="https://serverkueche.de/tutorials/backups-mit-restic/" rel="noopener noreferrer"&gt;Restic-Tutorial&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Updates:&lt;/strong&gt; Tag &lt;code&gt;:2&lt;/code&gt; bleibt bei der 2.x-Reihe und bringt Fehlerbehebungen mit
&lt;code&gt;docker compose pull &amp;amp;&amp;amp; docker compose up -d&lt;/code&gt;. Vor einem Sprung auf eine neue
Hauptversion (z. B. später &lt;code&gt;:3&lt;/code&gt;) die Release-Notes lesen und vorher das Volume
sichern.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Von 1.x kommend?&lt;/strong&gt; Der Wechsel auf &lt;code&gt;:2&lt;/code&gt; &lt;strong&gt;migriert die SQLite-Datenbank beim
ersten Start automatisch&lt;/strong&gt; – das kann einen Moment dauern, und ein Zurück auf &lt;code&gt;:1&lt;/code&gt;
ist danach nicht vorgesehen. Sichere deshalb &lt;strong&gt;vorher&lt;/strong&gt; das &lt;code&gt;kuma-data&lt;/code&gt;-Volume, dann
bist du auf der sicheren Seite. Neu-Installationen (wie oben) betrifft das nicht.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ehrliche Einschränkung:&lt;/strong&gt; Ein Monitor, der &lt;strong&gt;auf demselben Server&lt;/strong&gt; läuft wie die
überwachten Dienste, kann dich nicht warnen, wenn der ganze Server ausfällt – dann
ist auch Kuma offline. Ergänze für den Ernstfall einen &lt;strong&gt;externen&lt;/strong&gt; Wächter. Zwei
günstige Wege: ein &lt;strong&gt;zweites Uptime Kuma&lt;/strong&gt; auf einem kleinen Server (oder zu Hause),
das nur diese Instanz per HTTP überwacht – oder ein &lt;strong&gt;kostenloser externer
Ping-Dienst&lt;/strong&gt;, der deine öffentliche Status-Seite anpingt. So bekommst du auch dann
eine Meldung, wenn der ganze Host weg ist – der einzige Fall, den ein lokaler
Monitor prinzipbedingt nicht abdecken kann.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Damit hast du das App-Muster verinnerlicht und überwachst ab sofort alles, was du&lt;br&gt;
hinter Traefik hängst. Was jede weitere App voraussetzt, sind&lt;br&gt;
&lt;a href="https://serverkueche.de/tutorials/backups-mit-restic/" rel="noopener noreferrer"&gt;verschlüsselte Off-Site-Backups mit Restic&lt;/a&gt; –&lt;br&gt;
damit deine Daten einen Servercrash überleben.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Dieser Beitrag erschien zuerst auf &lt;a href="https://serverkueche.de/tutorials/uptime-kuma-monitoring/" rel="noopener noreferrer"&gt;serverkueche.de&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>german</category>
      <category>monitoring</category>
      <category>uptime</category>
      <category>selfhosting</category>
    </item>
    <item>
      <title>netcup-Firewall im SCP einrichten (mit stateless-UDP-Kniff)</title>
      <dc:creator>serverkueche.de</dc:creator>
      <pubDate>Sun, 19 Jul 2026 09:00:46 +0000</pubDate>
      <link>https://dev.to/serverkueche/netcup-firewall-im-scp-einrichten-mit-stateless-udp-kniff-2hl</link>
      <guid>https://dev.to/serverkueche/netcup-firewall-im-scp-einrichten-mit-stateless-udp-kniff-2hl</guid>
      <description>&lt;p&gt;netcup bringt eine &lt;strong&gt;Firewall schon vor deinem Server&lt;/strong&gt; mit – im Server Control Panel,&lt;br&gt;
noch bevor ein Paket das Betriebssystem erreicht. Richtig gebaut ist sie ein starker&lt;br&gt;
Schutzschild. Es gibt dabei aber einen Kniff, an dem viele scheitern: Die netcup-Firewall&lt;br&gt;
ist bei &lt;strong&gt;UDP zustandslos&lt;/strong&gt;. Dieses Tutorial zeigt dir eine saubere, wiederverwendbare&lt;br&gt;
Firewall – und warum DNS und NTP sonst plötzlich klemmen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was bauen wir?
&lt;/h2&gt;

&lt;p&gt;Wir bauen im &lt;strong&gt;netcup SCP&lt;/strong&gt; eine Netzwerk-Firewall aus &lt;strong&gt;komponierbaren Policy-Templates&lt;/strong&gt;:&lt;br&gt;
ein &lt;strong&gt;Basis&lt;/strong&gt;-Template (das jeder Server braucht), eins für &lt;strong&gt;SSH&lt;/strong&gt; und eins für&lt;br&gt;
&lt;strong&gt;Web (HTTP/HTTPS)&lt;/strong&gt;. Diese Bausteine weist du deinem Server nach Bedarf zu – ein&lt;br&gt;
Webserver bekommt Basis + SSH + Web, ein Server ohne Website nur Basis + SSH. Am Ende ist&lt;br&gt;
eingehend alles gesperrt außer dem, was du bewusst erlaubst.&lt;/p&gt;

&lt;p&gt;Ein netcup-spezifisches Detail macht dabei den Unterschied: Bei &lt;strong&gt;UDP arbeitet die Firewall&lt;br&gt;
zustandslos&lt;/strong&gt; – weshalb Dienste wie DNS und NTP eine eigene Eingangs-Regel brauchen, sonst&lt;br&gt;
klemmen sie plötzlich. Warum das so ist und wie das Basis-Template es löst, zeigt Schritt 2.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;ℹ️ Netzwerk-Firewall ≠ Host-Firewall&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Die netcup-Firewall arbeitet im &lt;strong&gt;Netzwerk vor dem Server&lt;/strong&gt; und ersetzt &lt;strong&gt;nicht&lt;/strong&gt; die&lt;br&gt;
Firewall auf dem Server selbst (&lt;a href="https://serverkueche.de/tutorials/firewall-ufw-einrichten/" rel="noopener noreferrer"&gt;UFW&lt;/a&gt;). Beide&lt;br&gt;
zusammen sind „Defense in Depth": Fällt eine Schicht aus oder ist falsch konfiguriert,&lt;br&gt;
greift die andere. Richte ruhig beide ein.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Voraussetzungen
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Ein netcup-Server, z. B. dein &lt;a href="https://serverkueche.de/tutorials/erste-schritte-netcup-vps/" rel="noopener noreferrer"&gt;erster VPS&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Deine &lt;strong&gt;SCP-Zugangsdaten&lt;/strong&gt; (Kundennummer + SCP-Passwort aus der netcup-Willkommensmail –
andere als dein SSH-Login).&lt;/li&gt;
&lt;li&gt;Klarheit über deinen &lt;strong&gt;SSH-Port&lt;/strong&gt;: Standard ist &lt;code&gt;22&lt;/code&gt;, und beim
&lt;a href="https://serverkueche.de/tutorials/ssh-absichern/" rel="noopener noreferrer"&gt;SSH-Absichern&lt;/a&gt; bleibt er auch dabei. Hast du ihn
anderweitig geändert, nimm deinen echten Port – sonst sperrst du dich beim
Aktivieren aus.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Schritt für Schritt
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Schritt 1: Firewall Policys im SCP öffnen
&lt;/h3&gt;

&lt;p&gt;Melde dich im &lt;a href="https://www.servercontrolpanel.de/" rel="noopener noreferrer"&gt;Server Control Panel&lt;/a&gt; an und öffne oben&lt;br&gt;
den Menüpunkt &lt;strong&gt;Firewall Policys&lt;/strong&gt;. Hier legst du wiederverwendbare Regelsätze („Policys")&lt;br&gt;
an – unabhängig vom einzelnen Server. Erst später weist du sie zu.&lt;/p&gt;

&lt;h3&gt;
  
  
  Schritt 2: Das Basis-Template bauen
&lt;/h3&gt;

&lt;p&gt;Klick auf &lt;strong&gt;Firewall Policy erstellen&lt;/strong&gt;, vergib den Namen &lt;code&gt;Serverküche Basis&lt;/code&gt; und lege über&lt;br&gt;
&lt;strong&gt;Regel hinzufügen&lt;/strong&gt; diese vier Regeln an – alle &lt;strong&gt;EINGEHEND&lt;/strong&gt; und &lt;strong&gt;ACCEPT&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Beschreibung&lt;/th&gt;
&lt;th&gt;Protokoll&lt;/th&gt;
&lt;th&gt;Quell-Port (Src)&lt;/th&gt;
&lt;th&gt;Ziel-Port (Dst)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DNS-Antworten (stateless)&lt;/td&gt;
&lt;td&gt;UDP&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;53&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;beliebig&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NTP-Antworten (stateless)&lt;/td&gt;
&lt;td&gt;UDP&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;123&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;beliebig&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ICMP (Ping/PMTU)&lt;/td&gt;
&lt;td&gt;ICMP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ICMPv6 (Neighbor Discovery)&lt;/td&gt;
&lt;td&gt;ICMPv6&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8cpz0sntlb2b3frlwopc.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%2F8cpz0sntlb2b3frlwopc.png" alt="Das Basis-Template im netcup SCP mit vier eingehenden ACCEPT-Regeln: DNS und NTP über den Quell-Port, dazu ICMP und ICMPv6" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Zwei Dinge, die hier den Unterschied machen:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DNS/NTP über den &lt;code&gt;Src Port&lt;/code&gt;, nicht den Ziel-Port.&lt;/strong&gt; Dein Server &lt;em&gt;fragt&lt;/em&gt; DNS/NTP an
(ausgehend); die &lt;strong&gt;Antwort&lt;/strong&gt; kommt von Port 53 bzw. 123 zurück. Weil UDP zustandslos ist,
brauchst du diese Eingangs-Regel – sonst gibt es keine Namensauflösung und keine
Zeitsynchronisation, obwohl „alles andere" zu funktionieren scheint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ICMPv6 nie vergessen.&lt;/strong&gt; Ohne Neighbor Discovery bricht IPv6 komplett. ICMP (v4) dazu
ist für Ping und die Pfad-MTU-Erkennung sinnvoll. netcups Default-Policy „Ping allow"
deckt ICMP zwar schon ab – im Basis-Template steht es bewusst nochmal drin, damit dein
Fundament auch dann vollständig bleibt, wenn du die netcup-Defaults einmal entfernst.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;ℹ️ Sobald eine Regel da ist, sperrt netcup den Rest automatisch&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Ein abschließendes „alles sperren" musst du &lt;strong&gt;nicht&lt;/strong&gt; anlegen. &lt;strong&gt;Ohne&lt;/strong&gt; zugewiesene Policy&lt;br&gt;
ist eingehend alles erlaubt. &lt;strong&gt;Sobald du dem Server aber mindestens eine eigene Policy&lt;br&gt;
zuweist, schaltet netcup den Default auf „block all"&lt;/strong&gt; – ab dann ist eingehend alles&lt;br&gt;
gesperrt, was keine deiner Regeln ausdrücklich erlaubt. Die Whitelist entsteht also von&lt;br&gt;
selbst; ausgehend bleibt offen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;💡 Nutzt dein Server DHCP?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;netcup-VPS sind in der Regel &lt;strong&gt;statisch&lt;/strong&gt; konfiguriert (&lt;code&gt;iface … inet static&lt;/code&gt;) – dann ist&lt;br&gt;
hier nichts zu tun. netcups Netz betreibt zwar einen DHCP-Server, aber weil UDP zustandslos&lt;br&gt;
ist, wird die DHCP-&lt;strong&gt;Antwort&lt;/strong&gt; (Quell-Port &lt;strong&gt;67&lt;/strong&gt;) von der Whitelist verworfen. Ist dein&lt;br&gt;
Server ausnahmsweise per DHCP konfiguriert, ergänze im Basis-Template&lt;br&gt;
&lt;code&gt;EINGEHEND · UDP · ACCEPT · Src 67&lt;/code&gt; (für DHCPv6 zusätzlich &lt;code&gt;Src 547&lt;/code&gt;) – sonst verliert er&lt;br&gt;
bei der nächsten Lease-Erneuerung seine IP.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Schritt 3: Kleine Bausteine für SSH und Web
&lt;/h3&gt;

&lt;p&gt;Statt eines großen Regel-Monolithen legst du &lt;strong&gt;atomare Templates&lt;/strong&gt; an, die du frei&lt;br&gt;
kombinierst. Erstelle nach demselben Muster zwei weitere Policys:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Serverküche SSH&lt;/code&gt;&lt;/strong&gt; – eine Regel: EINGEHEND, TCP, ACCEPT, Ziel-Port &lt;strong&gt;22&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Serverküche Web&lt;/code&gt;&lt;/strong&gt; – zwei Regeln: EINGEHEND, TCP, ACCEPT, Ziel-Port &lt;strong&gt;80&lt;/strong&gt; und &lt;strong&gt;443&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk0bf0hoxg505otf54545.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%2Fk0bf0hoxg505otf54545.png" alt="Die drei komponierbaren Firewall-Templates in der netcup-SCP-Übersicht: Basis, SSH und Web" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Der Gewinn: Die Basis-Regeln stehen nur &lt;strong&gt;einmal&lt;/strong&gt; da, nicht in jeder Policy dupliziert.&lt;br&gt;
Kommt später ein Dienst dazu (z. B. ein Mailserver oder WireGuard), baust du dafür ein&lt;br&gt;
weiteres kleines Template und steckst es dazu – ohne die bestehenden anzufassen.&lt;/p&gt;

&lt;h3&gt;
  
  
  Schritt 4: Templates dem Server zuweisen
&lt;/h3&gt;

&lt;p&gt;Wechsle zu &lt;strong&gt;Server → deinen Server → Reiter „Firewall"&lt;/strong&gt; und klick auf &lt;strong&gt;Firewall Policys&lt;br&gt;
editieren&lt;/strong&gt;. Schieb aus &lt;strong&gt;Verfügbare Firewall Policys&lt;/strong&gt; die passenden nach &lt;strong&gt;Ausgewählte&lt;/strong&gt;:&lt;br&gt;
für einen Webserver &lt;code&gt;Serverküche Basis&lt;/code&gt; + &lt;code&gt;Serverküche SSH&lt;/code&gt; + &lt;code&gt;Serverküche Web&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fet4fl8duyeh5nu8f9pmf.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%2Fet4fl8duyeh5nu8f9pmf.png" alt="Der Zuweisungs-Dialog im netcup SCP: links die verfügbaren Templates, rechts die für diesen Server ausgewählten" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Bestätige mit &lt;strong&gt;Übernehmen&lt;/strong&gt; und dann &lt;strong&gt;Speichern&lt;/strong&gt;. Prüfe, dass der Schalter &lt;strong&gt;„Firewall&lt;br&gt;
aktiv"&lt;/strong&gt; eingeschaltet ist. Die zugewiesenen Regeln erscheinen jetzt in der Liste –&lt;br&gt;
zusammen mit netcups &lt;strong&gt;Default-Policys&lt;/strong&gt; (dazu unten mehr).&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%2Fuhlgd12r1iyj26r6of7a.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%2Fuhlgd12r1iyj26r6of7a.png" alt="Der Firewall-Reiter des Servers mit aktiver Firewall und den zugewiesenen Regeln" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠️ Nicht aussperren&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Aktiviere die Firewall nur, wenn die &lt;strong&gt;SSH-Regel&lt;/strong&gt; (Port 22 bzw. dein echter Port) drin&lt;br&gt;
ist. Lass während der Umstellung eine &lt;strong&gt;zweite SSH-Sitzung offen&lt;/strong&gt; und öffne parallel eine&lt;br&gt;
neue Verbindung, um den Zugang zu testen – erst wenn die klappt, die alte Sitzung&lt;br&gt;
schließen. Kommst du gar nicht mehr rein, hilft die VNC-Konsole unter &lt;strong&gt;Bildschirm&lt;/strong&gt; im SCP.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Schritt 5: Fertige Templates für gängige Dienste
&lt;/h3&gt;

&lt;p&gt;Genau nach dem Muster aus Schritt 3 folgen hier fertige Vorlagen für die häufigsten&lt;br&gt;
Selfhosting-Dienste. Jede ist ein &lt;strong&gt;eigenes kleines Template&lt;/strong&gt;, alle Regeln sind&lt;br&gt;
&lt;strong&gt;EINGEHEND&lt;/strong&gt; und &lt;strong&gt;ACCEPT&lt;/strong&gt;. Bau nur die, die du wirklich brauchst, und steck sie wie in&lt;br&gt;
Schritt 4 zu &lt;code&gt;Serverküche Basis&lt;/code&gt; + &lt;code&gt;Serverküche SSH&lt;/code&gt; dazu.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;ℹ️ Bei UDP-Diensten: Ziel-Port, nicht Quell-Port&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Für UDP-Dienste, die du &lt;strong&gt;selbst anbietest&lt;/strong&gt; (VPN, TURN, eigener DNS-Server), steht der&lt;br&gt;
Port im &lt;strong&gt;Ziel-Port (Dst)&lt;/strong&gt; – denn hier verbinden sich Clients &lt;em&gt;zu deinem Server&lt;/em&gt;. Das ist&lt;br&gt;
das &lt;strong&gt;Gegenteil&lt;/strong&gt; der DNS/NTP-Regeln aus dem Basis-Template: Dort steht der Port im&lt;br&gt;
&lt;strong&gt;Quell-Port (Src)&lt;/strong&gt;, weil dein Server dort der &lt;em&gt;fragende Client&lt;/em&gt; ist, dessen Antwort&lt;br&gt;
zurückkommt. TCP-Antworten sind davon nicht betroffen (der Firewall-Zustand spielt nur bei&lt;br&gt;
UDP keine Rolle); ausgehend ist ohnehin alles erlaubt.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h4&gt;
  
  
  Mailserver (mailcow &amp;amp; Co.)
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Beschreibung&lt;/th&gt;
&lt;th&gt;Protokoll&lt;/th&gt;
&lt;th&gt;Quell-Port (Src)&lt;/th&gt;
&lt;th&gt;Ziel-Port (Dst)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SMTP (Mailannahme anderer Server)&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;25&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Submission (STARTTLS)&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;587&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Submission (implizites TLS)&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;465&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IMAP (STARTTLS)&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;143&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IMAPS&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;993&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;POP3 (STARTTLS)&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;110&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;POP3S&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;995&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ManageSieve (Filterregeln)&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4190&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Zwei Dinge, die beim Mailserver &lt;strong&gt;zusätzlich&lt;/strong&gt; dazugehören:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Web-UI und Zertifikate:&lt;/strong&gt; mailcow braucht &lt;code&gt;80&lt;/code&gt;/&lt;code&gt;443&lt;/code&gt; für das Webinterface und den
Let's-Encrypt-Abruf – dafür einfach das &lt;code&gt;Serverküche Web&lt;/code&gt;-Template mit zuweisen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ausgehendes SMTP freischalten:&lt;/strong&gt; Ein Mailserver muss auf &lt;strong&gt;Port 25 ausgehend&lt;/strong&gt;
zustellen können. netcup sperrt das per Default-Policy „netcup Mail block" – die musst du
am Server &lt;strong&gt;entfernen&lt;/strong&gt; (siehe „Wenn es nicht funktioniert"). Und ohne korrekten
&lt;a href="https://serverkueche.de/tutorials/domain-mit-server-verbinden/" rel="noopener noreferrer"&gt;PTR-/Reverse-DNS-Eintrag&lt;/a&gt; landen deine Mails
im Spam.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Beschreibung&lt;/th&gt;
&lt;th&gt;Protokoll&lt;/th&gt;
&lt;th&gt;Quell-Port (Src)&lt;/th&gt;
&lt;th&gt;Ziel-Port (Dst)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;WireGuard&lt;/td&gt;
&lt;td&gt;UDP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;51820&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;51820&lt;/code&gt; ist der übliche Standard – nimm den Wert aus deinem &lt;code&gt;ListenPort&lt;/code&gt;. Nur diese eine&lt;br&gt;
eingehende Regel ist nötig; die Antworten an die Clients gehen ausgehend raus.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Beschreibung&lt;/th&gt;
&lt;th&gt;Protokoll&lt;/th&gt;
&lt;th&gt;Quell-Port (Src)&lt;/th&gt;
&lt;th&gt;Ziel-Port (Dst)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;OpenVPN (UDP, Standard)&lt;/td&gt;
&lt;td&gt;UDP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1194&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenVPN (TCP-Fallback)&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1194&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Standard ist &lt;strong&gt;UDP 1194&lt;/strong&gt;. Die TCP-Regel nur, wenn du OpenVPN bewusst über TCP betreibst&lt;br&gt;
(manche legen es zusätzlich auf &lt;code&gt;TCP 443&lt;/code&gt;, um durch restriktive fremde Netze zu kommen) –&lt;br&gt;
sonst weglassen.&lt;/p&gt;

&lt;h4&gt;
  
  
  Zabbix-Monitoring
&lt;/h4&gt;

&lt;p&gt;Hier kommt es auf die Rolle des Servers an:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rolle&lt;/th&gt;
&lt;th&gt;Beschreibung&lt;/th&gt;
&lt;th&gt;Protokoll&lt;/th&gt;
&lt;th&gt;Ziel-Port (Dst)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Überwachter Host&lt;/strong&gt; (Zabbix-Agent)&lt;/td&gt;
&lt;td&gt;passive Checks vom Server&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10050&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Zabbix-Server&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Trapper (aktive Agenten/Proxies)&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10051&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Der Zabbix-Server braucht zusätzlich &lt;code&gt;80&lt;/code&gt;/&lt;code&gt;443&lt;/code&gt; fürs Web-Frontend (&lt;code&gt;Serverküche Web&lt;/code&gt;).&lt;br&gt;
Betreibst du &lt;strong&gt;aktive&lt;/strong&gt; Checks, verbindet sich der Agent ausgehend zum Server auf &lt;code&gt;10051&lt;/code&gt; –&lt;br&gt;
dafür ist am Agenten-Host &lt;strong&gt;keine&lt;/strong&gt; eingehende Regel nötig.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rolle&lt;/th&gt;
&lt;th&gt;Beschreibung&lt;/th&gt;
&lt;th&gt;Protokoll&lt;/th&gt;
&lt;th&gt;Ziel-Port (Dst)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Überwachter Host&lt;/strong&gt; (Agent)&lt;/td&gt;
&lt;td&gt;Agent-Controller, Pull-Modus&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6556&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Checkmk-Server&lt;/strong&gt; (optional)&lt;/td&gt;
&lt;td&gt;Agent-Receiver (Push/Registrierung)&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8000&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Checkmk-Server&lt;/strong&gt; (optional)&lt;/td&gt;
&lt;td&gt;Livestatus (verteiltes Monitoring)&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6557&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Im &lt;strong&gt;Standard-Pull-Modus&lt;/strong&gt; holt der Checkmk-Server die Daten aktiv ab – er verbindet sich&lt;br&gt;
also ausgehend zu &lt;code&gt;6556&lt;/code&gt; der Agenten. Eingehend braucht deshalb nur der &lt;strong&gt;überwachte Host&lt;/strong&gt;&lt;br&gt;
den Port &lt;code&gt;6556&lt;/code&gt;. Der Checkmk-Server selbst kommt mit &lt;code&gt;80&lt;/code&gt;/&lt;code&gt;443&lt;/code&gt; (&lt;code&gt;Serverküche Web&lt;/code&gt;) aus;&lt;br&gt;
&lt;code&gt;8000&lt;/code&gt; und &lt;code&gt;6557&lt;/code&gt; nur, wenn du Push-Modus bzw. verteiltes Monitoring nutzt.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Beschreibung&lt;/th&gt;
&lt;th&gt;Protokoll&lt;/th&gt;
&lt;th&gt;Quell-Port (Src)&lt;/th&gt;
&lt;th&gt;Ziel-Port (Dst)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;STUN/TURN&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3478&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;STUN/TURN&lt;/td&gt;
&lt;td&gt;UDP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3478&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;STUN/TURN über TLS&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5349&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;STUN/TURN über TLS&lt;/td&gt;
&lt;td&gt;UDP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5349&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Medien-Relay (Audio/Video)&lt;/td&gt;
&lt;td&gt;UDP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;49152–65535&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Die große UDP-Relay-Range ist der Standard – über sie laufen die eigentlichen&lt;br&gt;
Audio-/Video-Streams. Du kannst sie in der coturn-Konfiguration (&lt;code&gt;min-port&lt;/code&gt;/&lt;code&gt;max-port&lt;/code&gt;)&lt;br&gt;
enger fassen und die Firewall-Regel dann auf denselben, kleineren Bereich setzen.&lt;/p&gt;

&lt;h4&gt;
  
  
  Eigener DNS-Server (AdGuard Home, Pi-hole, Unbound)
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Beschreibung&lt;/th&gt;
&lt;th&gt;Protokoll&lt;/th&gt;
&lt;th&gt;Quell-Port (Src)&lt;/th&gt;
&lt;th&gt;Ziel-Port (Dst)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DNS-Anfragen&lt;/td&gt;
&lt;td&gt;UDP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;53&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DNS-Anfragen (große Antworten/TCP)&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;53&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DNS-over-TLS (DoT)&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;853&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Achtung, das ist genau die &lt;strong&gt;Gegenrichtung&lt;/strong&gt; zum Basis-Template: Dort erlaubt &lt;code&gt;Src 53&lt;/code&gt; die&lt;br&gt;
&lt;em&gt;Antworten&lt;/em&gt; auf deine eigenen DNS-Anfragen; hier erlaubt &lt;code&gt;Dst 53&lt;/code&gt; die &lt;em&gt;Anfragen fremder&lt;br&gt;
Clients&lt;/em&gt; an deinen DNS-Server. Beides existiert problemlos nebeneinander.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠️ Kein offener Resolver&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Ein weltweit offener rekursiver DNS-Server wird für &lt;strong&gt;DNS-Amplification-Angriffe&lt;/strong&gt;&lt;br&gt;
missbraucht. Beschränke den Zugriff auf deine eigenen Netze (Quell-Adresse in der Regel&lt;br&gt;
setzen) oder biete DNS ausschließlich verschlüsselt (DoT/DoH) für angemeldete Clients an –&lt;br&gt;
öffne &lt;code&gt;53&lt;/code&gt; nie „mal eben" für die ganze Welt.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h4&gt;
  
  
  Datenbank-Fernzugriff (nur im Notfall)
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Beschreibung&lt;/th&gt;
&lt;th&gt;Protokoll&lt;/th&gt;
&lt;th&gt;Quell-Port (Src)&lt;/th&gt;
&lt;th&gt;Ziel-Port (Dst)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5432&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MySQL / MariaDB&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3306&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;🛑 Datenbanken gehören nicht ins offene Internet&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Eine direkt erreichbare Datenbank ist ein bevorzugtes Angriffsziel. Der &lt;strong&gt;richtige&lt;/strong&gt; Weg&lt;br&gt;
ist, den Port &lt;strong&gt;gar nicht&lt;/strong&gt; zu öffnen und stattdessen über einen&lt;br&gt;
WireGuard-Tunnel oder einen SSH-Tunnel&lt;br&gt;
(&lt;code&gt;ssh -L 5432:localhost:5432 …&lt;/code&gt;) zuzugreifen. Musst du den Port trotzdem freigeben, dann&lt;br&gt;
&lt;strong&gt;nur&lt;/strong&gt; mit einer auf deine feste Admin-IP eingeschränkten Quell-Adresse in der&lt;br&gt;
netcup-Regel – niemals für alle.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Wenn es nicht funktioniert
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; Nach dem Aktivieren geht keine Namensauflösung mehr (&lt;code&gt;apt update&lt;/code&gt; hängt,&lt;br&gt;
&lt;code&gt;ping domain.de&lt;/code&gt; scheitert, aber &lt;code&gt;ping 1.1.1.1&lt;/code&gt; geht).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ursache &amp;amp; Lösung:&lt;/strong&gt; Die DNS-&lt;strong&gt;Antworten&lt;/strong&gt; werden geblockt. Prüfe im Basis-Template die&lt;br&gt;
Regel &lt;em&gt;EINGEHEND UDP ACCEPT, Src-Port 53&lt;/em&gt;. Wichtig: &lt;strong&gt;Quell&lt;/strong&gt;-Port, nicht Ziel-Port.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; Die Uhr driftet, oder TLS-Zertifikate werden wegen falscher Zeit abgelehnt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ursache &amp;amp; Lösung:&lt;/strong&gt; Die NTP-Antworten fehlen. Ergänze &lt;em&gt;EINGEHEND UDP ACCEPT, Src-Port 123&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; IPv6 funktioniert nicht mehr (v4 schon).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ursache &amp;amp; Lösung:&lt;/strong&gt; Es fehlt die &lt;strong&gt;ICMPv6&lt;/strong&gt;-Regel. Ohne Neighbor Discovery kann der&lt;br&gt;
Server über IPv6 nicht einmal seinen Nachbarn (Router) finden.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; Du kommst nach dem Aktivieren nicht mehr per SSH rein.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ursache &amp;amp; Lösung:&lt;/strong&gt; Die SSH-Regel fehlt oder nennt den falschen Port. Über die&lt;br&gt;
&lt;strong&gt;VNC-Konsole&lt;/strong&gt; (SCP → Bildschirm) kommst du trotzdem auf den Server; korrigiere die Policy&lt;br&gt;
und speichere neu. Genau dagegen hilft die „zweite Sitzung offen lassen"-Regel oben.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; Der Server kann keine E-Mails versenden (Port 25 raus geht nicht).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ursache &amp;amp; Lösung:&lt;/strong&gt; Das ist &lt;strong&gt;kein&lt;/strong&gt; Fehler deiner Policy, sondern netcups&lt;br&gt;
&lt;strong&gt;Default-Policy „netcup Mail block"&lt;/strong&gt; – sie sperrt ausgehendes SMTP (Port 25/465/587) als&lt;br&gt;
Spam-Schutz. Für einen echten Mailversand musst du dieses netcup-Template am Server deaktivieren.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; Der Server läuft zunächst normal und ist nach ein bis zwei Wochen plötzlich&lt;br&gt;
offline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ursache &amp;amp; Lösung:&lt;/strong&gt; Nutzt der Server DHCP, wird die &lt;strong&gt;Lease-Erneuerung&lt;/strong&gt; von der Whitelist&lt;br&gt;
geblockt – die DHCP-Antwort kommt von UDP-Quell-Port &lt;strong&gt;67&lt;/strong&gt;, den keine Regel erlaubt, und&lt;br&gt;
UDP ist zustandslos. Ergänze &lt;code&gt;EINGEHEND · UDP · ACCEPT · Src 67&lt;/code&gt; im Basis-Template.&lt;br&gt;
Statisch konfigurierte netcup-VPS (der Standard) sind davon nicht betroffen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wartung &amp;amp; Backups
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nach jeder Änderung den Zugang testen.&lt;/strong&gt; Firewall-Regeln sind der klassische Weg, sich
selbst auszusperren. Zweite Sitzung offen halten, neue Verbindung prüfen, erst dann fertig.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Neue Dienste = neues Template.&lt;/strong&gt; Brauchst du später einen weiteren Port (Game-Server,
WireGuard-VPN über UDP, Datenbank), leg ein kleines eigenes Template an und weise es
zusätzlich zu. Die bestehenden Bausteine bleiben unberührt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reihenfolge beachten.&lt;/strong&gt; netcup wertet die Regeln &lt;strong&gt;von oben nach unten&lt;/strong&gt; aus; die
&lt;strong&gt;erste&lt;/strong&gt; zutreffende Regel greift. Bei reinen ACCEPT-Regeln ist das egal – sobald du aber
eigene DROP-Regeln einsetzt, achte auf die Reihenfolge relativ zu den ACCEPTs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;netcups Default-Policys kennen.&lt;/strong&gt; „netcup Mail block" (ausgehendes SMTP auf Port
25/465/587 gesperrt) und „netcup Ping allow" sind vorgegeben. Über &lt;strong&gt;Default Policys
wiederherstellen&lt;/strong&gt; kommst du im Notfall auf einen bekannten, funktionierenden
Grundzustand zurück, falls du dich mit eigenen Regeln ausgesperrt hast – zusammen mit
der VNC-Konsole dein zweites Sicherheitsnetz.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Dieser Beitrag erschien zuerst auf &lt;a href="https://serverkueche.de/tutorials/netcup-firewall-einrichten/" rel="noopener noreferrer"&gt;serverkueche.de&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Docker Compose verstehen: Services, Volumes, Netzwerke</title>
      <dc:creator>serverkueche.de</dc:creator>
      <pubDate>Fri, 17 Jul 2026 22:10:57 +0000</pubDate>
      <link>https://dev.to/serverkueche/docker-compose-verstehen-services-volumes-netzwerke-11je</link>
      <guid>https://dev.to/serverkueche/docker-compose-verstehen-services-volumes-netzwerke-11je</guid>
      <description>&lt;p&gt;Im &lt;a href="https://serverkueche.de/tutorials/docker-installieren/" rel="noopener noreferrer"&gt;Docker-Tutorial&lt;/a&gt; hast du das Compose-Plugin&lt;br&gt;
installiert – erklärt haben wir es noch nicht. Das holen wir jetzt nach, denn fast&lt;br&gt;
jedes App-Rezept hier beschreibt eine Anwendung als &lt;strong&gt;&lt;code&gt;compose.yaml&lt;/code&gt;&lt;/strong&gt;. Wer diese&lt;br&gt;
Datei liest wie einen Einkaufszettel, kann jedes folgende Tutorial anpassen, statt&lt;br&gt;
nur zu kopieren.&lt;/p&gt;
&lt;h2&gt;
  
  
  Was bauen wir?
&lt;/h2&gt;

&lt;p&gt;Wir bauen Schritt für Schritt einen kleinen Stack auf und lernen dabei die fünf&lt;br&gt;
Bausteine kennen, aus denen praktisch jede &lt;code&gt;compose.yaml&lt;/code&gt; besteht: &lt;strong&gt;Services&lt;/strong&gt;&lt;br&gt;
(die Container), &lt;strong&gt;Ports&lt;/strong&gt; (Erreichbarkeit von außen), &lt;strong&gt;Volumes&lt;/strong&gt; (persistente&lt;br&gt;
Daten), &lt;strong&gt;Netzwerke&lt;/strong&gt; (Container reden miteinander) und &lt;strong&gt;Umgebungsvariablen&lt;/strong&gt;&lt;br&gt;
(Konfiguration). Am Ende verstehst du, warum deine Daten einen &lt;code&gt;down&lt;/code&gt;-Befehl&lt;br&gt;
überleben – und wann nicht.&lt;/p&gt;

&lt;p&gt;Getestet mit &lt;strong&gt;Docker Compose v5.3&lt;/strong&gt; (das &lt;code&gt;docker compose&lt;/code&gt;-Plugin ohne Bindestrich –&lt;br&gt;
nicht das alte &lt;code&gt;docker-compose&lt;/code&gt; v1 mit Bindestrich).&lt;/p&gt;
&lt;h2&gt;
  
  
  Voraussetzungen
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Ein &lt;a href="https://serverkueche.de/tutorials/ssh-absichern/" rel="noopener noreferrer"&gt;abgesicherter Server&lt;/a&gt; mit &lt;a href="https://serverkueche.de/tutorials/docker-installieren/" rel="noopener noreferrer"&gt;installiertem
Docker&lt;/a&gt; und Compose-Plugin&lt;/li&gt;
&lt;li&gt;Der Benutzer ist in der &lt;code&gt;docker&lt;/code&gt;-Gruppe (dann brauchst du kein &lt;code&gt;sudo&lt;/code&gt; vor
&lt;code&gt;docker&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Schritt für Schritt
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Schritt 1: Die erste compose.yaml
&lt;/h3&gt;

&lt;p&gt;Eine &lt;code&gt;compose.yaml&lt;/code&gt; beschreibt &lt;strong&gt;deklarativ&lt;/strong&gt;, welche Container laufen sollen – du&lt;br&gt;
sagst &lt;em&gt;was&lt;/em&gt; du willst, nicht &lt;em&gt;wie&lt;/em&gt;. Lege einen Projektordner an; der Ordnername&lt;br&gt;
wird später zum Präfix aller Container:&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;Lege eine kleine HTML-Seite an, die wir gleich ausliefern:&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;Und jetzt die zentrale Datei &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;Zeile für Zeile:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;services:&lt;/code&gt;&lt;/strong&gt; – die oberste Ebene. Jeder Eintrag darunter ist ein Container.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;web:&lt;/code&gt;&lt;/strong&gt; – ein frei wählbarer &lt;strong&gt;Service-Name&lt;/strong&gt;. Merke ihn dir, er wird später
auch zum Hostnamen im internen Netzwerk (Schritt 3).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;image: nginx:1.31&lt;/code&gt;&lt;/strong&gt; – das Container-Image mit &lt;strong&gt;festem Tag&lt;/strong&gt;. Nie &lt;code&gt;latest&lt;/code&gt;
verwenden: &lt;code&gt;latest&lt;/code&gt; ändert sich unter dir und macht Fehler unreproduzierbar.&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 im Container&lt;/strong&gt; wird
auf &lt;strong&gt;8080 des Servers&lt;/strong&gt; gelegt. Links steht immer der Server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;volumes: - ./site:...:ro&lt;/code&gt;&lt;/strong&gt; – der lokale Ordner &lt;code&gt;site&lt;/code&gt; wird ins Web-Root
gehängt, &lt;code&gt;:ro&lt;/code&gt; = read-only. Mehr dazu in Schritt 2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;restart: unless-stopped&lt;/code&gt;&lt;/strong&gt; – der Container startet nach einem Reboot oder
Absturz automatisch neu, außer du hast ihn selbst gestoppt. Für Server-Dienste
der sinnvolle Standard. Die Alternativen: &lt;code&gt;no&lt;/code&gt; (nie automatisch – der Default),
&lt;code&gt;always&lt;/code&gt; (startet selbst nach einem manuellen Stopp wieder, selten gewollt) und
&lt;code&gt;on-failure&lt;/code&gt; (nur nach einem Absturz mit Fehlercode). Für die allermeisten Dienste
ist &lt;code&gt;unless-stopped&lt;/code&gt; genau richtig.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Starte den 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; bedeutet &lt;strong&gt;detached&lt;/strong&gt; (im Hintergrund). Beim ersten Mal lädt Docker das Image;&lt;br&gt;
danach siehst du am Ende:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Network compose-demo_default  Created
 Container compose-demo-web-1  Started
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prüfe den 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;Der Container heißt &lt;code&gt;compose-demo-web-1&lt;/code&gt; – &lt;strong&gt;Projektordner + Service + Nummer&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;Läuft. Logs eines Dienstes siehst du mit &lt;code&gt;docker compose logs web&lt;/code&gt; (oder &lt;code&gt;-f&lt;/code&gt; zum&lt;br&gt;
Mitlaufen).&lt;/p&gt;
&lt;h3&gt;
  
  
  Schritt 2: Volumes – wo deine Daten wirklich liegen
&lt;/h3&gt;

&lt;p&gt;Container sind &lt;strong&gt;vergänglich&lt;/strong&gt;: Löschst du einen Container, ist alles weg, was&lt;br&gt;
&lt;em&gt;im&lt;/em&gt; Container geschrieben wurde. Damit Daten das überleben, gibt es zwei Arten von&lt;br&gt;
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;): ein &lt;strong&gt;Ordner von deinem Server&lt;/strong&gt;
wird in den Container gehängt. Ideal für Config-Dateien, die du selbst bearbeitest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Named Volume&lt;/strong&gt; (&lt;code&gt;webdata:/var/lib/...&lt;/code&gt;): ein von &lt;strong&gt;Docker verwalteter&lt;/strong&gt;
Speicher. Ideal für Datenbank-Daten – performant und sauber getrennt vom Host.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In Schritt 1 haben wir ein Bind-Mount genutzt. Für datenbankartige Dienste sieht&lt;br&gt;
es so aus – ändere &lt;code&gt;compose.yaml&lt;/code&gt; testweise:&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 müssen &lt;strong&gt;zusätzlich&lt;/strong&gt; auf oberster Ebene unter &lt;code&gt;volumes:&lt;/code&gt; deklariert&lt;br&gt;
werden. Nach &lt;code&gt;docker compose up -d&lt;/code&gt; taucht das Volume auf:&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;Der entscheidende Punkt kommt jetzt:&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; Container compose-demo-web-1  Removed
 Network compose-demo_default  Removed
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; entfernt Container und Netzwerk – &lt;strong&gt;das Volume bleibt&lt;/strong&gt;. Genau&lt;br&gt;
deshalb überleben deine Datenbank-Inhalte ein Update. Merke dir das Gegenstück:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;🛑 down -v löscht Daten&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;docker compose down -v&lt;/code&gt; löscht &lt;strong&gt;auch die Named Volumes&lt;/strong&gt; – also alle persistenten&lt;br&gt;
Daten des Stacks. Tippe das &lt;code&gt;-v&lt;/code&gt; nie aus Reflex mit. Für ein reines Neustarten&lt;br&gt;
reicht &lt;code&gt;docker compose down&lt;/code&gt; (ohne &lt;code&gt;-v&lt;/code&gt;).&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;
  
  
  Schritt 3: Netzwerke – Container reden miteinander
&lt;/h3&gt;

&lt;p&gt;Compose legt automatisch &lt;strong&gt;ein Netzwerk pro Projekt&lt;/strong&gt; an (oben gesehen:&lt;br&gt;
&lt;code&gt;compose-demo_default&lt;/code&gt;). Alle Services darin erreichen sich &lt;strong&gt;über ihren&lt;br&gt;
Service-Namen&lt;/strong&gt; als Hostname – kein IP-Gefummel nötig. Das ist der Grund, warum in&lt;br&gt;
App-Tutorials die Anwendung ihre Datenbank einfach unter &lt;code&gt;db&lt;/code&gt; erreicht.&lt;/p&gt;

&lt;p&gt;Zum Beweis ein zweiter Service, der den ersten anspricht:&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.21.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 startet &lt;code&gt;web&lt;/code&gt; &lt;strong&gt;vor&lt;/strong&gt; &lt;code&gt;ping&lt;/code&gt;. (Achtung: das
wartet nur auf den &lt;em&gt;Start&lt;/em&gt;, nicht auf „fertig hochgefahren" – dafür gibt es
Healthchecks, Schritt 4.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;command:&lt;/code&gt;&lt;/strong&gt; – überschreibt den Standardbefehl des Images. &lt;code&gt;ping&lt;/code&gt; ruft
&lt;code&gt;http://web&lt;/code&gt; auf – &lt;strong&gt;&lt;code&gt;web&lt;/code&gt; ist der Service-Name aus derselben Datei&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Führe nur den &lt;code&gt;ping&lt;/code&gt;-Service einmalig aus:&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; hat &lt;code&gt;web&lt;/code&gt; allein über den Namen erreicht – ganz ohne Port-Mapping. &lt;strong&gt;Merke:&lt;br&gt;
&lt;code&gt;ports:&lt;/code&gt; brauchst du nur, um einen Dienst von &lt;em&gt;außen&lt;/em&gt; (dem Internet) erreichbar zu&lt;br&gt;
machen.&lt;/strong&gt; Container untereinander reden über das interne Netzwerk – später lassen&lt;br&gt;
wir deshalb Datenbanken bewusst &lt;em&gt;ohne&lt;/em&gt; &lt;code&gt;ports:&lt;/code&gt; laufen.&lt;/p&gt;

&lt;p&gt;Bisher lebt jedes Netzwerk &lt;strong&gt;innerhalb&lt;/strong&gt; eines Compose-Projekts. Manchmal sollen aber&lt;br&gt;
Container aus &lt;strong&gt;verschiedenen&lt;/strong&gt; Projekten miteinander reden – das klassische Beispiel ist&lt;br&gt;
ein &lt;strong&gt;Reverse Proxy&lt;/strong&gt;, der vor vielen unabhängigen App-Stacks sitzt. Dafür gibt es das&lt;br&gt;
&lt;strong&gt;externe Netzwerk&lt;/strong&gt;: eines, das du &lt;strong&gt;einmalig von Hand&lt;/strong&gt; anlegst und das danach mehrere&lt;br&gt;
Compose-Projekte gemeinsam nutzen:&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 proxy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In der &lt;code&gt;compose.yaml&lt;/code&gt; legst du es dann nicht neu an, sondern verweist mit &lt;code&gt;external: true&lt;/code&gt;&lt;br&gt;
auf das bereits bestehende Netzwerk:&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; sagt Compose: „Dieses Netzwerk existiert schon – leg es &lt;strong&gt;nicht&lt;/strong&gt; an und&lt;br&gt;
lösch es beim &lt;code&gt;down&lt;/code&gt; &lt;strong&gt;nicht&lt;/strong&gt;." Fehlt das Netzwerk, bricht &lt;code&gt;up&lt;/code&gt; mit &lt;code&gt;network proxy&lt;br&gt;
declared as external, but could not be found&lt;/code&gt; ab – dann hast du das &lt;code&gt;docker network create&lt;/code&gt;&lt;br&gt;
vergessen. Genau dieses Muster – ein gemeinsames &lt;code&gt;proxy&lt;/code&gt;-Netz plus &lt;code&gt;external: true&lt;/code&gt; – ist&lt;br&gt;
die Grundlage des &lt;a href="https://serverkueche.de/tutorials/reverse-proxy-traefik/" rel="noopener noreferrer"&gt;Traefik-Tutorials&lt;/a&gt;, mit dem jede App&lt;br&gt;
später ihre Domain und ihr HTTPS bekommt.&lt;/p&gt;
&lt;h3&gt;
  
  
  Schritt 4: Konfiguration – Umgebungsvariablen, .env und Healthchecks
&lt;/h3&gt;

&lt;p&gt;Fast jede Anwendung wird über &lt;strong&gt;Umgebungsvariablen&lt;/strong&gt; konfiguriert. Zwei Wege:&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;Geheimnisse (Passwörter, Tokens) gehören &lt;strong&gt;nicht&lt;/strong&gt; in die &lt;code&gt;compose.yaml&lt;/code&gt;, sondern in&lt;br&gt;
eine &lt;code&gt;.env&lt;/code&gt;-Datei im selben Ordner. Compose liest sie automatisch:&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;⚠️ &lt;code&gt;.env&lt;/code&gt; nie ins Backup-Repo pushen&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Die &lt;code&gt;.env&lt;/code&gt; enthält Klartext-Geheimnisse. Nimm sie in eine &lt;code&gt;.gitignore&lt;/code&gt; auf, falls du&lt;br&gt;
deine Compose-Dateien versionierst, und sichere sie getrennt (verschlüsselt).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So prüfst du, ob Compose deine Datei versteht &lt;strong&gt;und&lt;/strong&gt; die &lt;code&gt;.env&lt;/code&gt;-Werte richtig&lt;br&gt;
einsetzt – ohne etwas zu starten:&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; löst alle Variablen auf und gibt die fertige, normalisierte Konfiguration&lt;br&gt;
aus:&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Steht dort dein &lt;strong&gt;echter&lt;/strong&gt; Wert statt &lt;code&gt;${DB_PASSWORD}&lt;/code&gt;, greift die &lt;code&gt;.env&lt;/code&gt;. Brauchst&lt;br&gt;
du nur einen schnellen Syntax-Check ohne die ganze Ausgabe, nimm&lt;br&gt;
&lt;code&gt;docker compose config --quiet&lt;/code&gt; – kommt nichts zurück (Exit-Code &lt;code&gt;0&lt;/code&gt;), ist die Datei&lt;br&gt;
gültig. Genau das ist auch dein erster Griff bei YAML-Fehlern (siehe unten).&lt;/p&gt;

&lt;p&gt;Ein &lt;strong&gt;Healthcheck&lt;/strong&gt; sagt Docker, wann ein Dienst wirklich bereit ist – die Basis&lt;br&gt;
dafür, dass abhängige Dienste erst dann starten:&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;Mit &lt;code&gt;condition: service_healthy&lt;/code&gt; startet &lt;code&gt;app&lt;/code&gt; erst, wenn der Healthcheck von &lt;code&gt;db&lt;/code&gt;&lt;br&gt;
grün ist – das häufigste „warum verbindet sich meine App nicht zur Datenbank?"&lt;br&gt;
verschwindet damit. Die vier Healthcheck-Felder bedeuten: &lt;strong&gt;&lt;code&gt;test&lt;/code&gt;&lt;/strong&gt; ist der Befehl,&lt;br&gt;
der im Container läuft (Exit-Code &lt;code&gt;0&lt;/code&gt; = gesund), &lt;strong&gt;&lt;code&gt;interval&lt;/code&gt;&lt;/strong&gt; der Abstand zwischen&lt;br&gt;
den Prüfungen, &lt;strong&gt;&lt;code&gt;timeout&lt;/code&gt;&lt;/strong&gt; wie lange eine Prüfung dauern darf, und &lt;strong&gt;&lt;code&gt;retries&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
wie viele Fehlversuche in Folge nötig sind, bevor der Container als &lt;code&gt;unhealthy&lt;/code&gt; gilt.&lt;br&gt;
Den aktuellen Zustand zeigt die &lt;code&gt;STATUS&lt;/code&gt;-Spalte von &lt;code&gt;docker compose ps&lt;/code&gt; als&lt;br&gt;
&lt;code&gt;(healthy)&lt;/code&gt; bzw. &lt;code&gt;(unhealthy)&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  Schritt 5: Der Betriebs-Werkzeugkasten
&lt;/h3&gt;

&lt;p&gt;Diese Befehle brauchst du täglich – immer &lt;strong&gt;im Projektordner&lt;/strong&gt; ausführen:&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;# starten / Änderungen anwenden&lt;/span&gt;
docker compose ps           &lt;span class="c"&gt;# Status der Services&lt;/span&gt;
docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt; web  &lt;span class="c"&gt;# Logs live mitlesen (Strg+C beendet nur das Ansehen)&lt;/span&gt;
docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;web sh  &lt;span class="c"&gt;# Shell im laufenden Container&lt;/span&gt;
docker compose restart web  &lt;span class="c"&gt;# einen einzelnen Dienst neu starten&lt;/span&gt;
docker compose stop         &lt;span class="c"&gt;# anhalten, ohne Container/Netzwerk zu entfernen&lt;/span&gt;
docker compose pull         &lt;span class="c"&gt;# neue Image-Versionen holen&lt;/span&gt;
docker compose down         &lt;span class="c"&gt;# Stack stoppen und entfernen (Volumes bleiben)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fast alle Befehle lassen sich auf &lt;strong&gt;einen&lt;/strong&gt; Service einschränken, indem du seinen&lt;br&gt;
Namen anhängst (&lt;code&gt;docker compose logs -f web&lt;/code&gt;, &lt;code&gt;docker compose restart web&lt;/code&gt;) – ohne&lt;br&gt;
Namen gelten sie für den ganzen Stack. Der Unterschied zwischen &lt;code&gt;stop&lt;/code&gt; und &lt;code&gt;down&lt;/code&gt;:&lt;br&gt;
&lt;code&gt;stop&lt;/code&gt; hält die Container nur an (mit &lt;code&gt;start&lt;/code&gt; geht's weiter), &lt;code&gt;down&lt;/code&gt; entfernt sie&lt;br&gt;
samt Netzwerk (die Named Volumes bleiben in beiden Fällen).&lt;/p&gt;

&lt;p&gt;Ein Update läuft fast immer nach demselben Muster: Tag in der &lt;code&gt;compose.yaml&lt;/code&gt;&lt;br&gt;
hochsetzen → &lt;code&gt;docker compose pull&lt;/code&gt; → &lt;code&gt;docker compose up -d&lt;/code&gt;. Compose ersetzt nur die&lt;br&gt;
Container, deren Image sich geändert hat.&lt;/p&gt;
&lt;h3&gt;
  
  
  Schritt 6: Alles zusammen – ein realistischer App-Stack
&lt;/h3&gt;

&lt;p&gt;So sieht das Muster aus, das dir in den App-Tutorials immer wieder begegnet: eine&lt;br&gt;
Anwendung plus ihre Datenbank. Diese Datei bündelt alles aus den Schritten 1–4 –&lt;br&gt;
lies sie einmal komplett, dann hast du 90 % jeder späteren &lt;code&gt;compose.yaml&lt;/code&gt;&lt;br&gt;
verstanden:&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;# feste Version, kein 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;# nur die App ist von außen erreichbar&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;# persistente App-Daten (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;# startet erst, wenn db bereit ist&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;# die eigentlichen Datenbank-Dateien&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;# kein ports: – die Datenbank ist NUR intern über den Namen "db" erreichbar&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;Drei Design-Entscheidungen, die du dir merken solltest:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Nur &lt;code&gt;app&lt;/code&gt; hat &lt;code&gt;ports:&lt;/code&gt;.&lt;/strong&gt; Die Datenbank braucht keinen offenen Host-Port – die
App erreicht sie intern über den Hostnamen &lt;code&gt;db&lt;/code&gt; (die &lt;code&gt;DATABASE_URL&lt;/code&gt; zeigt genau
dorthin). Ein nicht veröffentlichter Port ist ein Port, den niemand aus dem
Internet angreifen kann.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zwei getrennte Named Volumes.&lt;/strong&gt; App-Daten und Datenbank-Dateien liegen sauber
getrennt – das macht spätere Backups und Restores nachvollziehbar.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Passwort nur als &lt;code&gt;${DB_PASSWORD}&lt;/code&gt;.&lt;/strong&gt; Der echte Wert steht in der &lt;code&gt;.env&lt;/code&gt;, nicht
in dieser Datei. Dieselbe &lt;code&gt;compose.yaml&lt;/code&gt; kann so gefahrlos geteilt werden.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Genau dieses Grundgerüst – App nach außen, Datenbank nur intern, Daten in Named&lt;br&gt;
Volumes, Secrets in der &lt;code&gt;.env&lt;/code&gt; – wiederholt sich in Nextcloud, Vaultwarden,&lt;br&gt;
Paperless und den meisten anderen Rezepten.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wenn es nicht funktioniert
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; &lt;code&gt;yaml: line 7: did not find expected key&lt;/code&gt; (oder ähnliche YAML-Fehler)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ursache &amp;amp; Lösung:&lt;/strong&gt; YAML ist &lt;strong&gt;einrückungssensibel&lt;/strong&gt; – ausschließlich Leerzeichen,&lt;br&gt;
&lt;strong&gt;niemals Tabs&lt;/strong&gt;, und pro Ebene konsistent (üblich: 2 Leerzeichen). Prüfe die Datei&lt;br&gt;
ohne sie zu starten: &lt;code&gt;docker compose config&lt;/code&gt; löst alles auf und meckert genau die&lt;br&gt;
falsche Zeile an.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; &lt;code&gt;Error ... address already in use&lt;/code&gt; beim &lt;code&gt;up&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ursache &amp;amp; Lösung:&lt;/strong&gt; Der Host-Port (links in &lt;code&gt;8080:80&lt;/code&gt;) ist schon belegt. Finde den&lt;br&gt;
Beleger mit &lt;code&gt;sudo ss -tlnp | grep 8080&lt;/code&gt; oder wähle einen anderen Host-Port. Zwei&lt;br&gt;
Container dürfen sich denselben Host-Port nicht teilen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; Eine App findet ihre Datenbank nicht (&lt;code&gt;could not translate host name&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ursache &amp;amp; Lösung:&lt;/strong&gt; Als Hostname muss der &lt;strong&gt;Service-Name&lt;/strong&gt; stehen (z. B. &lt;code&gt;db&lt;/code&gt;),&lt;br&gt;
nicht &lt;code&gt;localhost&lt;/code&gt;. Innerhalb eines Containers ist &lt;code&gt;localhost&lt;/code&gt; der Container selbst,&lt;br&gt;
nicht der Nachbar-Service. Und: Beide Services müssen im selben Compose-Projekt&lt;br&gt;
(derselben Datei) liegen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; Nach &lt;code&gt;docker compose down&lt;/code&gt; sind alle Daten weg.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ursache &amp;amp; Lösung:&lt;/strong&gt; Entweder lag das Volume nicht als &lt;strong&gt;Named Volume&lt;/strong&gt; unter&lt;br&gt;
&lt;code&gt;volumes:&lt;/code&gt; vor (dann war es nur der vergängliche Container-Speicher), oder es wurde&lt;br&gt;
&lt;code&gt;down -v&lt;/code&gt; verwendet. Persistente Dienste immer mit deklariertem Named Volume fahren.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; &lt;code&gt;docker-compose: command not found&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ursache &amp;amp; Lösung:&lt;/strong&gt; Das ist das alte Compose v1 (mit Bindestrich). Aktuell ist&lt;br&gt;
&lt;code&gt;docker compose&lt;/code&gt; (mit Leerzeichen, Plugin). Falls es fehlt:&lt;br&gt;
&lt;code&gt;sudo apt install docker-compose-plugin&lt;/code&gt; (siehe &lt;a href="https://serverkueche.de/tutorials/docker-installieren/" rel="noopener noreferrer"&gt;Docker-Tutorial&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Wartung &amp;amp; Backups
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Image-Tags pflegen:&lt;/strong&gt; Feste Tags (&lt;code&gt;nginx:1.31&lt;/code&gt;) bedeuten, dass du Updates
&lt;strong&gt;bewusst&lt;/strong&gt; durch Hochsetzen des Tags einspielst. Das ist gewollt – so entscheidest
du, wann ein Update kommt, statt überrascht zu werden. Rechne mit &lt;strong&gt;monatlichem&lt;/strong&gt;
Draufschauen auf die Release-Notes deiner Dienste.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Was gehört ins Backup?&lt;/strong&gt; Nicht die Container – die sind aus der &lt;code&gt;compose.yaml&lt;/code&gt;
jederzeit neu baubar. Sichern musst du &lt;strong&gt;die Named Volumes&lt;/strong&gt; (bzw. Bind-Mount-
Ordner), die &lt;code&gt;compose.yaml&lt;/code&gt; und die &lt;code&gt;.env&lt;/code&gt;. Ein durchdachtes Off-Site-Backup dieser
Daten bauen wir mit &lt;a href="https://serverkueche.de/tutorials/backups-mit-restic/" rel="noopener noreferrer"&gt;verschlüsselten Restic-Backups&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Aufräumen:&lt;/strong&gt; &lt;code&gt;docker compose down&lt;/code&gt; beim Abbau eines Stacks; ungenutzte Images
räumst du mit &lt;code&gt;docker image prune&lt;/code&gt; weg. Named Volumes werden &lt;strong&gt;nie&lt;/strong&gt; automatisch
gelöscht – das ist Absicht.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Dieser Beitrag erschien zuerst auf &lt;a href="https://serverkueche.de/tutorials/docker-compose-grundlagen/" rel="noopener noreferrer"&gt;serverkueche.de&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>docker</category>
      <category>tutorial</category>
      <category>german</category>
    </item>
  </channel>
</rss>
