<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Applications – Serverküche</title><link>https://serverkueche.de/en/categories/applications/</link><description>Applications – Neueste Beiträge von Serverküche</description><generator>Hugo</generator><language>en-US</language><managingEditor>feedback@serverkueche.de (Serverküche)</managingEditor><webMaster>feedback@serverkueche.de (Serverküche)</webMaster><copyright>2026 Serverküche</copyright><lastBuildDate>Tue, 21 Jul 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://serverkueche.de/en/categories/applications/index.xml" rel="self" type="application/rss+xml"/><item><title>Self-hosting Jellyfin: your own media server behind Traefik</title><link>https://serverkueche.de/en/tutorials/jellyfin-media-server/</link><pubDate>Tue, 21 Jul 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/jellyfin-media-server/</guid><description>Set up Jellyfin with Docker behind Traefik: stream your movies, series and music – with HTTPS and the library on netcup's Local Block Storage.</description><content:encoded><![CDATA[<p>Netflix, Spotify and Google Photos in one – only on your own server and without a monthly
fee: <strong>Jellyfin</strong> streams your movie, series and music collection to any device. In this
recipe we hang Jellyfin behind Traefik and put the library on netcup&rsquo;s <strong>Local Block
Storage</strong> so you don&rsquo;t run out of space.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>By the end, <strong>Jellyfin 10.11</strong> runs as a container on your server, reachable at
<code>https://jellyfin.YOUR_DOMAIN</code> with valid HTTPS. Jellyfin is a completely free media server
(no cloud, no telemetry, no subscription): you store your files, Jellyfin automatically
pulls covers, descriptions and metadata and streams everything to the browser, phone,
smart TV or the Jellyfin app.</p>
<p>The real challenge with a media server is <strong>storage space</strong> – a movie collection quickly
bursts the small system SSD of a VPS. That&rsquo;s why we mount netcup&rsquo;s <strong>Local Block Storage</strong>:
an additional, local disk that appears in the server like a normal drive and on which the
library lives.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-sky-300 bg-sky-50 dark:border-sky-800 dark:bg-sky-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">ℹ️</span>Honest about transcoding
  </p>
  <div class="prose-kitchen text-sm">Jellyfin re-encodes videos on demand live into a format your end device understands
(<strong>transcoding</strong>) – that costs <strong>a lot of CPU</strong>. netcup servers are pure CPU machines
<strong>without a graphics card</strong>, so real hardware transcoding (via a GPU) doesn&rsquo;t exist there.
On a root server with <strong>dedicated cores</strong> (RS 1000), software transcoding works well for
one or two parallel streams; but the royal road remains <strong>Direct Play</strong> – more on that
below.</div>
</div>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>A running <strong>Traefik reverse proxy</strong> with the <code>proxy</code> network and the resolver <code>le</code> – see
<a href="/en/tutorials/traefik-reverse-proxy/">reverse proxy with Traefik</a>.</li>
<li>A subdomain <code>jellyfin.YOUR_DOMAIN</code> with a DNS record to your server IP – see
<a href="/en/tutorials/connect-domain-to-server/">connecting a domain to your server</a>.</li>
<li><strong>Dedicated CPU cores</strong> are clearly at an advantage for smooth streaming (transcoding).
On a shared VPS it works too for Direct Play, but gets tight quickly on re-encoding.</li>
<li>Optional but recommended: booked <strong>netcup Local Block Storage</strong> for the library (step 2).
Without it, you simply put the media in a folder on the system disk – until the space runs
out.</li>
<li>A few <strong>of your own</strong> media files (movies/series/music) that you&rsquo;re allowed to host.</li>
</ul>
<p>Whether your setup really needs dedicated cores is shown by the
<a href="/en/server-calculator/">server calculator</a> – with transcoding the answer is usually yes.</p>
<div class="not-prose my-6 overflow-hidden rounded-xl border border-paprika-200 bg-paprika-50 dark:border-paprika-800 dark:bg-paprika-900/20">
  <div class="flex items-center justify-between border-b border-paprika-200 bg-paprika-100 px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-paprika-700 dark:border-paprika-800 dark:bg-paprika-900/40 dark:text-paprika-300">
    <span>🍳 Recommendation</span>
    <span title="Links marked with * are affiliate links.">Ad</span>
  </div>
  <div class="flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between">
    <div>
      <p class="text-lg font-bold text-slate-900 dark:text-white">Root-Server RS 1000 G12</p>
      <p class="mt-1 text-sm text-slate-600 dark:text-slate-300">4 dedicated cores · 8 GB RAM · 256 GB NVMe</p>
      <p class="mt-1 text-sm font-semibold text-paprika-700 dark:text-paprika-400">from €12.79/month</p>
      <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">Dedicated cores for transcoding – and Local Block Storage can be added on.</p>
    </div>
    <a href="https://www.netcup.com/en/server/root-server/rs-1000-g12-ip-iv-12m?ref=44083" rel="sponsored noopener" target="_blank"
   class="inline-flex shrink-0 items-center justify-center rounded-lg bg-paprika-600 px-5 py-2.5 font-semibold text-white transition-colors hover:bg-paprika-700">
  Go to netcup →
</a>

  </div><div class="px-4 pb-4"><p class="not-prose my-3 flex flex-wrap items-center gap-x-2 gap-y-1 rounded-lg border border-herb-500/40 bg-herb-50 px-3 py-2 text-sm text-slate-700 dark:bg-herb-900/20 dark:text-slate-200">
  <span>💶 <strong>5 € voucher</strong> for new netcup customers:</span>
  <code class="rounded bg-white px-2 py-0.5 font-mono text-sm font-semibold text-herb-800 dark:bg-slate-800 dark:text-herb-400">36nc17844976032</code>
  <span class="text-xs text-slate-500 dark:text-slate-400">(new customers only, no domains)</span>
</p></div>
</div>

<h2 id="step-by-step">Step by step</h2>
<h3 id="step-1-create-the-dns-record">Step 1: Create the DNS record</h3>
<p>Create <code>jellyfin.YOUR_DOMAIN</code> (A/AAAA to your server IP) and check that it resolves:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">dig +short jellyfin.YOUR_DOMAIN</span></span></code></pre></div>
</div>
<p>Your server IP must come back – otherwise Traefik won&rsquo;t fetch a certificate later.</p>
<h3 id="step-2-mount-netcup-local-block-storage">Step 2: Mount netcup Local Block Storage</h3>
<p>The <strong>Local Block Storage</strong> is additional storage you book to your server in the netcup
customer account. Unlike the network-attached <strong>Storage Space</strong> of other providers (which
is connected via NFS and is slower), it appears in the server as a <strong>completely normal
local disk</strong> – ideal for a library because it&rsquo;s fast and freely partitionable.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-herb-400 bg-herb-50 dark:border-herb-700 dark:bg-herb-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🧑‍🍳</span>No block storage booked?
  </p>
  <div class="prose-kitchen text-sm">Then skip this step and simply create the library under <code>~/jellyfin/media</code> – all the
following commands work the same, only the path is different. You can add the block storage
later at any time and move the data.</div>
</div>
<p>First look at which disks the server knows:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo fdisk -l</span></span></code></pre></div>
</div>
<p>The system disk is typically <code>/dev/sda</code> (or <code>/dev/vda</code>). The new block storage appears as
an <strong>additional</strong> device, usually <code>/dev/vdb</code> – recognizable by the size you booked.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-paprika-400 bg-paprika-50 dark:border-paprika-700 dark:bg-paprika-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🔥</span>Check first, then partition
  </p>
  <div class="prose-kitchen text-sm">The device names can differ per server. If you accidentally partition the <strong>system disk</strong>,
your server is toast. Identify the device unambiguously by its <strong>size</strong> before you
continue. When in doubt, create a <a href="/en/tutorials/netcup-snapshots-scp/">snapshot</a> first.</div>
</div>
<p>Create a partition on the new disk (here <code>/dev/vdb</code>):</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo cfdisk /dev/vdb</span></span></code></pre></div>
</div>
<p>Choose <strong>gpt</strong> at the prompt (the modern standard – <strong>dos</strong>/MBR can address at most 2 TiB,
libraries like to grow beyond that), then <strong>New</strong>, take the full size, and write the table
with <strong>Write</strong> (type <code>yes</code>), then <strong>Quit</strong>. The partition <code>/dev/vdb1</code> is created. Format it
with an ext4 filesystem:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo mkfs.ext4 /dev/vdb1</span></span></code></pre></div>
</div>
<p>Create a mount point and mount the partition:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo mkdir -p /mnt/media
</span></span><span class="line"><span class="cl">sudo mount /dev/vdb1 /mnt/media</span></span></code></pre></div>
</div>
<p>So that the disk is mounted automatically <strong>even after a reboot</strong>, you enter it in
<code>/etc/fstab</code> with its <strong>UUID</strong> (not with <code>/dev/vdb1</code>, which can change). Read out the UUID:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo blkid /dev/vdb1</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">/dev/vdb1: UUID=&#34;a1b2c3d4-....&#34; TYPE=&#34;ext4&#34; ...</span></span></code></pre></div>
</div>
<p>Open the <code>fstab</code> and append the line with <strong>your</strong> UUID:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo nano /etc/fstab</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">UUID=a1b2c3d4-....   /mnt/media   ext4   defaults   0   2</span></span></code></pre></div>
</div>
<p>Check the entries <strong>before</strong> you rely on the reboot – a typo in the <code>fstab</code> can block the
boot process:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo umount /mnt/media <span class="o">&amp;&amp;</span> sudo mount -a <span class="o">&amp;&amp;</span> df -h /mnt/media</span></span></code></pre></div>
</div>
<p>If no error appears here and <code>df -h</code> shows your new disk under <code>/mnt/media</code>, everything is
entered correctly.</p>
<h3 id="step-3-create-the-project-and-media-folders">Step 3: Create the project and media folders</h3>
<p>Create the Compose project – the configuration is small and may go on the system disk. You
create the subfolders <code>config</code> and <code>cache</code> right along, <strong>before</strong> the container starts for
the first time: if the Docker daemon created them first, they would belong to <code>root</code> – but
Jellyfin runs as UID 1000 (see step 4) and couldn&rsquo;t write into its own <code>/config</code>, the
container would crash on the first start.</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mkdir -p ~/jellyfin/<span class="o">{</span>config,cache<span class="o">}</span> <span class="o">&amp;&amp;</span> <span class="nb">cd</span> ~/jellyfin</span></span></code></pre></div>
</div>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-herb-400 bg-herb-50 dark:border-herb-700 dark:bg-herb-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🧑‍🍳</span>Audiobooks belong elsewhere
  </p>
  <div class="prose-kitchen text-sm">Jellyfin can do music – but it is awkward for <strong>audiobooks and podcasts</strong>: it does not sync your
listening position across devices, and it knows nothing about chapter marks or a sleep timer. If you
want both, use <a href="/en/tutorials/audiobookshelf-audiobooks/">Audiobookshelf</a> for those; the two services
run side by side on the same server without trouble.</div>
</div>
<p>And the folder structure for the library on the block storage. Jellyfin sorts best when
movies, series and music are kept separate:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo mkdir -p /mnt/media/<span class="o">{</span>filme,serien,musik<span class="o">}</span></span></span></code></pre></div>
</div>
<p>So that the Jellyfin container may read the files, they have to belong to your user.
Determine your user and group ID (usually <code>1000</code>) and hand over the folder:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">id -u <span class="o">&amp;&amp;</span> id -g
</span></span><span class="line"><span class="cl">sudo chown -R 1000:1000 /mnt/media</span></span></code></pre></div>
</div>
<p>Now copy your media into the appropriate subfolders (via <code>scp</code>, <code>rsync</code> or an SFTP client).
For clean recognition, a clear naming helps, e.g. <code>filme/The Godfather (1972)/The Godfather (1972).mkv</code>.</p>
<h3 id="step-4-the-composeyaml">Step 4: The compose.yaml</h3>
<p>Now the central file. Replace <code>jellyfin.YOUR_DOMAIN</code> with your subdomain:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">jellyfin</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">jellyfin/jellyfin:10.11.11</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">user</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;1000:1000&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">JELLYFIN_PublishedServerUrl=https://jellyfin.YOUR_DOMAIN</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./config:/config</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./cache:/cache</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/mnt/media:/media:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">labels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.enable=true&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.jellyfin.rule=Host(`jellyfin.YOUR_DOMAIN`)&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.jellyfin.entrypoints=websecure&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.jellyfin.tls.certresolver=le&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.services.jellyfin.loadbalancer.server.port=8096&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">proxy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">proxy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">external</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span></span></span></code></pre></div>
</div>
<p>What the lines mean:</p>
<ul>
<li><strong><code>image: …:10.11.11</code></strong> – the fixed, current stable version. No <code>latest</code> (see
&ldquo;Maintenance &amp; backups&rdquo;).</li>
<li><strong><code>user: &quot;1000:1000&quot;</code></strong> – Jellyfin runs with <strong>your</strong> user/group ID so it can read the
media. If you adjust other IDs in step 3, change them here too.</li>
<li><strong><code>JELLYFIN_PublishedServerUrl</code></strong> – tells Jellyfin under which public address it&rsquo;s
reachable. This prevents app links from suddenly pointing to an internal IP.</li>
<li><strong><code>volumes</code></strong>: <code>config</code> (settings, users, metadata) and <code>cache</code> live small in the project
folder; the <strong>library</strong> is mounted from the block storage under <code>/mnt/media</code> into the
container as <code>/media</code> – <strong><code>:ro</code></strong> (read-only), so Jellyfin never changes your originals.</li>
<li><strong>The four Traefik labels</strong> are the familiar pattern. New is only the port: Jellyfin
listens on <strong>8096</strong>, hence the <code>loadbalancer.server.port</code> label. Without it, there&rsquo;d be a
<code>502 Bad Gateway</code>.</li>
<li><strong>No <code>ports:</code></strong> – Jellyfin is reachable exclusively via Traefik (HTTPS), not directly from
the internet.</li>
</ul>
<p>Start the stack and watch it boot:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose up -d
</span></span><span class="line"><span class="cl">docker compose logs -f jellyfin</span></span></code></pre></div>
</div>
<p>On the first start, certificate issuance takes a few seconds. Once <code>Startup complete</code>
appears in the log, Jellyfin is ready.</p>
<h3 id="step-5-the-initial-setup-wizard">Step 5: The initial setup wizard</h3>
<p>Open <code>https://jellyfin.YOUR_DOMAIN</code> in the browser. On the very first call, a wizard guides
you through the basic setup:</p>
<ol>
<li>Choose the <strong>language</strong>.</li>
<li>Create an <strong>administrator account</strong> – set a <strong>long, unique password</strong>, because the login
page is publicly on the net.</li>
<li><strong>Add media libraries</strong>: click &ldquo;Add media library&rdquo;, choose the type (e.g. <em>Movies</em>), and
enter the <strong>container path</strong> as the folder – i.e. <code>/media/filme</code> (not <code>/mnt/media/filme</code>,
that&rsquo;s the path on the host!). Repeat this for <code>serien</code> and <code>musik</code>.</li>
<li>Set the <strong>metadata language</strong> and you can accept the rest.</li>
</ol>
<p><figure class="my-6"><img src="/en/tutorials/jellyfin-media-server/jellyfin-einrichtung_hu_c1c02efb7c260d14.webp" srcset="/en/tutorials/jellyfin-media-server/jellyfin-einrichtung_hu_458de0a2575dec2c.webp 480w, /en/tutorials/jellyfin-media-server/jellyfin-einrichtung_hu_c1c02efb7c260d14.webp 768w, /en/tutorials/jellyfin-media-server/jellyfin-einrichtung_hu_6b6f7e3feb89f399.webp 1200w, /en/tutorials/jellyfin-media-server/jellyfin-einrichtung_hu_27aefc6cf81a8bf0.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/jellyfin-media-server/jellyfin-einrichtung_hu_874c631adaf10bb8.webp"
    alt="The Jellyfin setup wizard on first launch with fields for server name and preferred display language" title="The setup wizard on first launch"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The setup wizard on first launch</figcaption></figure></p>
<p>After the wizard, Jellyfin scans the folders and loads covers and descriptions. For large
collections the first scan takes a while – you see the progress under <strong>Dashboard →
Scheduled Tasks</strong>. After that you land on the home page and see your library with the first
titles:</p>
<p><figure class="my-6"><img src="/en/tutorials/jellyfin-media-server/jellyfin-startseite_hu_a427defcc02cab4a.webp" srcset="/en/tutorials/jellyfin-media-server/jellyfin-startseite_hu_8ee95ee88c46f3a5.webp 480w, /en/tutorials/jellyfin-media-server/jellyfin-startseite_hu_a427defcc02cab4a.webp 768w, /en/tutorials/jellyfin-media-server/jellyfin-startseite_hu_c9cf18598e9354f8.webp 1200w, /en/tutorials/jellyfin-media-server/jellyfin-startseite_hu_36db899c147b3bfc.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/jellyfin-media-server/jellyfin-startseite_hu_f5429822b251a633.webp"
    alt="The Jellyfin home page after setup with the &ldquo;Movies&rdquo; library and a recently added movie" title="The home page with the first library"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The home page with the first library</figcaption></figure></p>
<h3 id="step-6-configure-jellyfin-cleanly-behind-the-proxy">Step 6: Configure Jellyfin cleanly behind the proxy</h3>
<p>Two settings make Jellyfin work correctly behind Traefik. Open <strong>Dashboard → Networking</strong>:</p>
<p><figure class="my-6"><img src="/en/tutorials/jellyfin-media-server/jellyfin-dashboard_hu_49ea4f89f0c94e48.webp" srcset="/en/tutorials/jellyfin-media-server/jellyfin-dashboard_hu_236cd86efabb984.webp 480w, /en/tutorials/jellyfin-media-server/jellyfin-dashboard_hu_49ea4f89f0c94e48.webp 768w, /en/tutorials/jellyfin-media-server/jellyfin-dashboard_hu_d69db5ed4f3767ff.webp 1200w, /en/tutorials/jellyfin-media-server/jellyfin-dashboard_hu_474858f0a34026a.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/jellyfin-media-server/jellyfin-dashboard_hu_656fccc814e03344.webp"
    alt="The Jellyfin administration dashboard with server version 10.11.11, active devices and the storage paths, on the left the menu with network and playback settings" title="The admin dashboard – networking and playback sit here too"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The admin dashboard – networking and playback sit here too</figcaption></figure></p>
<ul>
<li>
<p>Under <strong>Known proxies</strong> you enter the Traefik network so Jellyfin sees the <strong>real</strong> client
IP (instead of Traefik&rsquo;s container IP). You determine the subnet with:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker network inspect proxy --format <span class="s1">&#39;{{ (index .IPAM.Config 0).Subnet }}&#39;</span></span></span></code></pre></div>
</div>
<p>Enter the returned range (e.g. <code>172.19.0.0/16</code>) there.</p>
</li>
<li>
<p><strong>HTTPS</strong>: since Traefik handles the encryption, Jellyfin itself needs <strong>no</strong> TLS. Leave
the HTTPS options in Jellyfin empty – everything runs via the proxy.</p>
</li>
</ul>
<p>WebSockets (for live updates of the interface) Traefik forwards automatically, nothing to do
there.</p>
<h3 id="step-7-transcoding-vs-direct-play">Step 7: Transcoding vs. Direct Play</h3>
<p>Whether your server breaks a sweat is decided here. Two cases:</p>
<ul>
<li><strong>Direct Play</strong> – the end device can play the file as-is. The server just pushes bytes
through, <strong>almost no CPU load</strong>. That&rsquo;s the ideal case.</li>
<li><strong>Transcoding</strong> – format, codec or bitrate don&rsquo;t fit the device (or the line is too slow),
so Jellyfin re-encodes <strong>live</strong>. On a netcup server this happens via the <strong>CPU</strong> – with a
4K stream that can max out a whole server.</li>
</ul>
<p>This is how you keep the load low:</p>
<ul>
<li>Provide <strong>suitable formats</strong>: H.264/AAC in an <code>.mp4</code>/<code>.mkv</code> plays directly on practically
every device. Exotic codecs (e.g. certain 4K HEVC audio tracks) force transcoding.</li>
<li>Set the <strong>client quality</strong> in the app to &ldquo;Original/Direct Play&rdquo; when the line is enough.</li>
<li>Under <strong>Dashboard → Playback</strong> you can set the transcoding settings and a limit for
simultaneous streams.</li>
</ul>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-amber-400 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">⚠️</span>Hardware transcoding needs a GPU
  </p>
  <div class="prose-kitchen text-sm">The <strong>hardware acceleration</strong> selectable in Jellyfin (VAAPI, QSV, NVENC) requires a graphics
unit under <code>/dev/dri</code>. Standard VPS and root servers at netcup are KVM machines <strong>without</strong> a
passed-through GPU – leave hardware acceleration <strong>off</strong> there, it would only produce errors.
Instead plan with dedicated CPU cores and Direct Play.</div>
</div>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong><code>502 Bad Gateway</code> on access, even though the container is running.</strong> Traefik reaches the
container but hits the wrong port. Jellyfin listens on <strong>8096</strong> – the label
<code>traefik.http.services.jellyfin.loadbalancer.server.port=8096</code> must be set. Also check that
Jellyfin is on the <strong><code>proxy</code> network</strong>.</p>
<p><strong>The libraries stay empty, even though files are there.</strong> Almost always a <strong>permission
problem</strong>. The container runs as <code>1000:1000</code> (step 4), so the media must belong to that
user: <code>sudo chown -R 1000:1000 /mnt/media</code>. And: in Jellyfin the <strong>container path</strong> must be
entered (<code>/media/filme</code>), not the host path. Afterwards start &ldquo;Scan all libraries&rdquo; under
<strong>Dashboard → Scheduled Tasks</strong>.</p>
<p><strong>Video stutters/buffers, <code>docker stats</code> shows Jellyfin at ~100% CPU.</strong> It&rsquo;s
<strong>transcoding</strong>. Under <strong>Dashboard → Playback</strong> the activity monitor shows whether it says
&ldquo;Transcode&rdquo; instead of &ldquo;Direct Play&rdquo;. Set the client quality to original, change exotic
codecs to a widely supported format, or give the server more (dedicated) cores.</p>
<p><strong>After a reboot the library is gone and Jellyfin shows empty folders.</strong> The block storage
wasn&rsquo;t mounted – usually a missing or wrong <code>fstab</code> entry (step 2). Check with <code>df -h /mnt/media</code> and <code>sudo mount -a</code>. The line must use the <strong>UUID</strong>, not <code>/dev/vdb1</code>.</p>
<p><strong>The login page loads, but the app can&rsquo;t find the server / links point to an internal
address.</strong> <code>JELLYFIN_PublishedServerUrl</code> is missing or wrong. Set it in the <code>compose.yaml</code>
to <code>https://jellyfin.YOUR_DOMAIN</code> and restart with <code>docker compose up -d</code> (a <code>restart</code> isn&rsquo;t
enough for the environment change to take effect).</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<ul>
<li><strong>Apply updates deliberately.</strong> The fixed tag (<code>jellyfin:10.11.11</code>) means: you decide when
to update. Bump the tag → <code>docker compose pull</code> → <code>docker compose up -d</code>. Before jumping to
a new major version, read the release notes – database migrations aren&rsquo;t always reversible.</li>
<li><strong>Back up the config, treat the media separately.</strong> The <code>config</code> volume (users, settings,
playback state, metadata) is small and the actually valuable part – back it up
<a href="/en/tutorials/restic-backups/">encrypted off-site with Restic</a>. The <strong>library</strong> itself is
usually too large for a classic backup and reproducible from the original media; decide
deliberately whether you back it up or classify it as &ldquo;replaceable&rdquo;. Honestly: a second
backup of your movie collection costs real storage.</li>
<li><strong>Monitor disk space.</strong> A library grows steadily. Keep an eye on usage
(<code>df -h /mnt/media</code>) and best set up a monitor in
<a href="/en/tutorials/uptime-kuma-monitoring/">Uptime Kuma</a> that raises the alarm before the
block storage is full.</li>
<li><strong>Publicly reachable = secure it.</strong> The login page is on the net, and Jellyfin brings <strong>no
two-factor authentication</strong> (not even via an official plugin). So consistently assign
strong passwords, deactivate unused accounts – and for purely private use, consider making
Jellyfin reachable not publicly but only via a VPN.</li>
</ul>
]]></content:encoded></item><item><title>Self-hosting Immich: your private photo backup behind Traefik</title><link>https://serverkueche.de/en/tutorials/self-host-immich-photos/</link><pubDate>Mon, 20 Jul 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/self-host-immich-photos/</guid><description>Set up Immich with Docker behind Traefik: the self-hosted alternative to Google Photos – with automatic phone backup, face recognition and HTTPS.</description><content:encoded><![CDATA[<p>Thousands of photos and videos from your phone – and all of them at Google or Apple.
<strong>Immich</strong> 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.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>By the end, <strong>Immich v3</strong> runs behind your Traefik proxy, reachable at
<code>https://photos.YOUR_DOMAIN</code> with valid HTTPS. The official <strong>Immich phone app</strong> 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 (&ldquo;beach&rdquo;, &ldquo;dog&rdquo;) and let Immich group faces.</p>
<p>Immich consists of <strong>four containers</strong>: 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.</p>
<p>The appeal over Google Photos: the images don&rsquo;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 <strong>backups</strong>: if the server dies and
you have no backup, the photos are gone. That&rsquo;s exactly why the backup below is not optional
but mandatory.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-amber-400 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">⚠️</span>Immich moves fast – pin the version
  </p>
  <div class="prose-kitchen text-sm">Immich develops rapidly. Database migrations run automatically on update and are <strong>not
backward-compatible</strong> – a downgrade is no longer cleanly possible afterwards. So <strong>always pin
a fixed version</strong> (here <code>v3.0.3</code>) instead of <code>release</code> or <code>latest</code>, and make a <strong>backup
before every update</strong>. That way you decide when to update.</div>
</div>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>A running <strong>Traefik reverse proxy</strong> with the <code>proxy</code> network and the Let&rsquo;s Encrypt resolver
<code>le</code> – see <a href="/en/tutorials/traefik-reverse-proxy/">reverse proxy with Traefik</a>.</li>
<li>A subdomain <code>photos.YOUR_DOMAIN</code> with a DNS record to your server IP – see
<a href="/en/tutorials/connect-domain-to-server/">connecting a domain to your server</a>.</li>
<li>A <strong>backup</strong>. Your photo collection is irreplaceable – first set up
<a href="/en/tutorials/restic-backups/">backups with Restic</a>.</li>
</ul>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-herb-400 bg-herb-50 dark:border-herb-700 dark:bg-herb-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🧑‍🍳</span>How big does the server need to be?
  </p>
  <div class="prose-kitchen text-sm">Immich is the most resource-hungry service in the Serverküche. Officially, <strong>at least 6 GB
RAM (8 recommended)</strong> are required – the machine-learning service for face recognition and
search in particular needs memory. The tested <strong>VPS 1000 with 8 GB RAM</strong> just meets that but
has little reserve. For a large library or several users, a server with <strong>16 GB RAM</strong> (e.g.
VPS 2000) is more relaxed. On a 4 GB server, Immich only runs with <strong>ML turned off</strong> (see
&ldquo;When things go wrong&rdquo;). And: photos need space – plan for enough disk.</div>
</div>
<p>How much RAM your photo library including AI search really needs is calculated by the
<a href="/en/server-calculator/">server calculator</a>.</p>
<div class="not-prose my-6 overflow-hidden rounded-xl border border-paprika-200 bg-paprika-50 dark:border-paprika-800 dark:bg-paprika-900/20">
  <div class="flex items-center justify-between border-b border-paprika-200 bg-paprika-100 px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-paprika-700 dark:border-paprika-800 dark:bg-paprika-900/40 dark:text-paprika-300">
    <span>🍳 Recommendation</span>
    <span title="Links marked with * are affiliate links.">Ad</span>
  </div>
  <div class="flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between">
    <div>
      <p class="text-lg font-bold text-slate-900 dark:text-white">VPS 2000 G12</p>
      <p class="mt-1 text-sm text-slate-600 dark:text-slate-300">8 vCores · 16 GB RAM · 512 GB NVMe</p>
      <p class="mt-1 text-sm font-semibold text-paprika-700 dark:text-paprika-400">from €19.24/month</p>
      <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">For a large photo library with face recognition, 16 GB RAM is comfortable.</p>
    </div>
    <a href="https://www.netcup.com/en/server/vps/vps-2000-g12-iv-12m?ref=44083" rel="sponsored noopener" target="_blank"
   class="inline-flex shrink-0 items-center justify-center rounded-lg bg-paprika-600 px-5 py-2.5 font-semibold text-white transition-colors hover:bg-paprika-700">
  Go to netcup →
</a>

  </div><div class="px-4 pb-4"><p class="not-prose my-3 flex flex-wrap items-center gap-x-2 gap-y-1 rounded-lg border border-herb-500/40 bg-herb-50 px-3 py-2 text-sm text-slate-700 dark:bg-herb-900/20 dark:text-slate-200">
  <span>💶 <strong>5 € voucher</strong> for new netcup customers:</span>
  <code class="rounded bg-white px-2 py-0.5 font-mono text-sm font-semibold text-herb-800 dark:bg-slate-800 dark:text-herb-400">36nc17844976032</code>
  <span class="text-xs text-slate-500 dark:text-slate-400">(new customers only, no domains)</span>
</p></div>
</div>

<h2 id="step-by-step">Step by step</h2>
<h3 id="step-1-create-the-dns-record">Step 1: Create the DNS record</h3>
<p>Create an entry <code>photos.YOUR_DOMAIN</code> that points to your server IP, and check it:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">dig +short photos.YOUR_DOMAIN</span></span></code></pre></div>
</div>
<p>Your server IP must come back – otherwise Traefik won&rsquo;t fetch a certificate later.</p>
<h3 id="step-2-prepare-the-folder-and-env">Step 2: Prepare the folder and .env</h3>
<p>Immich is configured via a <code>.env</code> file. Create the project:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mkdir -p ~/immich <span class="o">&amp;&amp;</span> <span class="nb">cd</span> ~/immich</span></span></code></pre></div>
</div>
<p>Create a <code>.env</code> with the core values (set a strong DB password – <strong>letters and digits
only</strong>, no special characters, Immich&rsquo;s DB init doesn&rsquo;t like those):</p>
<div class="sk-code">
  <span class="sk-code-head">INI</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="na">UPLOAD_LOCATION</span><span class="o">=</span><span class="s">./library</span>
</span></span><span class="line"><span class="cl"><span class="na">DB_DATA_LOCATION</span><span class="o">=</span><span class="s">./postgres</span>
</span></span><span class="line"><span class="cl"><span class="na">DB_PASSWORD</span><span class="o">=</span><span class="s">YOUR_DB_PASSWORD</span>
</span></span><span class="line"><span class="cl"><span class="na">DB_USERNAME</span><span class="o">=</span><span class="s">postgres</span>
</span></span><span class="line"><span class="cl"><span class="na">DB_DATABASE_NAME</span><span class="o">=</span><span class="s">immich</span>
</span></span><span class="line"><span class="cl"><span class="na">IMMICH_VERSION</span><span class="o">=</span><span class="s">v3.0.3</span></span></span></code></pre></div>
</div>
<ul>
<li><strong><code>UPLOAD_LOCATION</code></strong> – this is where your <strong>photos and videos</strong> land. It&rsquo;s the most
important directory for the backup.</li>
<li><strong><code>DB_DATA_LOCATION</code></strong> – the PostgreSQL data. Must be on a <strong>local disk</strong> (no
network drive/NFS – the DB resents that).</li>
<li><strong><code>IMMICH_VERSION</code></strong> – the pinned version.</li>
</ul>
<h3 id="step-3-create-the-composeyaml">Step 3: Create the compose.yaml</h3>
<p>We adopt the official Immich template and only add the Traefik labels on the <code>immich-server</code>.
Create <code>compose.yaml</code>:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">immich</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">immich-server</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">ghcr.io/immich-app/immich-server:${IMMICH_VERSION}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">${UPLOAD_LOCATION}:/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/etc/localtime:/etc/localtime:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">env_file</span><span class="p">:</span><span class="w"> </span><span class="l">.env</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">DB_HOSTNAME</span><span class="p">:</span><span class="w"> </span><span class="l">database</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">REDIS_HOSTNAME</span><span class="p">:</span><span class="w"> </span><span class="l">redis</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">redis</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_started</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">database</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_healthy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">labels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.enable=true&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.immich.rule=Host(`photos.YOUR_DOMAIN`)&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.immich.entrypoints=websecure&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.immich.tls.certresolver=le&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.services.immich.loadbalancer.server.port=2283&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">default, proxy]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">immich-machine-learning</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">model-cache:/cache</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">default]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">redis</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">docker.io/valkey/valkey:9</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">default]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">database</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">POSTGRES_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">${DB_PASSWORD}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">POSTGRES_USER</span><span class="p">:</span><span class="w"> </span><span class="l">${DB_USERNAME}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">POSTGRES_DB</span><span class="p">:</span><span class="w"> </span><span class="l">${DB_DATABASE_NAME}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">${DB_DATA_LOCATION}:/var/lib/postgresql/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD-SHELL&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;pg_isready -U ${DB_USERNAME} -d ${DB_DATABASE_NAME}&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">10s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">timeout</span><span class="p">:</span><span class="w"> </span><span class="l">5s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">5</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">default]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">model-cache</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">default</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">proxy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">external</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span></span></span></code></pre></div>
</div>
<p>Important to understand:</p>
<ul>
<li><strong>Only <code>immich-server</code> is on the <code>proxy</code> network</strong> and carries Traefik labels. The ML
service, Valkey and the database stay exclusively on the internal <code>default</code> network – not
reachable from outside.</li>
<li><strong><code>loadbalancer.server.port=2283</code></strong> – Immich listens on port 2283 in the container.</li>
<li>The <strong>database</strong> is deliberately the prebuilt Immich Postgres image with the vector
extension <em>VectorChord</em> (for image search). Don&rsquo;t just use a standard <code>postgres</code> – the
extension is then missing.</li>
<li><strong>Valkey</strong> is the Redis successor; the service is still called <code>redis</code> in the Immich
template for compatibility reasons.</li>
<li>The <strong>healthcheck</strong> on the database reports when Postgres is really ready. Via
<code>depends_on: … condition: service_healthy</code>, <code>immich-server</code> only starts then – this
prevents migration errors and the &ldquo;database not reachable&rdquo; start on the first boot.</li>
</ul>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-herb-400 bg-herb-50 dark:border-herb-700 dark:bg-herb-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🧑‍🍳</span>Applying changes to the .env
  </p>
  <div class="prose-kitchen text-sm">If you later change something in the <code>.env</code>, a <code>docker compose restart</code> is <strong>not</strong> enough –
Docker only reads the environment variables when <strong>recreating</strong> the containers. Use
<code>docker compose up -d</code> then; Compose detects the change and rebuilds the affected containers.</div>
</div>
<h3 id="step-4-allow-large-uploads-traefik-timeout">Step 4: Allow large uploads (Traefik timeout)</h3>
<p>This is <strong>the</strong> stumbling block with Immich behind Traefik: by default Traefik aborts
connections after <strong>60 seconds</strong> (<code>readTimeout</code>). When uploading large videos from the phone
this leads to aborted uploads (error 502/499). Increase the timeout on the <code>websecure</code>
entrypoint in your <strong>Traefik configuration</strong> (the <code>traefik</code> service from the
<a href="/en/tutorials/traefik-reverse-proxy/">Traefik tutorial</a>):</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># ... your existing lines ...</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--entrypoints.websecure.transport.respondingTimeouts.readTimeout=600s&#34;</span></span></span></code></pre></div>
</div>
<p>Restart Traefik afterwards (<code>docker compose up -d</code> in the Traefik folder). Unlike nginx,
Traefik has no fixed size limit for uploads – only this timeout has to be high.</p>
<h3 id="step-5-start-and-run-through-the-initial-setup">Step 5: Start and run through the initial setup</h3>
<p>Pull the images (several GB – this takes a while) and start:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose up -d
</span></span><span class="line"><span class="cl">docker compose logs -f immich-server</span></span></code></pre></div>
</div>
<p>The images are several gigabytes in size – the first <code>pull</code> takes a few minutes depending on
your connection. Then check that all four containers are running:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose ps</span></span></code></pre></div>
</div>
<p>You should see <code>immich-server</code>, <code>immich-machine-learning</code>, <code>redis</code> and <code>database</code> with status
<code>running</code> (the database <code>healthy</code>). Wait until
<code>Immich Server is listening on http://[::1]:2283 [v3.0.3]</code> appears in the log. Then open
<code>https://photos.YOUR_DOMAIN</code>. Traefik fetches the Let&rsquo;s Encrypt certificate on the first
access (be patient for a moment). On the very first start, Immich greets you with <strong>&ldquo;Welcome
to Immich&rdquo;</strong> and the choice of whether you <strong>start fresh</strong> or restore a backup:</p>
<p><figure class="my-6"><img src="/en/tutorials/self-host-immich-photos/immich-willkommen_hu_df42e8458020275e.webp" srcset="/en/tutorials/self-host-immich-photos/immich-willkommen_hu_a2524608a6612199.webp 480w, /en/tutorials/self-host-immich-photos/immich-willkommen_hu_df42e8458020275e.webp 768w, /en/tutorials/self-host-immich-photos/immich-willkommen_hu_22b944244b61376c.webp 1200w, /en/tutorials/self-host-immich-photos/immich-willkommen_hu_6355c1dbc067f168.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/self-host-immich-photos/immich-willkommen_hu_157a6edfcb1fc735.webp"
    alt="The welcome screen of Immich with the options &ldquo;Getting Started&rdquo; and &ldquo;Restore from database&rdquo;" title="First launch: choose \&#34;Getting Started\&#34; for a fresh installation"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">First launch: choose \&#34;Getting Started\&#34; for a fresh installation</figcaption></figure></p>
<p>Click <strong>Getting Started</strong> (you only need Restore if you&rsquo;re importing a backup). Then you
create the <strong>administrator account</strong> – as the first user you automatically become admin:</p>
<p><figure class="my-6"><img src="/en/tutorials/self-host-immich-photos/immich-registrierung_hu_5e17e8d959b4be91.webp" srcset="/en/tutorials/self-host-immich-photos/immich-registrierung_hu_5da2b7656e747eea.webp 480w, /en/tutorials/self-host-immich-photos/immich-registrierung_hu_5e17e8d959b4be91.webp 768w, /en/tutorials/self-host-immich-photos/immich-registrierung_hu_dc0630b55968f943.webp 1200w, /en/tutorials/self-host-immich-photos/immich-registrierung_hu_94e19f1b46ee602b.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/self-host-immich-photos/immich-registrierung_hu_7d1f7c90347fc007.webp"
    alt="The Immich form &ldquo;Admin Registration&rdquo; with fields for email, password and name" title="Admin registration – the first user automatically becomes administrator"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Admin registration – the first user automatically becomes administrator</figcaption></figure></p>
<p>After the first login, a short <strong>setup wizard</strong> guides you through theme, language and basic
privacy settings:</p>
<p><figure class="my-6"><img src="/en/tutorials/self-host-immich-photos/immich-onboarding_hu_605f1bfc221f0d38.webp" srcset="/en/tutorials/self-host-immich-photos/immich-onboarding_hu_3985d8f71d5bbe6b.webp 480w, /en/tutorials/self-host-immich-photos/immich-onboarding_hu_605f1bfc221f0d38.webp 768w, /en/tutorials/self-host-immich-photos/immich-onboarding_hu_7a4acec9c89174a5.webp 1200w, /en/tutorials/self-host-immich-photos/immich-onboarding_hu_afbeaf6cfb205ba2.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/self-host-immich-photos/immich-onboarding_hu_8ded2f93b7bae082.webp"
    alt="The Immich setup wizard greets the new administrator" title="The onboarding wizard after the first login"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The onboarding wizard after the first login</figcaption></figure></p>
<p>After that you land on your (still empty) <strong>timeline</strong> – the heart of Immich:</p>
<p><figure class="my-6"><img src="/en/tutorials/self-host-immich-photos/immich-timeline_hu_7736fed89cfe0c0f.webp" srcset="/en/tutorials/self-host-immich-photos/immich-timeline_hu_29dd4a91051bc5d2.webp 480w, /en/tutorials/self-host-immich-photos/immich-timeline_hu_7736fed89cfe0c0f.webp 768w, /en/tutorials/self-host-immich-photos/immich-timeline_hu_5f77e6240ffda01c.webp 1200w, /en/tutorials/self-host-immich-photos/immich-timeline_hu_3e1ec3671920a427.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/self-host-immich-photos/immich-timeline_hu_d8deafe9761a334f.webp"
    alt="The empty photo timeline of Immich with the prompt to upload the first photo" title="The photo timeline – all images later flow in here (server online, v3.0.3)"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The photo timeline – all images later flow in here (server online, v3.0.3)</figcaption></figure></p>
<h3 id="step-6-connect-the-phone-app">Step 6: Connect the phone app</h3>
<p>The real benefit comes from the app. Install <strong>Immich</strong> from the App Store or Play Store (or
F-Droid). At launch it asks for the <strong>server address</strong> – enter <code>https://photos.YOUR_DOMAIN</code>
and log in with your account. Then, in the app settings, enable the <strong>backup</strong> and choose the
albums to be uploaded.</p>
<p>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.</p>
<p>After the upload, Immich keeps working in the background: it generates thumbnails, reads the
capture metadata (date, location) and lets the ML service <strong>recognize faces</strong> and index the
images for <strong>smart search</strong>. These jobs run for some time after the first big import – so
faces and search hits only appear gradually. You see the progress under <strong>Administration →
Job queues</strong>. In the sidebar you then find the typical photo features: <strong>Explore</strong> (by people
and places), the <strong>map</strong> with geolocation, <strong>albums</strong> for sharing and the <strong>memories</strong> (&ldquo;a
year ago&rdquo;).</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-sky-300 bg-sky-50 dark:border-sky-800 dark:bg-sky-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">ℹ️</span>Videos &amp; transcoding on the VPS
  </p>
  <div class="prose-kitchen text-sm">Immich converts videos for playback in the browser (transcoding). On a normal VPS <strong>without a
graphics card</strong> 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.</div>
</div>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-herb-400 bg-herb-50 dark:border-herb-700 dark:bg-herb-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🧑‍🍳</span>More users
  </p>
  <div class="prose-kitchen text-sm">For the family, create further accounts under <strong>Administration → Users</strong> – each gets its own
separate library. Registration from outside is off by default; new users only arise via the
admin account.</div>
</div>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-amber-400 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">⚠️</span>Your photos are on the internet
  </p>
  <div class="prose-kitchen text-sm">As soon as Immich is reachable via <code>photos.YOUR_DOMAIN</code>, the login page is open on the net. So
set a <strong>long, unique password</strong> for the admin and all user accounts and enable <strong>two-factor
authentication</strong> in the account settings. Whoever wants to be extra safe makes Immich reachable
only via a VPN – but for automatic phone upload, direct HTTPS reachability is usually the more
practical way.</div>
</div>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong>Uploads of large videos abort after about a minute (error 502 or 499).</strong> Traefik&rsquo;s
<code>readTimeout</code> (default 60 s) kicks in. Set it to <code>600s</code> (or higher) as in step 4 and restart
Traefik. This is by far the most common Immich-behind-proxy error.</p>
<p><strong>The <code>immich-machine-learning</code> container crashes or exits with &ldquo;exit 137&rdquo;, search and face
recognition don&rsquo;t work.</strong> Too little RAM – the container was killed by the system (out of
memory). Give the server more memory, or <strong>disable ML</strong> by removing the service
<code>immich-machine-learning</code> from the <code>compose.yaml</code>. Immich then runs without face recognition
and smart search, but upload and timeline work normally.</p>
<p><strong>After an update the containers no longer start or report migration errors.</strong> Server, machine
learning and database must run on the <strong>same</strong> version. Set the new <code>IMMICH_VERSION</code> in the
<code>.env</code> and update <strong>all</strong> containers together (<code>docker compose pull &amp;&amp; docker compose up -d</code>).
A downgrade after a migration isn&rsquo;t possible – only a reset from the backup.</p>
<p><strong>Immich shows &ldquo;maintenance mode&rdquo; / &ldquo;temporarily unavailable&rdquo;.</strong> Immich v3 starts into a
maintenance mode under certain database states. The log (<code>docker compose logs immich-server</code>)
then contains a URL with a <strong>one-time token</strong> (<code>…/maintenance?token=…</code>) – through it you log
into the maintenance mode and choose &ldquo;restart&rdquo; or end it. After that the normal interface is
back.</p>
<p><strong>Container won&rsquo;t start, &ldquo;permission denied&rdquo; on the database or upload directory.</strong>
<code>UPLOAD_LOCATION</code> or <code>DB_DATA_LOCATION</code> 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.</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<ul>
<li>
<p><strong>Two things belong in the backup – consistently together.</strong> Your <strong>photos</strong>
(<code>UPLOAD_LOCATION</code>, i.e. <code>~/immich/library</code>) and the <strong>database</strong>. The DB dump contains only
the metadata; without the matching files it&rsquo;s worthless, and vice versa. Cleanest is to stop
Immich briefly and back up both together:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose stop immich-server
</span></span><span class="line"><span class="cl">docker compose <span class="nb">exec</span> -T database pg_dump --clean --if-exists <span class="se">\
</span></span></span><span class="line"><span class="cl">  --dbname<span class="o">=</span>immich --username<span class="o">=</span>postgres <span class="p">|</span> gzip &gt; immich-db.sql.gz
</span></span><span class="line"><span class="cl">docker compose start immich-server</span></span></code></pre></div>
</div>
<p>Back up the dump together with the <code>library</code> folder <strong>encrypted and off-site</strong> with
<a href="/en/tutorials/restic-backups/">Restic</a>. Alternatively, Immich can generate automatic DB
dumps under <strong>Administration → Job queues</strong> – you still have to back up the photos
separately.</p>
</li>
<li>
<p><strong>Rehearse the worst case once.</strong> A backup you&rsquo;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
<code>library</code> folder in the same spot – Immich offers the option <strong>&ldquo;Restore from database&rdquo;</strong> on
the first start for this (the second button from step 5). What matters is the <strong>same
version</strong>: the database state and the program must match.</p>
</li>
<li>
<p><strong>Backup before every update.</strong> Because migrations aren&rsquo;t reversible, the backup is your only
way back. Back up first, then raise <code>IMMICH_VERSION</code>, then
<code>docker compose pull &amp;&amp; docker compose up -d</code>.</p>
</li>
<li>
<p><strong>Honest about the effort:</strong> Immich often brings several releases per month. You don&rsquo;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.</p>
</li>
<li>
<p><strong>Keep an eye on disk space.</strong> Photos and videos grow steadily; Immich additionally creates
thumbnails and converted videos. Monitor the disk usage (e.g. with
<a href="/en/tutorials/uptime-kuma-monitoring/">Uptime Kuma</a>) so the server doesn&rsquo;t fill up.</p>
</li>
</ul>
]]></content:encoded></item><item><title>Self-hosting Paperless-ngx: the paperless office with OCR</title><link>https://serverkueche.de/en/tutorials/self-host-paperless-ngx/</link><pubDate>Mon, 20 Jul 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/self-host-paperless-ngx/</guid><description>Set up Paperless-ngx with Docker behind Traefik: archive documents searchably via OCR – with full-text search, tags and automatic import.</description><content:encoded><![CDATA[<p>Invoices, contracts, official mail – the paper pile grows and grows, and in the end you
never find anything anyway. <strong>Paperless-ngx</strong> turns it into a searchable digital archive:
you throw a document in, it&rsquo;s recognized via <strong>OCR</strong>, tagged, and back in seconds via the
full-text search. In this recipe we set it up behind Traefik.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>By the end, <strong>Paperless-ngx 2.20</strong> runs behind your Traefik proxy, reachable at
<code>https://paperless.YOUR_DOMAIN</code> with HTTPS. Paperless is a document management system: you
feed it scans or PDFs, it reads out the text via <strong>OCR</strong> (even from pure image scans),
recognizes date and content, and stores everything searchably. Via tags, correspondents and
document types you bring order into it; the full-text search later finds every document.</p>
<p>The clever part is the <strong>consume folder</strong>: everything you place there (e.g. from a network
scanner) is imported and processed automatically. Paperless consists of <strong>five containers</strong> –
the web server, a PostgreSQL database, a Redis broker for background processing, plus
<strong>Gotenberg</strong> and <strong>Tika</strong>, which convert office documents into PDF. The official template
provides the complete team.</p>
<p>The gain over a folder full of PDFs on the disk: Paperless makes every document
<strong>searchable</strong> (even scanned paper), keeps the original and archive version cleanly
separated, and can be automated via rules. Your documents stay on your server – no cloud
service reads along, and you&rsquo;re not tied to a proprietary format.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>A running <strong>Traefik reverse proxy</strong> with the <code>proxy</code> network and the resolver <code>le</code> – see
<a href="/en/tutorials/traefik-reverse-proxy/">reverse proxy with Traefik</a>.</li>
<li>A subdomain <code>paperless.YOUR_DOMAIN</code> with a DNS record to your server IP – see
<a href="/en/tutorials/connect-domain-to-server/">connecting a domain to your server</a>.</li>
<li>A <strong>backup</strong>. Your most important documents land in Paperless – first set up
<a href="/en/tutorials/restic-backups/">backups with Restic</a>.</li>
</ul>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-herb-400 bg-herb-50 dark:border-herb-700 dark:bg-herb-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🧑‍🍳</span>How big does the server need to be?
  </p>
  <div class="prose-kitchen text-sm">Paperless itself is frugal, but it&rsquo;s five containers, and the <strong>OCR processing is
CPU-heavy</strong>: when recognizing a scan, a core runs at full tilt for a few seconds to minutes.
For private use, <strong>2 vCPU and 4 GB RAM</strong> are quite enough; the tested VPS 1000 (4 vCore /
8 GB) has plenty of headroom. During bulk imports of many documents you notice the CPU load –
then Paperless just works through the queue bit by bit.</div>
</div>
<p>Not sure which server size is enough? The <a href="/en/server-calculator/">server calculator</a>
calculates the RAM and CPU needs for your services.</p>
<div class="not-prose my-6 overflow-hidden rounded-xl border border-paprika-200 bg-paprika-50 dark:border-paprika-800 dark:bg-paprika-900/20">
  <div class="flex items-center justify-between border-b border-paprika-200 bg-paprika-100 px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-paprika-700 dark:border-paprika-800 dark:bg-paprika-900/40 dark:text-paprika-300">
    <span>🍳 Recommendation</span>
    <span title="Links marked with * are affiliate links.">Ad</span>
  </div>
  <div class="flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between">
    <div>
      <p class="text-lg font-bold text-slate-900 dark:text-white">VPS 1000 G12</p>
      <p class="mt-1 text-sm text-slate-600 dark:text-slate-300">4 vCores · 8 GB RAM · 256 GB NVMe</p>
      <p class="mt-1 text-sm font-semibold text-paprika-700 dark:text-paprika-400">from €10.36/month</p>
      <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">The VPS 1000 handles Paperless including OCR with ease.</p>
    </div>
    <a href="https://www.netcup.com/en/server/vps/vps-1000-g12-iv-12m?ref=44083" rel="sponsored noopener" target="_blank"
   class="inline-flex shrink-0 items-center justify-center rounded-lg bg-paprika-600 px-5 py-2.5 font-semibold text-white transition-colors hover:bg-paprika-700">
  Go to netcup →
</a>

  </div><div class="px-4 pb-4"><p class="not-prose my-3 flex flex-wrap items-center gap-x-2 gap-y-1 rounded-lg border border-herb-500/40 bg-herb-50 px-3 py-2 text-sm text-slate-700 dark:bg-herb-900/20 dark:text-slate-200">
  <span>💶 <strong>5 € voucher</strong> for new netcup customers:</span>
  <code class="rounded bg-white px-2 py-0.5 font-mono text-sm font-semibold text-herb-800 dark:bg-slate-800 dark:text-herb-400">36nc17844976032</code>
  <span class="text-xs text-slate-500 dark:text-slate-400">(new customers only, no domains)</span>
</p></div>
</div>

<h2 id="step-by-step">Step by step</h2>
<h3 id="step-1-create-the-dns-record">Step 1: Create the DNS record</h3>
<p>Create <code>paperless.YOUR_DOMAIN</code> (A/AAAA to your server IP) and check:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">dig +short paperless.YOUR_DOMAIN</span></span></code></pre></div>
</div>
<p>Your server IP must come back – otherwise Traefik won&rsquo;t fetch a certificate later.</p>
<h3 id="step-2-generate-a-secret-key">Step 2: Generate a secret key</h3>
<p>Paperless encrypts sessions with a secret key. The default is <strong>publicly known</strong> – a real
risk for an instance on the internet. Generate your own:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">head -c <span class="m">50</span> /dev/urandom <span class="p">|</span> base64</span></span></code></pre></div>
</div>
<p>Copy the output – it goes into the configuration as <code>PAPERLESS_SECRET_KEY</code> shortly.</p>
<h3 id="step-3-create-the-composeyaml">Step 3: Create the compose.yaml</h3>
<p>Create the project – <strong>including</strong> the two bind-mount folders:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mkdir -p ~/paperless/<span class="o">{</span>consume,export<span class="o">}</span> <span class="o">&amp;&amp;</span> <span class="nb">cd</span> ~/paperless</span></span></code></pre></div>
</div>
<p>You deliberately create the subfolders <code>consume</code> and <code>export</code> <strong>now, as a normal user</strong>: if
the Docker daemon created them at startup, they would belong to <code>root</code> – then neither you
(<code>cp</code> into the consume folder) nor Paperless itself (runs via <code>USERMAP_UID</code> as UID 1000)
could write into them.</p>
<p>Create <code>compose.yaml</code>. Replace <code>paperless.YOUR_DOMAIN</code>, the passwords and the
<code>PAPERLESS_SECRET_KEY</code>:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">paperless</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">broker</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">docker.io/library/redis:8</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">redisdata:/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">default]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">db</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">docker.io/library/postgres:18</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">POSTGRES_DB</span><span class="p">:</span><span class="w"> </span><span class="l">paperless</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">POSTGRES_USER</span><span class="p">:</span><span class="w"> </span><span class="l">paperless</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">POSTGRES_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">A_STRONG_DB_PASSWORD</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">pgdata:/var/lib/postgresql</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">default]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">gotenberg</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">docker.io/gotenberg/gotenberg:8.34</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;gotenberg&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--chromium-disable-javascript=true&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--chromium-allow-list=file:///tmp/.*&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">default]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">tika</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">docker.io/apache/tika:3.3.1.0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">default]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">webserver</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">ghcr.io/paperless-ngx/paperless-ngx:2.20.15</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">db, broker, gotenberg, tika]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PAPERLESS_REDIS</span><span class="p">:</span><span class="w"> </span><span class="l">redis://broker:6379</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PAPERLESS_DBHOST</span><span class="p">:</span><span class="w"> </span><span class="l">db</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PAPERLESS_DBUSER</span><span class="p">:</span><span class="w"> </span><span class="l">paperless</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PAPERLESS_DBPASS</span><span class="p">:</span><span class="w"> </span><span class="l">A_STRONG_DB_PASSWORD</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PAPERLESS_TIKA_ENABLED</span><span class="p">:</span><span class="w"> </span><span class="m">1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PAPERLESS_TIKA_GOTENBERG_ENDPOINT</span><span class="p">:</span><span class="w"> </span><span class="l">http://gotenberg:3000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PAPERLESS_TIKA_ENDPOINT</span><span class="p">:</span><span class="w"> </span><span class="l">http://tika:9998</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PAPERLESS_URL</span><span class="p">:</span><span class="w"> </span><span class="l">https://paperless.YOUR_DOMAIN</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PAPERLESS_SECRET_KEY</span><span class="p">:</span><span class="w"> </span><span class="l">YOUR_LONG_SECRET_KEY</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PAPERLESS_OCR_LANGUAGE</span><span class="p">:</span><span class="w"> </span><span class="l">eng</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PAPERLESS_TIME_ZONE</span><span class="p">:</span><span class="w"> </span><span class="l">Europe/Berlin</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PAPERLESS_ADMIN_USER</span><span class="p">:</span><span class="w"> </span><span class="l">admin</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PAPERLESS_ADMIN_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">A_STRONG_ADMIN_PASSWORD</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">USERMAP_UID</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;1000&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">USERMAP_GID</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;1000&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">data:/usr/src/paperless/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">media:/usr/src/paperless/media</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./export:/usr/src/paperless/export</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./consume:/usr/src/paperless/consume</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">labels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.enable=true&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.ppl.rule=Host(`paperless.YOUR_DOMAIN`)&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.ppl.entrypoints=websecure&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.ppl.tls.certresolver=le&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.services.ppl.loadbalancer.server.port=8000&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">default, proxy]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">data</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">media</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">pgdata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">redisdata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">default</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">proxy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">external</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span></span></span></code></pre></div>
</div>
<p>The most important points:</p>
<ul>
<li><strong>Only <code>webserver</code> is on the <code>proxy</code> network</strong> and carries Traefik labels (port <strong>8000</strong>).
The four helper services (db, broker, gotenberg, tika) stay internal.</li>
<li><strong><code>PAPERLESS_URL</code></strong> is mandatory behind a proxy. Without it, Paperless rejects the login
with a CSRF/&ldquo;403 Forbidden&rdquo; error. It simultaneously sets the allowed hosts and the trusted
origins.</li>
<li><strong><code>PAPERLESS_OCR_LANGUAGE: eng</code></strong> sets text recognition to English. English, German and a
few other languages are already included in the image – no additional package needed.</li>
<li><strong><code>PAPERLESS_ADMIN_USER</code> / <code>_PASSWORD</code></strong> create the superuser automatically on the first
start, so you can log in directly.</li>
<li><strong><code>USERMAP_UID</code>/<code>GID</code></strong> should match your server user&rsquo;s ID (determine via <code>id -u</code> or
<code>id -g</code>, usually <code>1000</code>). Otherwise there&rsquo;s &ldquo;Permission denied&rdquo; in the consume folder.</li>
<li><strong><code>export</code> and <code>consume</code></strong> are deliberately folders in the project directory (bind mounts):
<code>consume</code> is the inbox, <code>export</code> the target for backups.</li>
</ul>
<p>Paperless uses <strong>four storage areas</strong> in total, which you should keep apart: <code>media</code> contains
your <strong>processed documents</strong> (the heart!), <code>data</code> the search index and helper data, <code>consume</code>
is the inbox and <code>export</code> the backup target. For the backup, <code>media</code> and <code>data</code> plus the
database count – exactly what the <code>document_exporter</code> further below takes off your hands.</p>
<h3 id="step-4-start-and-log-in">Step 4: Start and log in</h3>
<p>Pull the images (several GB) and start:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose up -d
</span></span><span class="line"><span class="cl">docker compose logs -f webserver</span></span></code></pre></div>
</div>
<p>On the first start, Paperless sets up the database (migrations) – that takes a moment. Check
that all five containers are running:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose ps</span></span></code></pre></div>
</div>
<p>You should see <code>webserver</code>, <code>db</code>, <code>broker</code>, <code>gotenberg</code> and <code>tika</code> with status <code>running</code> (the
<code>webserver</code> becomes <code>healthy</code> after a short while). Once the worker is ready (<code>celery@… ready</code>
in the log), open <code>https://paperless.YOUR_DOMAIN</code>. The login page appears:</p>
<p><figure class="my-6"><img src="/en/tutorials/self-host-paperless-ngx/paperless-login_hu_8c17b0adc53e6d7c.webp" srcset="/en/tutorials/self-host-paperless-ngx/paperless-login_hu_eab31263f0071468.webp 480w, /en/tutorials/self-host-paperless-ngx/paperless-login_hu_8c17b0adc53e6d7c.webp 768w, /en/tutorials/self-host-paperless-ngx/paperless-login_hu_4c5105a0d43bf62d.webp 1200w, /en/tutorials/self-host-paperless-ngx/paperless-login_hu_b34074ae087a306b.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/self-host-paperless-ngx/paperless-login_hu_548768b5baa3e389.webp"
    alt="The login page of Paperless-ngx under your own domain" title="The login – the superuser was created via environment variable"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The login – the superuser was created via environment variable</figcaption></figure></p>
<p>Log in with <code>admin</code> and your password. You land on the <strong>home page</strong> with a short welcome
message and initial statistics. At the bottom left you see the running <strong>version</strong> – handy to
know the starting point before an update. You can change the language under <strong>Settings</strong> if
needed; by default Paperless follows your browser&rsquo;s language:</p>
<p><figure class="my-6"><img src="/en/tutorials/self-host-paperless-ngx/paperless-dashboard_hu_532c17eeb9a02757.webp" srcset="/en/tutorials/self-host-paperless-ngx/paperless-dashboard_hu_24d85502f951755a.webp 480w, /en/tutorials/self-host-paperless-ngx/paperless-dashboard_hu_532c17eeb9a02757.webp 768w, /en/tutorials/self-host-paperless-ngx/paperless-dashboard_hu_5f8d48fef757cd12.webp 1200w, /en/tutorials/self-host-paperless-ngx/paperless-dashboard_hu_d217a5d44c702c2a.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/self-host-paperless-ngx/paperless-dashboard_hu_b512bdf2de2fef4d.webp"
    alt="The home page of Paperless-ngx with a welcome message and statistics widget" title="The home page after the first login"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The home page after the first login</figcaption></figure></p>
<h3 id="step-5-import-the-first-document">Step 5: Import the first document</h3>
<p>Now the core. There are three ways to get a document in:</p>
<ol>
<li><strong>Via the web interface:</strong> click <strong>Upload documents</strong> at the top right and choose a PDF or
image file.</li>
<li><strong>Via the consume folder:</strong> place a file in <code>~/paperless/consume</code> – Paperless detects it
automatically, processes it and then deletes it from the folder. Ideal for a network
scanner that scans directly there.</li>
<li><strong>From a mailbox:</strong> under <strong>Email</strong>, Paperless can fetch an IMAP account and import
attachments automatically – handy for invoices that come to you by mail anyway.</li>
</ol>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cp ~/an-invoice.pdf ~/paperless/consume/</span></span></code></pre></div>
</div>
<p>Paperless accepts PDFs, images (JPG/PNG/TIFF) and – thanks to Gotenberg and Tika – office
files like Word or Excel. For a pure image scan, the <strong>OCR</strong> reads out the text and places it
as a searchable layer over the document; for a PDF with an existing text layer, Paperless
skips the recognition and is correspondingly faster. The original is preserved untouched –
Paperless additionally creates a searchable archive version.</p>
<p>After a few seconds (OCR takes some time), the document appears under <strong>Documents</strong> – with a
preview and the recognized title:</p>
<p><figure class="my-6"><img src="/en/tutorials/self-host-paperless-ngx/paperless-dokumente_hu_84777342d82cdc6b.webp" srcset="/en/tutorials/self-host-paperless-ngx/paperless-dokumente_hu_9bb40ad0b8765b92.webp 480w, /en/tutorials/self-host-paperless-ngx/paperless-dokumente_hu_84777342d82cdc6b.webp 768w, /en/tutorials/self-host-paperless-ngx/paperless-dokumente_hu_a4d3e509f7d12dd9.webp 1200w, /en/tutorials/self-host-paperless-ngx/paperless-dokumente_hu_e1dfa31205f60c26.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/self-host-paperless-ngx/paperless-dokumente_hu_cd05852f670666a2.webp"
    alt="The document list of Paperless-ngx with a processed document as a tile" title="The document list – the imported document is immediately searchable"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The document list – the imported document is immediately searchable</figcaption></figure></p>
<p>A click opens the <strong>detail view</strong>: on the left the metadata (title, date, correspondent,
tags) and the tabs for content, metadata and history, on the right the document with a
zoomable preview. Paperless has already <strong>recognized the date</strong> from the scan and suggests it
for confirmation – exactly what the OCR does for you in the background:</p>
<p><figure class="my-6"><img src="/en/tutorials/self-host-paperless-ngx/paperless-dokument-detail_hu_2f3ba14ecbae68c7.webp" srcset="/en/tutorials/self-host-paperless-ngx/paperless-dokument-detail_hu_cc142753382bd2b3.webp 480w, /en/tutorials/self-host-paperless-ngx/paperless-dokument-detail_hu_2f3ba14ecbae68c7.webp 768w, /en/tutorials/self-host-paperless-ngx/paperless-dokument-detail_hu_510c85a62c169f85.webp 1200w, /en/tutorials/self-host-paperless-ngx/paperless-dokument-detail_hu_975081bceb44bc8e.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/self-host-paperless-ngx/paperless-dokument-detail_hu_96c911f3a177fc51.webp"
    alt="The detail view of a document in Paperless-ngx with metadata on the left and the recognized document on the right" title="Detail view: OCR recognized text and date, metadata can be supplemented"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Detail view: OCR recognized text and date, metadata can be supplemented</figcaption></figure></p>
<h3 id="step-6-order-with-tags-correspondents--document-types">Step 6: Order with tags, correspondents &amp; document types</h3>
<p>So that the search works later, you assign <strong>tags</strong> (e.g. <code>Tax</code>, <code>Insurance</code>), assign a
<strong>correspondent</strong> (the sender) and a <strong>document type</strong> (invoice, contract …). You can do that
by hand – or automate it via <strong>workflows</strong>: rules that automatically tag incoming documents
based on their content. This way your archive sorts itself over time.</p>
<p>The <strong>full-text search</strong> at the top then searches not only titles and tags, but the complete
recognized text – a search for <code>invoice amount</code> or a customer name finds the matching
document in seconds. Combined with the filters (correspondent, period, document type), the
paper pile finally becomes a searchable archive.</p>
<p>A simple example of a <strong>workflow</strong>: if a new document contains the word &ldquo;electricity bill&rdquo;,
automatically assign the tag <code>Energy</code>, set the correspondent to your electricity provider and
the document type to <code>Invoice</code>. You create such rules under <strong>Administration → Workflows</strong>;
they take effect for every incoming document. At the start it pays to sort a few documents by
hand – from that you quickly see which rules repeat and can be automated. After a few weeks,
the majority of your mail lands in the right place without your involvement.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-herb-400 bg-herb-50 dark:border-herb-700 dark:bg-herb-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🧑‍🍳</span>Scanner directly into the consume folder
  </p>
  <div class="prose-kitchen text-sm">The full convenience comes with a document scanner. Set up a network share on the server
(e.g. Samba) that points to <code>~/paperless/consume</code>, and configure your scanner to scan there.
From then on: insert sheet, press button – a few seconds later the document is recognized,
tagged and searchable in the archive. For on the go there are also community apps (e.g.
&ldquo;Paperless Mobile&rdquo;) that connect to your instance.</div>
</div>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-amber-400 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">⚠️</span>Your documents are on the internet
  </p>
  <div class="prose-kitchen text-sm">Paperless holds sensitive documents – invoices, contracts, official mail. As soon as the
instance is reachable via <code>paperless.YOUR_DOMAIN</code>, the login page is open on the net. So set
a <strong>long, unique password</strong>, and enable <strong>two-factor authentication</strong> under <strong>Settings</strong>.
Whoever wants maximum security makes Paperless reachable only via a VPN – for a purely private
archive that only you use, that&rsquo;s worth considering.</div>
</div>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong>On login, &ldquo;Forbidden (403)&rdquo; or &ldquo;CSRF verification failed&rdquo; appears.</strong> <code>PAPERLESS_URL</code> is not
set or set wrong. It must exactly match your HTTPS address (<code>https://paperless.YOUR_DOMAIN</code>,
without a trailing slash). After the correction, <code>docker compose up -d</code>.</p>
<p><strong>Files in the consume folder aren&rsquo;t processed, &ldquo;permission denied&rdquo;.</strong> Most common cause: the
folders weren&rsquo;t created beforehand (step 3), but generated by the Docker daemon on the first
start – then they belong to <code>root</code>. With <code>sudo chown -R $(id -u):$(id -g) ~/paperless/consume ~/paperless/export</code> they belong to you again. Otherwise: <code>USERMAP_UID</code>/<code>USERMAP_GID</code> don&rsquo;t
match the folder&rsquo;s owner – determine your ID with <code>id -u</code> and <code>id -g</code>, enter the values and
restart.</p>
<p><strong>Uploaded documents get stuck &ldquo;in processing&rdquo;.</strong> The background processing runs via Redis.
Check that the <code>broker</code> container is running, and look under <strong>File Tasks</strong> for the error
message of the failed task.</p>
<p><strong>Text recognition returns gibberish or recognizes nothing.</strong> Wrong OCR language. Set
<code>PAPERLESS_OCR_LANGUAGE=eng</code> (or <code>eng+deu</code> for mixed documents). Only installed languages
work.</p>
<p><strong>Office documents (Word, Excel) aren&rsquo;t accepted or end in a timeout.</strong> Gotenberg and Tika
are responsible for those. Check that both containers are running and
<code>PAPERLESS_TIKA_ENABLED=1</code> plus the two endpoint variables are set.</p>
<p><strong>During bulk import the server gets very slow, the CPU is constantly maxed out.</strong> OCR is
compute-intensive, and Paperless uses all cores by default. On small servers you can throttle
the load by limiting the number of workers or threads (<code>PAPERLESS_TASK_WORKERS</code>,
<code>PAPERLESS_THREADS_PER_WORKER</code>). Then the import takes longer, but the interface stays
usable.</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<ul>
<li>
<p><strong>The clean backup is done by the <code>document_exporter</code>.</strong> It writes all documents,
thumbnails, metadata and the database content into the <code>export</code> folder – portable and
re-importable:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose <span class="nb">exec</span> -T webserver document_exporter ../export</span></span></code></pre></div>
</div>
<p>Then back up the <code>export</code> folder <strong>encrypted and off-site</strong> with
<a href="/en/tutorials/restic-backups/">Restic</a>. Alternatively you back up the volumes <code>media</code>,
<code>data</code> and the database directly – but the exporter is the recommended, migration-safe way.</p>
</li>
<li>
<p><strong>Use the same version when restoring.</strong> An export contains an image matching the database
schema; only import it into a Paperless instance of the <strong>same version</strong>
(<code>document_importer ../export</code>).</p>
</li>
<li>
<p><strong>Updates.</strong> Before the update, pull a backup, then raise the image tag (e.g. <code>2.20.15</code> →
next version), <code>docker compose pull</code> and <code>docker compose up -d</code>. The database migrations run
automatically at startup. Stay on the stable <code>2.x</code> series – the <code>3.0</code> beta isn&rsquo;t meant for
production yet.</p>
</li>
<li>
<p><strong>Automate the backup.</strong> Best put the <code>document_exporter</code> in a daily cron job (e.g. at
night) that afterwards backs up the <code>export</code> folder via Restic. That way you have a fresh,
restorable state every morning – without having to think about it.</p>
</li>
<li>
<p><strong>Honest about the effort:</strong> Paperless runs very low-maintenance after that. The actual
effort is filing new documents – but with a bit of set-up automation, the software
increasingly takes that off your hands too. Plan the rough tag structure once, then the
archive largely maintains itself.</p>
</li>
</ul>
]]></content:encoded></item><item><title>Hardening &amp; optimizing Nextcloud: clear every warning (part 2)</title><link>https://serverkueche.de/en/tutorials/harden-optimize-nextcloud/</link><pubDate>Sun, 19 Jul 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/harden-optimize-nextcloud/</guid><description>Get your Nextcloud admin overview green: set up HSTS headers, email sending, enforced two-factor auth and brute-force protection with real IPs.</description><content:encoded><![CDATA[<p>Your Nextcloud is running – but the admin overview shows yellow warnings, there&rsquo;s no working
email and no second factor protects your accounts. In this second part we turn &ldquo;running&rdquo;
into &ldquo;cleanly secured and snappy&rdquo;: we work through every warning until the overview is green.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>By the end, the <strong>security &amp; setup overview</strong> of your Nextcloud shows no more warnings.
Concretely, we build on the installation from <a href="/en/tutorials/self-host-nextcloud/">part 1</a>
and add: the missing <strong>HSTS security header</strong> via Traefik, a configured <strong>maintenance
window</strong>, working <strong>email sending</strong> (for password resets and notifications), <strong>enforced
two-factor authentication</strong> and <strong>brute-force protection that sees the real attacker IPs</strong> –
the basis for Fail2ban.</p>
<p>Everything is tested against <strong>Nextcloud 34.0.1</strong> behind <strong>Traefik v3.7</strong> on Debian 13 with
PHP 8.5. This is legwork, but exactly the legwork missing in 90% of all Nextcloud guides –
and it makes the difference between &ldquo;somehow online&rdquo; and &ldquo;properly operated&rdquo;.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>A <strong>running Nextcloud</strong> set up as in
<a href="/en/tutorials/self-host-nextcloud/">self-hosting Nextcloud (part 1)</a>: classic <code>nextcloud</code>
image behind Traefik, with MariaDB, Redis and a cron sidecar. The container names from
part 1 (<code>nc-app</code>, <code>nc-db</code>, <code>nc-redis</code>, <code>nc-cron</code>) and the Compose folder <code>~/nextcloud</code> are
the basis here.</li>
<li><strong>SSH access</strong> to the server and the command-line tool <strong><code>occ</code></strong> – we call it as in part 1
in the app container: <code>docker exec -u www-data nc-app php occ &lt;command&gt;</code>.</li>
<li>A <strong>Traefik reverse proxy</strong> with the <code>proxy</code> network and the resolver <code>le</code> as in the
tutorial <a href="/en/tutorials/traefik-reverse-proxy/">reverse proxy with Traefik</a> – we attach the
security headers as a Traefik middleware.</li>
</ul>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-herb-400 bg-herb-50 dark:border-herb-700 dark:bg-herb-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🧑‍🍳</span>Keeping occ in hand
  </p>
  <div class="prose-kitchen text-sm"><p><code>occ</code> is Nextcloud&rsquo;s admin tool. Because the containers in part 1 got fixed names
(<code>container_name</code>), you address the app container directly – no matter which directory you&rsquo;re
in:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker <span class="nb">exec</span> -u www-data nc-app php occ status</span></span></code></pre></div>
</div>
<p>You should see <code>installed: true</code> and your version. We won&rsquo;t abbreviate this prefix
(<code>docker exec -u www-data nc-app php occ …</code>) in the following – so you can copy every command
directly. Only the few <code>docker compose …</code> commands (like the container restart in step 3) you
still run from the <code>~/nextcloud</code> folder.</p>
</div>
</div>
<h2 id="step-by-step">Step by step</h2>
<h3 id="step-1-read-the-admin-overview-as-a-stock-take">Step 1: Read the admin overview as a stock-take</h3>
<p>Log in as administrator and open <strong>Administration settings → Overview</strong>. At the top is the
<strong>&ldquo;Security &amp; setup warnings&rdquo;</strong> section. Right after a standard installation it looks like
this:</p>
<p><figure class="my-6"><img src="/en/tutorials/harden-optimize-nextcloud/nc-uebersicht-warnungen_hu_1c52167f2e50ee81.webp" srcset="/en/tutorials/harden-optimize-nextcloud/nc-uebersicht-warnungen_hu_abb4be326f248f94.webp 480w, /en/tutorials/harden-optimize-nextcloud/nc-uebersicht-warnungen_hu_1c52167f2e50ee81.webp 768w, /en/tutorials/harden-optimize-nextcloud/nc-uebersicht-warnungen_hu_486b6513ab603031.webp 1200w, /en/tutorials/harden-optimize-nextcloud/nc-uebersicht-warnungen_hu_2dc71aec63365520.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/harden-optimize-nextcloud/nc-uebersicht-warnungen_hu_19f3c57f5382e577.webp"
    alt="The Nextcloud admin overview lists several yellow warnings about the maintenance window, MIME-type migrations and HTTP headers" title="Starting point: three warnings and several notices in the admin overview"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Starting point: three warnings and several notices in the admin overview</figcaption></figure></p>
<p>Nextcloud distinguishes three levels: <strong>yellow warnings</strong> (⚠, you should fix), <strong>blue notices</strong>
(ℹ, usually optional) and silently passed checks. On our fresh instance there are three
warnings and a few notices:</p>
<ul>
<li>⚠ <strong>Maintenance window start</strong> is not set (step 2)</li>
<li>⚠ <strong>MIME-type migrations available</strong> (step 2)</li>
<li>⚠ <strong>HTTP headers</strong> – the <code>Strict-Transport-Security</code> header is missing (step 3)</li>
<li>ℹ <strong>Email test</strong> – no mail server configured yet (step 4)</li>
<li>ℹ <strong>Two-factor configuration</strong> – 2FA is available but not enforced (step 5)</li>
</ul>
<p>You also get the same report on the command line – handy to check progress in between without
clicking through the web interface:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker <span class="nb">exec</span> -u www-data nc-app php occ setupchecks</span></span></code></pre></div>
</div>
<p>The command outputs each check with ✓, ⚠ or ✗. We now work through exactly this list from top
to bottom.</p>
<h3 id="step-2-fix-the-two-quick-warnings">Step 2: Fix the two quick warnings</h3>
<p>Two warnings are done with one command each.</p>
<p><strong>Set the maintenance window.</strong> Nextcloud runs compute-intensive cleanup jobs once daily
(previews, activities, calendar repeats). Without a defined time window they run <em>at some
point</em> – even in the middle of the day. Set a start time when hardly anyone works. The value
is a <strong>full hour in UTC</strong>; <code>1</code> sets the start to 01:00 UTC – the check afterwards reports the
window from 1:00 to 7:00 UTC:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker <span class="nb">exec</span> -u www-data nc-app php occ config:system:set maintenance_window_start --type<span class="o">=</span>integer --value<span class="o">=</span><span class="m">1</span></span></span></code></pre></div>
</div>
<p>To verify, the setup check afterwards confirms:</p>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">✓ Maintenance window start: Maintenance window to execute heavy background jobs is between 1:00 UTC and 7:00 UTC</span></span></code></pre></div>
</div>
<p><strong>Run MIME-type migrations.</strong> Occasionally Nextcloud learns new file types (e.g. for better
icons and previews). This migration does <em>not</em> run automatically on updates because it can
take a while on large instances. On a fresh cloud it&rsquo;s done in seconds:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker <span class="nb">exec</span> -u www-data nc-app php occ maintenance:repair --include-expensive</span></span></code></pre></div>
</div>
<p>You see a list of repair steps performed (including <code>Repair mime types</code>). Afterwards the check
reports <code>Mimetype migrations available: None</code>. Two fewer warnings.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-sky-300 bg-sky-50 dark:border-sky-800 dark:bg-sky-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">ℹ️</span>And the blue notices?
  </p>
  <div class="prose-kitchen text-sm">The notices <strong>&ldquo;AppAPI deploy daemon&rdquo;</strong> and <strong>&ldquo;Server-ID configuration&rdquo;</strong> you can ignore on a
normal single-server installation. The first concerns only the installation of external
&ldquo;Ex-Apps&rdquo; via a Docker daemon, the second only setups spread across several PHP servers. You
need neither here – that&rsquo;s why they stay notices and not warnings.</div>
</div>
<h3 id="step-3-set-the-hsts-security-header-via-traefik">Step 3: Set the HSTS security header via Traefik</h3>
<p>The third warning is the most important: the <strong><code>Strict-Transport-Security</code> header</strong> (HSTS) is
missing. It instructs the browser to address this domain <strong>exclusively over HTTPS</strong> – even
when someone types <code>http://</code> or an attacker tries to redirect the connection. Without HSTS, a
small time window for downgrade attacks stays open.</p>
<p>Because our Nextcloud sits behind Traefik, we set the header <strong>in the proxy</strong>, not in
Nextcloud – that&rsquo;s where it belongs, since Traefik terminates the TLS. In the <code>compose.yaml</code>
of your Nextcloud (from part 1), add the middleware <code>nc-secure</code> and attach it to the router.
Concretely, two places change in the <code>labels</code> block of <code>nc-app</code>.</p>
<p>First, extend the router line with <code>nc-secure</code>:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.nc.middlewares=nc-dav,nc-secure&#34;</span></span></span></code></pre></div>
</div>
<p>Second, define the header middleware (right by the other <code>nc-app</code> labels):</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.middlewares.nc-secure.headers.stsSeconds=15552000&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.middlewares.nc-secure.headers.stsIncludeSubdomains=true&#34;</span></span></span></code></pre></div>
</div>
<p><code>stsSeconds=15552000</code> is 180 days – the minimum value Nextcloud requires. Apply the change and
reload only the app container:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose up -d nc-app</span></span></code></pre></div>
</div>
<p>After a few seconds, check from your own machine that the header is really delivered:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -sS -I https://cloud.YOUR_DOMAIN/ <span class="p">|</span> grep -i strict-transport</span></span></code></pre></div>
</div>
<p>You should see exactly this:</p>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">strict-transport-security: max-age=15552000; includeSubDomains</span></span></code></pre></div>
</div>
<p>In the admin overview the HTTP-header warning thus turns green: <em>&ldquo;Your server is correctly
configured to send security headers.&rdquo;</em> The remaining headers (<code>X-Content-Type-Options</code>,
<code>X-Frame-Options</code>, <code>Referrer-Policy</code> …) Nextcloud delivers itself – only HSTS had to come from
the proxy.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-amber-400 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">⚠️</span>preload is a one-way street
  </p>
  <div class="prose-kitchen text-sm"><code>stsIncludeSubdomains=true</code> extends the HTTPS requirement to all subdomains <strong>of
<code>cloud.YOUR_DOMAIN</code></strong> (not to siblings like <code>www.YOUR_DOMAIN</code> – the header always applies only
to the host that delivers it). Deliberately <strong>not</strong> set is <code>stsPreload=true</code>: with it you&rsquo;d
signal that you want the domain entered into the browsers'
<a href="https://hstspreload.org/">HSTS preload list</a> – and such an entry can only be undone with
weeks of lead time. If you ever deliberately want that: the preload list only accepts
<code>max-age</code> ≥ 31536000 (1 year) – with the 15552000 above the flag would be ineffective anyway.
Only set <code>preload</code> when really <em>every</em> service under the domain permanently speaks HTTPS; the
header is fully functional without it.</div>
</div>
<h3 id="step-4-set-up-email-sending">Step 4: Set up email sending</h3>
<p>Without working mail sending, Nextcloud can send no <strong>password resets</strong>, no <strong>share
notifications</strong> and no <strong>security warnings</strong> – and the &ldquo;Email test&rdquo; notice stays. We catch up
on that.</p>
<p>Open <strong>Administration settings → Basic settings</strong> and scroll to the <strong>&ldquo;Email server&rdquo;</strong>
section. Enter your mail provider&rsquo;s credentials:</p>
<ul>
<li><strong>Send mode:</strong> <code>SMTP</code></li>
<li><strong>Encryption:</strong> <code>SSL/TLS</code> (port <strong>465</strong>) or <code>STARTTLS</code> (port <strong>587</strong>) – never unencrypted</li>
<li><strong>From address:</strong> e.g. <code>cloud</code> @ <code>YOUR_DOMAIN</code></li>
<li><strong>Server address:</strong> host and port of your provider</li>
<li><strong>Authentication:</strong> enable and store username/password</li>
</ul>
<p>At the top, under <strong>Personal information</strong>, enter an email address for your admin account (the
test mail goes there). Then click <strong>&ldquo;Send test email&rdquo;</strong>. If the mail arrives, Nextcloud
confirms it with a green message:</p>
<p><figure class="my-6"><img src="/en/tutorials/harden-optimize-nextcloud/nc-email-test_hu_8d45e6800f6b3498.webp" srcset="/en/tutorials/harden-optimize-nextcloud/nc-email-test_hu_fa94582cdd4ce86a.webp 480w, /en/tutorials/harden-optimize-nextcloud/nc-email-test_hu_8d45e6800f6b3498.webp 768w, /en/tutorials/harden-optimize-nextcloud/nc-email-test_hu_c032c70fb8d96766.webp 1200w, /en/tutorials/harden-optimize-nextcloud/nc-email-test_hu_634c0ea546cf8596.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/harden-optimize-nextcloud/nc-email-test_hu_9c1f6dabcee5c7cf.webp"
    alt="The email server is configured with SMTP, a test send is confirmed at the top right with &ldquo;Email sent&rdquo;" title="Successful test send – the values in the image come from an internal test mail server and are NOT to be adopted for real mailboxes; there belong the host, port 587/465 with STARTTLS/SSL and your provider&#39;s credentials"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Successful test send – the values in the image come from an internal test mail server and are NOT to be adopted for real mailboxes; there belong the host, port 587/465 with STARTTLS/SSL and your provider&#39;s credentials</figcaption></figure></p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-herb-400 bg-herb-50 dark:border-herb-700 dark:bg-herb-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🧑‍🍳</span>App password instead of account password
  </p>
  <div class="prose-kitchen text-sm">If your mail provider itself uses two-factor authentication (Gmail, Mailbox.org, many others),
your normal login password does <strong>not</strong> work here. Create a dedicated <strong>app password</strong> in your
provider&rsquo;s account and enter that. It can also be revoked specifically without changing your
main password.</div>
</div>
<h3 id="step-5-enforce-two-factor-authentication">Step 5: Enforce two-factor authentication</h3>
<p>A stolen password is the most common way accounts get taken over. A <strong>second factor</strong> (a
time-based code from an authenticator app, TOTP) makes the password alone worthless. Nextcloud
already brings the matching provider – we just have to enable it and make it mandatory.</p>
<p>First enable the TOTP app (it&rsquo;s included):</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker <span class="nb">exec</span> -u www-data nc-app php occ app:enable twofactor_totp</span></span></code></pre></div>
</div>
<p>Each user then sets up their second factor themselves – under <strong>Personal settings → Security →
&ldquo;TOTP (Authenticator app)&rdquo;</strong>. On enabling, Nextcloud shows a QR code you scan with an app like
Aegis, andOTP or Google Authenticator. Additionally, the app <strong>&ldquo;Two-Factor Backup Codes&rdquo;</strong> is
recommended – the one-time codes save you if the phone is lost.</p>
<p>So that nobody &ldquo;forgets&rdquo; the safeguard, you enforce 2FA for the administrators. On the web
you find that under <strong>Administration settings → Security → &ldquo;Enforce two-factor
authentication&rdquo;</strong>, on the command line it works like this:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker <span class="nb">exec</span> -u www-data nc-app php occ twofactorauth:enforce --on --group<span class="o">=</span>admin</span></span></code></pre></div>
</div>
<p><figure class="my-6"><img src="/en/tutorials/harden-optimize-nextcloud/nc-zweifaktor_hu_852af47470939e12.webp" srcset="/en/tutorials/harden-optimize-nextcloud/nc-zweifaktor_hu_1a27814597c783f5.webp 480w, /en/tutorials/harden-optimize-nextcloud/nc-zweifaktor_hu_852af47470939e12.webp 768w, /en/tutorials/harden-optimize-nextcloud/nc-zweifaktor_hu_4b78d97a12110e38.webp 1200w, /en/tutorials/harden-optimize-nextcloud/nc-zweifaktor_hu_2c4ea048fe38829a.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/harden-optimize-nextcloud/nc-zweifaktor_hu_99357b7cbf56851d.webp"
    alt="The security settings show enforced two-factor authentication for the &ldquo;admin&rdquo; group as well as the brute-force status with a correctly recognized client IP" title="Two-factor requirement for the \&#34;admin\&#34; group – and above it the proof that Nextcloud recognizes the real client IP"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Two-factor requirement for the \&#34;admin\&#34; group – and above it the proof that Nextcloud recognizes the real client IP</figcaption></figure></p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-paprika-400 bg-paprika-50 dark:border-paprika-700 dark:bg-paprika-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🔥</span>Don&#39;t lock yourself out
  </p>
  <div class="prose-kitchen text-sm">As soon as enforcement is active, every member of the group <strong>must</strong> set up a second factor on
the next login. So set up <strong>your own</strong> second factor first and keep the backup codes safe
before you arm the requirement. If something does go wrong, you lift the enforcement again via
the command line: <code>docker exec -u www-data nc-app php occ twofactorauth:enforce --off</code>. A
single stuck factor you remove with <code>… php occ twofactorauth:disable USER totp</code>.</div>
</div>
<h3 id="step-6-brute-force-protection-with-real-ips--basis-for-fail2ban">Step 6: Brute-force protection with real IPs – basis for Fail2ban</h3>
<p>Nextcloud throttles failed logins out of the box: after several failed attempts it delays
further requests from <strong>the same IP</strong>. But this protection stands and falls with Nextcloud
seeing the <strong>real client IP</strong> – and not the internal IP of Traefik. That&rsquo;s exactly what the
<code>TRUSTED_PROXIES</code> setting from part 1 does.</p>
<p>Whether it works you see under <strong>Administration settings → Security</strong> in the &ldquo;brute-force
allow list&rdquo; box (in the screenshot above). There stands your current, correctly recognized
public IP:</p>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Your current IP address is recognized as &#34;YOUR_IP&#34;. This address is currently not throttled.</span></span></code></pre></div>
</div>
<p>If a <code>172.x.x.x</code> address from the Docker network appears there instead, <code>TRUSTED_PROXIES</code>
isn&rsquo;t taking effect – then the brute-force protection would lump all users together and, in
doubt, ban Traefik itself. In that case, back to part 1, step 2/3.</p>
<p><strong>Hard bans with Fail2ban.</strong> The built-in throttling only delays; whoever wants to really ban
attackers combines Nextcloud with <a href="/en/tutorials/fail2ban-setup/">Fail2ban</a>. Nextcloud logs
every failed attempt in the JSON log – and thanks to <code>TRUSTED_PROXIES</code> with the real IP:</p>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">{&#34;level&#34;:2,&#34;time&#34;:&#34;2026-07-18T08:14:05+00:00&#34;,&#34;remoteAddr&#34;:&#34;203.0.113.47&#34;,&#34;user&#34;:&#34;--&#34;,&#34;app&#34;:&#34;no app in context&#34;,&#34;method&#34;:&#34;POST&#34;,&#34;url&#34;:&#34;/login&#34;,&#34;message&#34;:&#34;Login failed: USER (Remote IP: 203.0.113.47)&#34;}</span></span></code></pre></div>
</div>
<p>This log lives in the Nextcloud volume. You find the path on the host like this:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker inspect -f <span class="s1">&#39;{{ range .Mounts }}{{ if eq .Destination &#34;/var/www/html&#34; }}{{ .Source }}{{ end }}{{ end }}&#39;</span> nc-app</span></span></code></pre></div>
</div>
<p>Below it is the file <code>data/nextcloud.log</code>. At that you point a Fail2ban filter that matches on
<code>remoteAddr</code>, plus a jail following the pattern from the
<a href="/en/tutorials/fail2ban-setup/">Fail2ban tutorial</a>. This way, after a few failed attempts, the
attacker IP goes straight into the firewall – before the request even reaches Nextcloud.</p>
<h3 id="step-7-check-caching--php--the-performance-foundation">Step 7: Check caching &amp; PHP – the performance foundation</h3>
<p>Finally, we make sure the performance basis is in place. Much of this part 1 already set up
automatically via the <code>REDIS_HOST</code> variable – checking doesn&rsquo;t hurt anyway, because this is
exactly where a Nextcloud gets sluggish or snappy.</p>
<p>Nextcloud uses <strong>three</strong> cache roles. Check all three:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker <span class="nb">exec</span> -u www-data nc-app php occ config:system:get memcache.local
</span></span><span class="line"><span class="cl">docker <span class="nb">exec</span> -u www-data nc-app php occ config:system:get memcache.distributed
</span></span><span class="line"><span class="cl">docker <span class="nb">exec</span> -u www-data nc-app php occ config:system:get memcache.locking</span></span></code></pre></div>
</div>
<p>Expected:</p>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">\OC\Memcache\APCu
</span></span><span class="line"><span class="cl">\OC\Memcache\Redis
</span></span><span class="line"><span class="cl">\OC\Memcache\Redis</span></span></code></pre></div>
</div>
<ul>
<li><strong>Local cache (APCu):</strong> keeps frequently used data in the RAM of the PHP process – the most
noticeable accelerator in everyday use.</li>
<li><strong>Distributed cache (Redis):</strong> shares cache data across processes, important once the app and
cron containers interact.</li>
<li><strong>File locking (Redis):</strong> prevents two accesses from changing the same file simultaneously and
corrupting it. The reason we built in Redis at all in part 1.</li>
</ul>
<p>If the interplay fits, <code>docker exec -u www-data nc-app php occ setupchecks</code> reports the line
<code>✓ Memcache: Configured</code>. (Passed checks aren&rsquo;t shown by the web overview – only warnings and
notices appear there, which is why you check this via CLI.)</p>
<p><strong>OPcache</strong> – PHP&rsquo;s bytecode cache – is already sensibly preconfigured in the official
<code>nextcloud</code> image (including <code>opcache.interned_strings_buffer=32</code>). So you don&rsquo;t have to
adjust anything here; the formerly notorious OPcache warning no longer appears at all with
current images. You can check it if needed like this:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker <span class="nb">exec</span> -u www-data nc-app php -i <span class="p">|</span> grep opcache.interned_strings_buffer</span></span></code></pre></div>
</div>
<p>With that, the overview is warning-free:</p>
<p><figure class="my-6"><img src="/en/tutorials/harden-optimize-nextcloud/nc-uebersicht-gruen_hu_3292f5806336612e.webp" srcset="/en/tutorials/harden-optimize-nextcloud/nc-uebersicht-gruen_hu_6b3888444730c10c.webp 480w, /en/tutorials/harden-optimize-nextcloud/nc-uebersicht-gruen_hu_3292f5806336612e.webp 768w, /en/tutorials/harden-optimize-nextcloud/nc-uebersicht-gruen_hu_2a971e5ec7202dca.webp 1200w, /en/tutorials/harden-optimize-nextcloud/nc-uebersicht-gruen_hu_b6c2c3da23b5f506.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/harden-optimize-nextcloud/nc-uebersicht-gruen_hu_f8c15f84dd9e1da.webp"
    alt="The Nextcloud admin overview shows no more yellow warnings, only two optional notices, and reports the current version" title="Result: no more warnings, version current – only the two optional notices remain"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Result: no more warnings, version current – only the two optional notices remain</figcaption></figure></p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-herb-400 bg-herb-50 dark:border-herb-700 dark:bg-herb-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🧑‍🍳</span>Generate previews proactively
  </p>
  <div class="prose-kitchen text-sm">If your users store many photos, the app <strong>&ldquo;Preview Generator&rdquo;</strong> is worth it: an <code>occ</code> cron job
generates thumbnails in advance instead of computing them slowly on the fly on first open. If
you add it, limit the maximum preview size via
<code>occ config:system:set preview_max_x --value=2048</code> (and <code>preview_max_y</code>), otherwise the storage
demand grows quickly.</div>
</div>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong>After the Traefik restart, the HSTS header doesn&rsquo;t appear in the <code>curl</code> output.</strong> Usually
the middleware isn&rsquo;t on the router. Check that the line <code>traefik.http.routers.nc.middlewares</code>
names <strong>both</strong> middlewares (<code>nc-dav,nc-secure</code>) and that the three <code>nc-secure</code> header labels
are in the <code>nc-app</code> block. Then <code>docker compose up -d nc-app</code>. Traefik only picks up changed
labels when recreating the container.</p>
<p><strong>The email test fails (&ldquo;There was a problem sending the email&rdquo;).</strong> Almost always
port/encryption or authentication. Combine <code>SSL/TLS</code> with port <strong>465</strong> or <code>STARTTLS</code> with port
<strong>587</strong> – not crosswise. If your provider uses 2FA, you need an <strong>app password</strong> (see step 4).
Details are in the Nextcloud log: <code>docker exec -u www-data nc-app php occ log:tail 20</code>.</p>
<p><strong>You can&rsquo;t get in yourself after enforcing 2FA.</strong> You hadn&rsquo;t set up a second factor yet. Lift
the requirement via the command line, set up your factor calmly and arm it again afterwards:
<code>docker exec -u www-data nc-app php occ twofactorauth:enforce --off</code>.</p>
<p><strong>In the brute-force box a <code>172.x.x.x</code> address appears instead of your real IP.</strong>
<code>TRUSTED_PROXIES</code> doesn&rsquo;t match the actual <code>proxy</code> subnet. Determine it with <code>docker network inspect proxy -f '{{(index .IPAM.Config 0).Subnet}}'</code> and enter the value in the <code>nc-app</code>
environment (part 1, step 2). Otherwise the brute-force protection bans the proxy in an
emergency and thus all users.</p>
<p><strong>After a reboot, Nextcloud reports &ldquo;Redis went away&rdquo; or gets very slow.</strong> The app container
started before Redis. Make sure <code>nc-redis</code> is in <code>depends_on</code> (part 1) and runs with <code>restart: unless-stopped</code> – then Docker catches the start-order case itself.</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<ul>
<li><strong>Check the overview after every update.</strong> New Nextcloud versions bring new checks. After
every upgrade take a look at <strong>Administration settings → Overview</strong> or run <code>occ setupchecks</code> –
this way you catch new warnings early.</li>
<li><strong>Use the official security scan regularly.</strong> The
<a href="https://scan.nextcloud.com/">Nextcloud Security Scan</a> checks your domain from outside and
assigns a grade. With HSTS and current versions, <strong>A/A+</strong> is the realistic target; if the
grade drops, an update is usually overdue.</li>
<li><strong>Keep an eye on second factors.</strong> Keep the backup codes separate from the phone. If a user
loses their device, you remove their factor with <code>occ twofactorauth:disable USER totp</code>, after
which they set it up anew.</li>
<li><strong>Clean up app passwords.</strong> Every connected client (desktop, phone) gets its own app
password. Under <strong>Personal settings → Security</strong> you see all devices and can specifically
revoke individual ones on loss.</li>
<li><strong>The hardening replaces no backup.</strong> All of this protects against foreign access, not
against data loss. The consistent backup of a database dump and data directory stays
mandatory – see the maintenance section in <a href="/en/tutorials/self-host-nextcloud/">part 1</a> and
<a href="/en/tutorials/restic-backups/">backups with Restic</a>. Honest about the effort: firmly plan
the monthly update pass with a quick look at the overview – then your cloud stays green
permanently.</li>
</ul>
]]></content:encoded></item><item><title>Installing Uptime Kuma: server monitoring behind Traefik</title><link>https://serverkueche.de/en/tutorials/uptime-kuma-monitoring/</link><pubDate>Sun, 19 Jul 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/uptime-kuma-monitoring/</guid><description>Set up Uptime Kuma behind Traefik and monitor your services: monitors, notifications and a status page – the first real app of the series.</description><content:encoded><![CDATA[<p>Traefik is up – now we hang the first real app behind it. <strong>Uptime Kuma</strong> 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 <em>you</em> notice first that something is stuck, not your
users. Along the way you learn the pattern every further app repeats.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>By the end, <strong>Uptime Kuma 2.4</strong> runs at <code>https://status.YOUR_DOMAIN</code>, secured via Traefik
with automatic HTTPS. You&rsquo;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 <strong>doesn&rsquo;t listen on port 80</strong> – an important detail for all
the following apps.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>A running <a href="/en/tutorials/traefik-reverse-proxy/">Traefik reverse proxy</a> including the
<code>proxy</code> network and a working Let&rsquo;s Encrypt resolver <code>le</code></li>
<li>A DNS record <code>status.YOUR_DOMAIN</code> that
<a href="/en/tutorials/connect-domain-to-server/">points to the server</a></li>
</ul>
<h2 id="step-by-step">Step by step</h2>
<h3 id="step-1-the-composeyaml">Step 1: The compose.yaml</h3>
<p>Own folder, own file:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mkdir -p ~/uptime-kuma <span class="o">&amp;&amp;</span> <span class="nb">cd</span> ~/uptime-kuma</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">uptime-kuma</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">louislam/uptime-kuma:2</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">kuma-data:/app/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">proxy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">labels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.enable=true&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.kuma.rule=Host(`status.YOUR_DOMAIN`)&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.kuma.entrypoints=websecure&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.kuma.tls.certresolver=le&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.services.kuma.loadbalancer.server.port=3001&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">kuma-data</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">proxy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">external</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span></span></span></code></pre></div>
</div>
<p>You know this from the Traefik tutorial – except for <strong>one new, decisive line</strong>:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.services.kuma.loadbalancer.server.port=3001&#34;</span></span></span></code></pre></div>
</div>
<p>Uptime Kuma listens internally on <strong>port 3001</strong>, not on 80. This line tells Traefik which
port to forward the requests to. Without it, Traefik guesses wrong and you get a <code>Bad Gateway</code>. Remember the label – every app that doesn&rsquo;t run on port 80 needs it.</p>
<p>The rest is the familiar pattern: <strong>no <code>ports:</code></strong> (only reachable via Traefik), <code>proxy</code>
network, named volume for the data.</p>
<h3 id="step-2-start-and-first-login">Step 2: Start and first login</h3>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose up -d
</span></span><span class="line"><span class="cl">docker compose logs -f uptime-kuma</span></span></code></pre></div>
</div>
<p>When Kuma is ready, the log ends with this line – from here the service accepts requests
(<code>Ctrl+C</code> only ends the following):</p>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">[SERVER] INFO: Welcome to Uptime Kuma
</span></span><span class="line"><span class="cl">[SERVER] INFO: Uptime Kuma Version: 2.4.0
</span></span><span class="line"><span class="cl">[SETUP-DATABASE] INFO: Listening on:
</span></span><span class="line"><span class="cl">[SETUP-DATABASE] INFO: -  http://localhost:3001</span></span></code></pre></div>
</div>
<p>On the first start, Kuma creates its database in the volume – that takes a few seconds. Then
open <code>https://status.YOUR_DOMAIN</code> in the browser. Uptime Kuma 2.x first asks for the
<strong>database</strong> – for a setup like ours, <strong>SQLite</strong> is the right, simplest choice (select it,
click <strong>Next</strong>). Right after that you create the <strong>admin account</strong> (username + strong
password).</p>
<p><figure class="my-6"><img src="/en/tutorials/uptime-kuma-monitoring/kuma-setup_hu_1d0c56e67101b8af.webp" srcset="/en/tutorials/uptime-kuma-monitoring/kuma-setup_hu_8fae09095cd4616e.webp 480w, /en/tutorials/uptime-kuma-monitoring/kuma-setup_hu_1d0c56e67101b8af.webp 768w, /en/tutorials/uptime-kuma-monitoring/kuma-setup_hu_6992115843b8c5c4.webp 1200w, /en/tutorials/uptime-kuma-monitoring/kuma-setup_hu_7dac57d86ca48603.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/uptime-kuma-monitoring/kuma-setup_hu_2fd60a2da8963f4b.webp"
    alt="Uptime Kuma&rsquo;s initial setup: choose the language and create the admin account" title="Initial setup: choose the language and create the admin account"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Initial setup: choose the language and create the admin account</figcaption></figure></p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-amber-400 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">⚠️</span>Create the admin account immediately
  </p>
  <div class="prose-kitchen text-sm">As long as no admin account exists, <strong>anyone</strong> who opens the page can create one. Do this
right after the first start – not &ldquo;later&rdquo;.</div>
</div>
<h3 id="step-3-create-the-first-monitor">Step 3: Create the first monitor</h3>
<p>A <strong>monitor</strong> is a recurring check. Click <strong>Add New Monitor</strong> and create one for Traefik
itself:</p>
<ul>
<li><strong>Monitor type:</strong> <code>HTTP(s)</code></li>
<li><strong>Friendly name:</strong> <code>Traefik Dashboard</code></li>
<li><strong>URL:</strong> <code>https://traefik.YOUR_DOMAIN</code></li>
<li><strong>Heartbeat interval:</strong> <code>60</code> seconds</li>
<li><strong>Authentication:</strong> the Traefik dashboard is protected by basic auth (step 7 of the
<a href="/en/tutorials/traefik-reverse-proxy/">Traefik tutorial</a>) – so choose <strong>HTTP Basic Auth</strong> as
the authentication method below and enter the user and password. Without credentials, Kuma
only gets <code>401</code> and reports the monitor as <strong>Down</strong>.</li>
</ul>
<p>Save – after a few seconds the monitor is <strong>Up</strong> (green) and shows the response time.</p>
<p><figure class="my-6"><img src="/en/tutorials/uptime-kuma-monitoring/kuma-dashboard_hu_ecad2a835a6b6005.webp" srcset="/en/tutorials/uptime-kuma-monitoring/kuma-dashboard_hu_b9fd222e62394661.webp 480w, /en/tutorials/uptime-kuma-monitoring/kuma-dashboard_hu_ecad2a835a6b6005.webp 768w, /en/tutorials/uptime-kuma-monitoring/kuma-dashboard_hu_84686854fd419462.webp 1200w, /en/tutorials/uptime-kuma-monitoring/kuma-dashboard_hu_25a9861864b2b7ef.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/uptime-kuma-monitoring/kuma-dashboard_hu_efc1a897fd354ecc.webp"
    alt="The Uptime Kuma dashboard with two running monitors – both green with 100% availability and &ldquo;200 – OK&rdquo;" title="The dashboard with two running monitors"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The dashboard with two running monitors</figcaption></figure></p>
<p>Besides the simple <code>HTTP(s)</code> check, it pays to choose the matching <strong>monitor type</strong>:</p>
<ul>
<li><strong>HTTP(s) – Keyword:</strong> additionally checks whether a certain word appears in the response
text. This detects &ldquo;the server responds, but shows an error page&rdquo;.</li>
<li><strong>TCP port:</strong> for services without a web interface (e.g. a database, an SSH port). Only
checks whether the port is open.</li>
<li><strong>Ping:</strong> the simplest reachability test via ICMP.</li>
<li><strong>Docker container:</strong> checks the container status directly via the Docker socket – handy
for internal services without their own domain.</li>
<li><strong>Push:</strong> here <em>the monitored service</em> calls Kuma regularly. Ideal for cron jobs and
backups: if the script doesn&rsquo;t report in time, Kuma raises the alarm (&ldquo;dead man&rsquo;s switch&rdquo;).</li>
</ul>
<p>Create the matching monitor for every important service.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-herb-400 bg-herb-50 dark:border-herb-700 dark:bg-herb-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🧑‍🍳</span>Tip
  </p>
  <div class="prose-kitchen text-sm">Check <strong>public services via their real domain</strong> (<code>https://…</code>), not via <code>localhost</code>. This way
you simultaneously test that Traefik and the certificate work from outside – not just that
the container is running.</div>
</div>
<p>For the <code>HTTP(s)</code> monitor, the option <strong>&ldquo;certificate expiry notification&rdquo;</strong> is also worth it:
Kuma then warns in good time before a TLS certificate expires. For services behind Traefik,
Let&rsquo;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.</p>
<h3 id="step-4-set-up-notifications-email-and-telegram">Step 4: Set up notifications (email and Telegram)</h3>
<p>A monitor without an alarm is just a pretty chart. Under <strong>Settings → Notifications → Setup
Notification</strong> you create a channel. Uptime Kuma supports over 90 – we set up the two most
common completely: <strong>email</strong> for the classic message and <strong>Telegram</strong> for push straight to
the phone.</p>
<h4 id="email-smtp">Email (SMTP)</h4>
<p>Choose &ldquo;Email (SMTP)&rdquo; as the <strong>notification service</strong> and enter your mail provider&rsquo;s data:</p>
<ul>
<li><strong>Hostname / port:</strong> e.g. <code>smtp.YOUR_PROVIDER.com</code> and <code>587</code></li>
<li><strong>Security:</strong> <code>STARTTLS</code> (port 587) or <code>TLS/SSL</code> (port 465)</li>
<li><strong>Username / password:</strong> your SMTP credentials</li>
<li><strong>From / to address:</strong> which address the warning comes from and which it&rsquo;s sent to</li>
</ul>
<p><figure class="my-6"><img src="/en/tutorials/uptime-kuma-monitoring/kuma-email_hu_7f1e9c91a901332a.webp" srcset="/en/tutorials/uptime-kuma-monitoring/kuma-email_hu_e7e333a89de51892.webp 480w, /en/tutorials/uptime-kuma-monitoring/kuma-email_hu_7f1e9c91a901332a.webp 768w, /en/tutorials/uptime-kuma-monitoring/kuma-email_hu_a1d62b8025eec0b7.webp 1200w, /en/tutorials/uptime-kuma-monitoring/kuma-email_hu_6c1a492676432907.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/uptime-kuma-monitoring/kuma-email_hu_dc87d46846cc61b7.webp"
    alt="The email notification (SMTP) in Uptime Kuma: hostname, port, security and from address" title="Setting up the email notification (SMTP)"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Setting up the email notification (SMTP)</figcaption></figure></p>
<p>Click <strong>Test</strong> – within a few seconds a test message lands in the mailbox. Only when it
really arrives are the credentials and port correct. Don&rsquo;t forget to save.</p>
<h4 id="telegram">Telegram</h4>
<p>Telegram is ideal for instant push alerts to the phone – without your own mail server. You
need two pieces of information:</p>
<ol>
<li><strong>Bot token:</strong> message <a href="https://t.me/BotFather">@BotFather</a> in Telegram, send <code>/newbot</code>,
assign a name – BotFather replies with the <strong>token</strong>.</li>
<li><strong>Chat ID:</strong> send your new bot any message, then open
<code>https://api.telegram.org/bot&lt;YOUR_TOKEN&gt;/getUpdates</code> in the browser and read the
<code>chat.id</code> from the response.</li>
</ol>
<p>Enter the token and chat ID into the Telegram notification (Uptime Kuma links both helpers
directly in the dialog) and click <strong>Test</strong> – the message should appear in the chat
immediately.</p>
<p><figure class="my-6"><img src="/en/tutorials/uptime-kuma-monitoring/kuma-telegram_hu_1feb4facbe88ee6e.webp" srcset="/en/tutorials/uptime-kuma-monitoring/kuma-telegram_hu_c4bb1dc005fd5de9.webp 480w, /en/tutorials/uptime-kuma-monitoring/kuma-telegram_hu_1feb4facbe88ee6e.webp 768w, /en/tutorials/uptime-kuma-monitoring/kuma-telegram_hu_f338e7e25e86656.webp 1200w, /en/tutorials/uptime-kuma-monitoring/kuma-telegram_hu_fa7e43c2efbd8e16.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/uptime-kuma-monitoring/kuma-telegram_hu_2c05a870636172bd.webp"
    alt="The Telegram notification in Uptime Kuma: bot token and chat ID, with a direct link to the BotFather" title="Telegram notification: bot token and chat ID"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Telegram notification: bot token and chat ID</figcaption></figure></p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-amber-400 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">⚠️</span>Otherwise the alarm stays silent
  </p>
  <div class="prose-kitchen text-sm">A configured notification does <strong>not</strong> take effect automatically. Enable it in each monitor
(checkbox in the monitor form) or, in the notification dialog, turn on &ldquo;Enabled by default&rdquo;
and &ldquo;Apply on all existing monitors&rdquo;.</div>
</div>
<p>If you want push messages entirely under your own control (without a Telegram server),
<strong>ntfy</strong> comes later – self-hosted, with its own recipe in the series.</p>
<h3 id="step-5-a-public-status-page-optional">Step 5: A public status page (optional)</h3>
<p>Kuma can publish a <strong>status page</strong> where visitors see whether your services are running.
Here&rsquo;s how:</p>
<ol>
<li><strong>Status Pages → New Status Page</strong>, then assign a <strong>name</strong> (e.g. &ldquo;Serverküche Status&rdquo;) and
a <strong>slug</strong> (the URL, e.g. <code>serverkueche</code>) and click <strong>Next</strong>.</li>
<li>In the editor, <strong>Add Group</strong> (e.g. &ldquo;Services&rdquo;), and below it, add the desired <strong>monitors</strong>
via the selection field.</li>
<li><strong>Save</strong> at the top right – done. The page is then publicly reachable at
<code>https://status.YOUR_DOMAIN/status/serverkueche</code>.</li>
</ol>
<p>Only include what really everyone may see – better leave internal services out.</p>
<p><figure class="my-6"><img src="/en/tutorials/uptime-kuma-monitoring/kuma-statusseite_hu_c4b45400765e134.webp" srcset="/en/tutorials/uptime-kuma-monitoring/kuma-statusseite_hu_34f9e0e0768594d.webp 480w, /en/tutorials/uptime-kuma-monitoring/kuma-statusseite_hu_c4b45400765e134.webp 768w, /en/tutorials/uptime-kuma-monitoring/kuma-statusseite_hu_b036ea3223a2da7.webp 1200w, /en/tutorials/uptime-kuma-monitoring/kuma-statusseite_hu_3c0dc17506dd64f2.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/uptime-kuma-monitoring/kuma-statusseite_hu_652cbd6c4d4b33a1.webp"
    alt="The public status page &ldquo;Serverküche Status&rdquo;: &ldquo;All Systems Operational&rdquo; and both services with 100% availability" title="The finished public status page"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The finished public status page</figcaption></figure></p>
<h3 id="step-6-avoid-false-alarms--fine-tune-the-alarm-behavior">Step 6: Avoid false alarms – fine-tune the alarm behavior</h3>
<p>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:</p>
<ul>
<li><strong>Retries:</strong> the service only counts as &ldquo;Down&rdquo; after <em>n</em> failed checks. <code>2</code>–<code>3</code> filters out
individual dropouts without obscuring real outages for long.</li>
<li><strong>Heartbeat interval on failure:</strong> Kuma may check more often in the error case (e.g. every
20 seconds) to detect recovery quickly.</li>
<li><strong>Resend notification:</strong> Kuma can remind you every <em>x</em> minutes as long as a service is down
– useful so an outage at night doesn&rsquo;t get lost in a single mail.</li>
</ul>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-herb-400 bg-herb-50 dark:border-herb-700 dark:bg-herb-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🧑‍🍳</span>Plan maintenance windows
  </p>
  <div class="prose-kitchen text-sm">If you plan an update with downtime, create a <strong>maintenance window</strong> under <strong>Maintenance</strong>.
Kuma then pauses the alarms for the affected monitors – so you (and the status page) don&rsquo;t
get false alarms while you&rsquo;re at work.</div>
</div>
<h3 id="step-7-monitor-cron-jobs-with-a-push-monitor">Step 7: Monitor cron jobs with a push monitor</h3>
<p>Classic monitors check from outside whether a service <em>responds</em>. For things that <strong>run
silently in the background</strong> – a nightly backup, a sync script, a cron job – the <strong>push
monitor</strong> flips the principle: not Kuma asks, but <em>your script reports in</em>. If the report
fails to come, Kuma raises the alarm – the classic &ldquo;dead man&rsquo;s switch&rdquo;.</p>
<p>Create a monitor of type <strong>Push</strong>. Kuma then shows you a unique <strong>push URL</strong>:</p>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">https://status.YOUR_DOMAIN/api/push/YOUR_TOKEN?status=up&amp;msg=OK&amp;ping=</span></span></code></pre></div>
</div>
<p>You call this URL at the end of your script – for example after a successfully completed
backup:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># ... your backup command ...</span>
</span></span><span class="line"><span class="cl">curl -fsS <span class="s2">&#34;https://status.YOUR_DOMAIN/api/push/YOUR_TOKEN?status=up&amp;msg=Backup+OK&#34;</span> &gt; /dev/null</span></span></code></pre></div>
</div>
<p>Set the <strong>check interval</strong> 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 <code>curl</code> comes in that time,
the monitor goes to <strong>Down</strong> and you&rsquo;re notified – so you learn about a backup that did <em>not</em>
run, not only when you urgently need it.</p>
<h3 id="step-8-secure-the-admin-login-with-2fa">Step 8: Secure the admin login with 2FA</h3>
<p>Your Kuma login protects access to all monitors, the stored notification credentials and the
status-page configuration – and it&rsquo;s publicly on the net. So enable <strong>two-factor
authentication</strong>: under <strong>Settings → Security → Two-Factor Authentication</strong>. Kuma shows a QR
code you scan with an authenticator app (e.g. Aegis or 2FAS); to activate, you enter the
generated code once.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-herb-400 bg-herb-50 dark:border-herb-700 dark:bg-herb-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🧑‍🍳</span>Secure the recovery
  </p>
  <div class="prose-kitchen text-sm">Keep the TOTP secret or a second authenticator in a safe place (password manager). If you lose
your phone <strong>and</strong> have no copy, you can otherwise only get back in via the database in the
<code>kuma-data</code> volume.</div>
</div>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong><code>Bad Gateway</code> (502) when opening <code>status.YOUR_DOMAIN</code>.</strong> Almost always the port label
<code>traefik.http.services.kuma.loadbalancer.server.port=3001</code> is missing or has a wrong port.
Traefik then reaches the container but knocks on the wrong port. Check the label and run
<code>docker compose up -d</code> again.</p>
<p><strong><code>404 page not found</code> instead of Kuma.</strong> As with every app behind Traefik:
<code>traefik.enable=true</code> set? Container on the <code>proxy</code> network? Is the domain in the <code>Host(...)</code>
rule correct and does the DNS record <code>status.YOUR_DOMAIN</code> point to the server? The Traefik
dashboard shows under &ldquo;HTTP Routers&rdquo; whether <code>kuma</code> is registered.</p>
<p><strong>The interface loads, but the live update stutters / breaks off.</strong> Kuma uses WebSockets.
Traefik forwards those correctly by default – if the problem still occurs, it&rsquo;s usually an
upstream CDN/proxy (e.g. Cloudflare in &ldquo;proxy&rdquo; mode) that blocks WebSockets. For direct
operation behind Traefik, no extra configuration is needed.</p>
<p><strong>After a re-setup all monitors are gone.</strong> The <code>kuma-data</code> volume was deleted (e.g. by
<code>docker compose down -v</code>). All configuration and history lives solely in this volume – that&rsquo;s
why it&rsquo;s at the top of the next section.</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<ul>
<li><strong>Back up:</strong> the complete heart of Kuma is the <code>kuma-data</code> volume (a SQLite database). Back
it up regularly – if it&rsquo;s gone, all monitors and the history are gone. We build the off-site
backup for it in the <a href="/en/tutorials/restic-backups/">Restic tutorial</a>.</li>
<li><strong>Updates:</strong> the tag <code>:2</code> stays on the 2.x series and brings bug fixes with
<code>docker compose pull &amp;&amp; docker compose up -d</code>. Before a jump to a new major version (e.g.
later <code>:3</code>), read the release notes and back up the volume first.</li>
<li><strong>Coming from 1.x?</strong> The switch to <code>:2</code> <strong>migrates the SQLite database automatically on the
first start</strong> – that can take a moment, and going back to <code>:1</code> is not intended afterwards.
So back up the <code>kuma-data</code> volume <strong>beforehand</strong>, then you&rsquo;re on the safe side. New
installations (like above) aren&rsquo;t affected.</li>
<li><strong>Honest limitation:</strong> a monitor that runs <strong>on the same server</strong> as the monitored services
can&rsquo;t warn you when the whole server fails – then Kuma is offline too. For that case, add an
<strong>external</strong> watcher. Two cheap ways: a <strong>second Uptime Kuma</strong> on a small server (or at
home) that only monitors this instance via HTTP – or a <strong>free external ping service</strong> 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&rsquo;t cover.</li>
</ul>
<p>With that you&rsquo;ve internalized the app pattern and monitor everything you hang behind Traefik
from now on. What every further app requires are
<a href="/en/tutorials/restic-backups/">encrypted off-site backups with Restic</a> – so your data
survives a server crash.</p>
]]></content:encoded></item><item><title>Self-hosting Nextcloud: your own cloud behind Traefik</title><link>https://serverkueche.de/en/tutorials/self-host-nextcloud/</link><pubDate>Sun, 19 Jul 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/self-host-nextcloud/</guid><description>Set up Nextcloud with Docker behind Traefik: your own cloud for files, calendar and contacts – with MariaDB, Redis and automatic HTTPS.</description><content:encoded><![CDATA[<p>Your files, your calendar, your contacts – but on your server instead of at a cloud
corporation. <strong>Nextcloud</strong> 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.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>By the end, <strong>Nextcloud 34</strong> runs behind your Traefik proxy, reachable at
<code>https://cloud.YOUR_DOMAIN</code> with a valid Let&rsquo;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 <strong>desktop sync client</strong> and the <strong>phone
apps</strong> with your server – the data lives exclusively with you.</p>
<p>We deliberately use the <strong>classic <code>nextcloud</code> image</strong> (not &ldquo;Nextcloud All-in-One&rdquo;) plus
<strong>MariaDB</strong> as the database and <strong>Redis</strong> for caching and file locking. This combination is
stable, well documented and fits exactly into the Traefik pattern from the Serverküche.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-sky-300 bg-sky-50 dark:border-sky-800 dark:bg-sky-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">ℹ️</span>Why not Nextcloud AIO?
  </p>
  <div class="prose-kitchen text-sm">Nextcloud also offers an &ldquo;All-in-One&rdquo; package (AIO). But it brings its <strong>own TLS and own
ports</strong> 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.</div>
</div>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>A running <strong>Traefik reverse proxy</strong> with the shared <code>proxy</code> network and the Let&rsquo;s Encrypt
resolver <code>le</code> – set up as in the tutorial
<a href="/en/tutorials/traefik-reverse-proxy/">reverse proxy with Traefik</a>.</li>
<li>A subdomain <code>cloud.YOUR_DOMAIN</code> whose DNS record (A/AAAA) points to your server IP – see
<a href="/en/tutorials/connect-domain-to-server/">connecting a domain to your server</a>.</li>
<li>A working <strong>backup</strong>. A cloud is the place where data loss hurts most – first set up
<a href="/en/tutorials/restic-backups/">backups with Restic</a>.</li>
</ul>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-herb-400 bg-herb-50 dark:border-herb-700 dark:bg-herb-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🧑‍🍳</span>How big does the server need to be?
  </p>
  <div class="prose-kitchen text-sm">Nextcloud is more frugal than its reputation: for private use or a small family (1–5 users),
<strong>2 vCPU and 4 GB RAM</strong> 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.</div>
</div>
<p>How big your server should be for your user count is estimated by the
<a href="/en/server-calculator/">server calculator</a> in a few clicks.</p>
<div class="not-prose my-6 overflow-hidden rounded-xl border border-paprika-200 bg-paprika-50 dark:border-paprika-800 dark:bg-paprika-900/20">
  <div class="flex items-center justify-between border-b border-paprika-200 bg-paprika-100 px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-paprika-700 dark:border-paprika-800 dark:bg-paprika-900/40 dark:text-paprika-300">
    <span>🍳 Recommendation</span>
    <span title="Links marked with * are affiliate links.">Ad</span>
  </div>
  <div class="flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between">
    <div>
      <p class="text-lg font-bold text-slate-900 dark:text-white">VPS 1000 G12</p>
      <p class="mt-1 text-sm text-slate-600 dark:text-slate-300">4 vCores · 8 GB RAM · 256 GB NVMe</p>
      <p class="mt-1 text-sm font-semibold text-paprika-700 dark:text-paprika-400">from €10.36/month</p>
      <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">For a private Nextcloud with a few users, the VPS 1000 is quite enough.</p>
    </div>
    <a href="https://www.netcup.com/en/server/vps/vps-1000-g12-iv-12m?ref=44083" rel="sponsored noopener" target="_blank"
   class="inline-flex shrink-0 items-center justify-center rounded-lg bg-paprika-600 px-5 py-2.5 font-semibold text-white transition-colors hover:bg-paprika-700">
  Go to netcup →
</a>

  </div><div class="px-4 pb-4"><p class="not-prose my-3 flex flex-wrap items-center gap-x-2 gap-y-1 rounded-lg border border-herb-500/40 bg-herb-50 px-3 py-2 text-sm text-slate-700 dark:bg-herb-900/20 dark:text-slate-200">
  <span>💶 <strong>5 € voucher</strong> for new netcup customers:</span>
  <code class="rounded bg-white px-2 py-0.5 font-mono text-sm font-semibold text-herb-800 dark:bg-slate-800 dark:text-herb-400">36nc17844976032</code>
  <span class="text-xs text-slate-500 dark:text-slate-400">(new customers only, no domains)</span>
</p></div>
</div>

<h2 id="step-by-step">Step by step</h2>
<h3 id="step-1-create-the-dns-record">Step 1: Create the DNS record</h3>
<p>At your DNS provider, create an entry <code>cloud.YOUR_DOMAIN</code> that points to your server IP
(A record for IPv4, AAAA for IPv6). Check that it resolves:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">dig +short cloud.YOUR_DOMAIN</span></span></code></pre></div>
</div>
<p>You should see your server IP. Only once the record is set can Traefik fetch the HTTPS
certificate later.</p>
<h3 id="step-2-determine-the-proxy-subnet">Step 2: Determine the proxy subnet</h3>
<p>Nextcloud sits behind Traefik. So that Nextcloud&rsquo;s brute-force protection recognizes the
<strong>real</strong> visitor IP (and doesn&rsquo;t ban the internal Traefik IP), we have to enter Traefik as a
&ldquo;trusted proxy&rdquo;. For that you need the subnet of your <code>proxy</code> network:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker network inspect proxy -f <span class="s1">&#39;{{(index .IPAM.Config 0).Subnet}}&#39;</span></span></span></code></pre></div>
</div>
<p>You get something like <code>172.18.0.0/16</code>. <strong>Remember this value</strong> – it goes into the
configuration as <code>TRUSTED_PROXIES</code> shortly.</p>
<h3 id="step-3-create-the-composeyaml">Step 3: Create the compose.yaml</h3>
<p>Create a folder and change into it:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mkdir -p ~/nextcloud <span class="o">&amp;&amp;</span> <span class="nb">cd</span> ~/nextcloud</span></span></code></pre></div>
</div>
<p>Create the <code>compose.yaml</code>. Replace <code>cloud.YOUR_DOMAIN</code>, all passwords and the
<code>TRUSTED_PROXIES</code> subnet from step 2:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">nc-db</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">mariadb:11.4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">nc-db</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span>--<span class="l">transaction-isolation=READ-COMMITTED --log-bin=binlog --binlog-format=ROW</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">MARIADB_ROOT_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">A_STRONG_ROOT_PASSWORD</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">MARIADB_DATABASE</span><span class="p">:</span><span class="w"> </span><span class="l">nextcloud</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">MARIADB_USER</span><span class="p">:</span><span class="w"> </span><span class="l">nextcloud</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">MARIADB_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">A_STRONG_DB_PASSWORD</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">nc_db:/var/lib/mysql</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;healthcheck.sh&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;--connect&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;--innodb_initialized&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">10s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">timeout</span><span class="p">:</span><span class="w"> </span><span class="l">5s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">6</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">default]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">nc-redis</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">redis:8-alpine</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">nc-redis</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l">redis-server --requirepass A_STRONG_REDIS_PASSWORD</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">default]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">nc-app</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">nextcloud:34-apache</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">nc-app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">nc-db</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_healthy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">nc-redis</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_started</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">MYSQL_HOST</span><span class="p">:</span><span class="w"> </span><span class="l">nc-db</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">MYSQL_DATABASE</span><span class="p">:</span><span class="w"> </span><span class="l">nextcloud</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">MYSQL_USER</span><span class="p">:</span><span class="w"> </span><span class="l">nextcloud</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">MYSQL_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">A_STRONG_DB_PASSWORD</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">REDIS_HOST</span><span class="p">:</span><span class="w"> </span><span class="l">nc-redis</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">REDIS_HOST_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">A_STRONG_REDIS_PASSWORD</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">NEXTCLOUD_TRUSTED_DOMAINS</span><span class="p">:</span><span class="w"> </span><span class="l">cloud.YOUR_DOMAIN</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">OVERWRITEPROTOCOL</span><span class="p">:</span><span class="w"> </span><span class="l">https</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">OVERWRITECLIURL</span><span class="p">:</span><span class="w"> </span><span class="l">https://cloud.YOUR_DOMAIN</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">TRUSTED_PROXIES</span><span class="p">:</span><span class="w"> </span><span class="m">172.18.0.0</span><span class="l">/16</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PHP_MEMORY_LIMIT</span><span class="p">:</span><span class="w"> </span><span class="l">1024M</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PHP_UPLOAD_LIMIT</span><span class="p">:</span><span class="w"> </span><span class="l">10G</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">nc_html:/var/www/html</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">labels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.enable=true&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.nc.rule=Host(`cloud.YOUR_DOMAIN`)&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.nc.entrypoints=websecure&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.nc.tls.certresolver=le&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.nc.middlewares=nc-dav&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.services.nc.loadbalancer.server.port=80&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># .well-known redirect for CalDAV/CardDAV (see below)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.middlewares.nc-dav.redirectregex.regex=https://(.*)/.well-known/(?:card|cal)dav&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.middlewares.nc-dav.redirectregex.replacement=https://cloud.YOUR_DOMAIN/remote.php/dav/&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.middlewares.nc-dav.redirectregex.permanent=true&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">default, proxy]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">nc_html</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">nc_db</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">default</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">proxy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">external</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span></span></span></code></pre></div>
</div>
<p>What matters here:</p>
<ul>
<li><strong>Three containers:</strong> <code>nc-app</code> (Nextcloud), <code>nc-db</code> (MariaDB), <code>nc-redis</code>. Only <code>nc-app</code> is
on the <strong><code>proxy</code> network</strong> (for Traefik) <em>and</em> on the internal <code>default</code> network. Database
and Redis stay exclusively internal – they have <strong>no <code>ports:</code></strong> and are not reachable from
outside.</li>
<li><strong>Two named volumes:</strong> <code>nc_html</code> (program code, <code>config.php</code>, user data) and <code>nc_db</code> (the
MariaDB data). The <code>nc_db</code> volume is mandatory: without the entry, the database lands in an
<strong>anonymous</strong> volume, and a <code>docker compose down</code> followed by <code>up</code> would <strong>lose it
irrecoverably</strong>.</li>
<li><strong><code>healthcheck</code> + <code>depends_on: service_healthy</code>:</strong> Nextcloud starts faster than the
database initializes. Without this healthcheck the initial install fails with a &ldquo;Connection
refused&rdquo;. This way the app container waits until MariaDB is really ready.</li>
<li><strong><code>NEXTCLOUD_TRUSTED_DOMAINS</code>:</strong> without your own domain here, Nextcloud greets you with
&ldquo;Access through untrusted domain&rdquo;.</li>
<li><strong><code>OVERWRITEPROTOCOL: https</code></strong> tells Nextcloud it runs behind HTTPS – otherwise it builds
internal links as <code>http://</code> and logins/redirects break.</li>
<li><strong><code>TRUSTED_PROXIES</code>:</strong> the subnet from step 2. This lets Nextcloud recognize the real client
IP; otherwise the brute-force protection bans the Traefik IP.</li>
<li><strong><code>PHP_MEMORY_LIMIT</code> / <code>PHP_UPLOAD_LIMIT</code>:</strong> the defaults (512 MB) are too small for large
uploads. Set generously here.</li>
<li><strong><code>loadbalancer.server.port=80</code>:</strong> the Apache image listens on port 80 in the container.</li>
<li><strong>The <code>nc-dav</code> middleware</strong> solves a classic Nextcloud problem – more on that shortly.</li>
</ul>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-amber-400 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">⚠️</span>CalDAV/CardDAV: the .well-known redirect
  </p>
  <div class="prose-kitchen text-sm">Nextcloud wants to send calendar/contact clients via <code>/.well-known/caldav</code> and
<code>/.well-known/carddav</code> to <code>/remote.php/dav/</code>. Behind a reverse proxy, Nextcloud&rsquo;s own
redirect doesn&rsquo;t work reliably – this later leads to a warning in the security check and to
problems with calendar setup. The <code>nc-dav</code> middleware above does the redirect directly in
Traefik. Important: the pattern must match the <strong>full <code>https://…</code> URL</strong> – <code>redirectregex</code>
always checks the complete URL, not just the path. A pure path pattern like <code>^/.well-known/…</code>
therefore never matches.</div>
</div>
<h3 id="step-4-start-and-run-through-the-initial-setup">Step 4: Start and run through the initial setup</h3>
<p>Start the stack:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose up -d</span></span></code></pre></div>
</div>
<p>On the first start, MariaDB initializes the database and Nextcloud unpacks itself – that
takes a minute or two. Follow it in the log:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose logs -f nc-app</span></span></code></pre></div>
</div>
<p>On the first run the image unpacks Nextcloud into the volume; you see lines like
<code>Initializing nextcloud 34.0.1.2 ...</code> and finally <code>Initializing finished</code>, followed by the
Apache start. If <code>restarting</code> appears repeatedly instead, take a look at the &ldquo;When things go
wrong&rdquo; section. With <code>Ctrl+C</code> you leave the log view again (the container keeps running).</p>
<p>Then open <code>https://cloud.YOUR_DOMAIN</code>. 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
<strong>administration account</strong>:</p>
<p><figure class="my-6"><img src="/en/tutorials/self-host-nextcloud/nextcloud-setup_hu_c50c35957723d189.webp" srcset="/en/tutorials/self-host-nextcloud/nextcloud-setup_hu_41179e40d0ac8fe6.webp 480w, /en/tutorials/self-host-nextcloud/nextcloud-setup_hu_c50c35957723d189.webp 768w, /en/tutorials/self-host-nextcloud/nextcloud-setup_hu_64a4383ace5c261b.webp 1200w, /en/tutorials/self-host-nextcloud/nextcloud-setup_hu_ab4c44f37cb05b2f.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/self-host-nextcloud/nextcloud-setup_hu_a6187c5c382c3ab8.webp"
    alt="The Nextcloud initial setup detects the autoconfig and only asks for the name and password of the administration account" title="Initial setup: the database is preconfigured, you only create the admin account"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Initial setup: the database is preconfigured, you only create the admin account</figcaption></figure></p>
<p>Set an admin name and a strong password and click <strong>Install</strong>. Nextcloud sets up the instance
– after that you land on the login page:</p>
<p><figure class="my-6"><img src="/en/tutorials/self-host-nextcloud/nextcloud-login_hu_ab5d9fc01a9c0266.webp" srcset="/en/tutorials/self-host-nextcloud/nextcloud-login_hu_5703a02035c4a1d2.webp 480w, /en/tutorials/self-host-nextcloud/nextcloud-login_hu_ab5d9fc01a9c0266.webp 768w, /en/tutorials/self-host-nextcloud/nextcloud-login_hu_1328b37d05a830c5.webp 1200w, /en/tutorials/self-host-nextcloud/nextcloud-login_hu_82532d9c9d89c6e1.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/self-host-nextcloud/nextcloud-login_hu_ff7e27fbd50c96bb.webp"
    alt="The Nextcloud login page under your own HTTPS domain" title="The login page at cloud.YOUR_DOMAIN – with valid HTTPS from Traefik"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The login page at cloud.YOUR_DOMAIN – with valid HTTPS from Traefik</figcaption></figure></p>
<p>After the login, the <strong>dashboard</strong> greets you, and under <strong>Files</strong> you find your cloud with a
few example files:</p>
<p><figure class="my-6"><img src="/en/tutorials/self-host-nextcloud/nextcloud-dashboard_hu_db3401a59a72d3bd.webp" srcset="/en/tutorials/self-host-nextcloud/nextcloud-dashboard_hu_c4f9d6d8f49e635d.webp 480w, /en/tutorials/self-host-nextcloud/nextcloud-dashboard_hu_db3401a59a72d3bd.webp 768w, /en/tutorials/self-host-nextcloud/nextcloud-dashboard_hu_9086315a95ddf210.webp 1200w, /en/tutorials/self-host-nextcloud/nextcloud-dashboard_hu_860e5d90f28daa69.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/self-host-nextcloud/nextcloud-dashboard_hu_f4c7dc282828c0f.webp"
    alt="The Nextcloud dashboard with the &ldquo;Recommended files&rdquo; widget" title="The dashboard after the first login"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The dashboard after the first login</figcaption></figure></p>
<p><figure class="my-6"><img src="/en/tutorials/self-host-nextcloud/nextcloud-dateien_hu_c6651a5b12cc3b8d.webp" srcset="/en/tutorials/self-host-nextcloud/nextcloud-dateien_hu_c2ff2f12d453b9c0.webp 480w, /en/tutorials/self-host-nextcloud/nextcloud-dateien_hu_c6651a5b12cc3b8d.webp 768w, /en/tutorials/self-host-nextcloud/nextcloud-dateien_hu_b5d67ad5d77fc9b.webp 1200w, /en/tutorials/self-host-nextcloud/nextcloud-dateien_hu_a5c245e619d92d9f.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/self-host-nextcloud/nextcloud-dateien_hu_f58abd3991247124.webp"
    alt="The Files interface of Nextcloud with folders and example files" title="The Files app – the heart of your own cloud"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The Files app – the heart of your own cloud</figcaption></figure></p>
<h3 id="step-5-set-up-background-jobs-cron">Step 5: Set up background jobs (cron)</h3>
<p>Nextcloud has to perform tasks regularly (cleanup, notifications, thumbnails). By default
this happens via &ldquo;AJAX&rdquo; on every page load – which is unreliable. The recommended way is a
<strong>cron sidecar</strong>: a second container with the same image that only runs <code>cron.php</code>. Add a
service to the <code>compose.yaml</code>:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">  </span><span class="nt">nc-cron</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">nextcloud:34-apache</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">nc-cron</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">entrypoint</span><span class="p">:</span><span class="w"> </span><span class="l">/cron.sh</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">nc-db</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_healthy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">nc_html:/var/www/html</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">default]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span></span></span></code></pre></div>
</div>
<p>The sidecar shares the <code>nc_html</code> volume with the app and runs the jobs every five minutes.
Apply the change and set the mode in Nextcloud to &ldquo;Cron&rdquo;:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose up -d
</span></span><span class="line"><span class="cl">docker <span class="nb">exec</span> -u www-data nc-app php occ background:cron</span></span></code></pre></div>
</div>
<p>Under <strong>Administration → Basic settings</strong>, &ldquo;Cron&rdquo; should now be active.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-herb-400 bg-herb-50 dark:border-herb-700 dark:bg-herb-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🧑‍🍳</span>occ – the command-line tool
  </p>
  <div class="prose-kitchen text-sm"><p><code>occ</code> is Nextcloud&rsquo;s admin tool. You always call it as the user <code>www-data</code> in the app
container: <code>docker exec -u www-data nc-app php occ &lt;command&gt;</code>. Two useful cleanup commands
right after installation:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker <span class="nb">exec</span> -u www-data nc-app php occ db:add-missing-indices
</span></span><span class="line"><span class="cl">docker <span class="nb">exec</span> -u www-data nc-app php occ config:system:set default_phone_region --value<span class="o">=</span>DE</span></span></code></pre></div>
</div></div>
</div>
<h3 id="step-6-connect-the-clients">Step 6: Connect the clients</h3>
<p>Now the real benefit. There are three ways to use your cloud:</p>
<ol>
<li><strong>Desktop sync client</strong> (Windows/macOS/Linux): install the &ldquo;Nextcloud Desktop&rdquo; client,
enter <code>https://cloud.YOUR_DOMAIN</code> as the server address, log in and choose a local folder.
Files are synced like with Dropbox. With <strong>virtual files</strong> (Windows/macOS) they only take
up space on the disk when you open them – handy when your cloud is larger than the local
disk.</li>
<li><strong>Phone app</strong> (Android/iOS): the official Nextcloud app, same server address. Ideal for
automatic <strong>photo upload</strong> from the smartphone.</li>
<li><strong>Calendar &amp; contacts:</strong> in your device&rsquo;s system settings, create a CalDAV/CardDAV account
with the address <code>https://cloud.YOUR_DOMAIN</code> – thanks to the <code>.well-known</code> middleware from
step 3, automatic detection works.</li>
</ol>
<p>Features like calendar, contacts or mail are separate <strong>apps</strong> you install with one click
under <strong>Administration → Apps</strong> – so Nextcloud is extensible without you touching anything on
the server.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-amber-400 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">⚠️</span>Don&#39;t work with the admin account
  </p>
  <div class="prose-kitchen text-sm">The administration account from step 4 is meant for <strong>management</strong>, not for everyday use.
Create a normal user account under <strong>Administration → Accounts</strong> and sync your files through
it. That way your powerful admin account isn&rsquo;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.</div>
</div>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong>&ldquo;Access through untrusted domain&rdquo; instead of the login page.</strong> Your domain isn&rsquo;t in
<code>trusted_domains</code>. Check <code>NEXTCLOUD_TRUSTED_DOMAINS</code> in the Compose file. To set it
afterwards, use occ: <code>docker exec -u www-data nc-app php occ config:system:set trusted_domains 1 --value=cloud.YOUR_DOMAIN</code>.</p>
<p><strong>The security check reports &ldquo;Your web server is not set up properly to resolve
.well-known/caldav&rdquo;.</strong> The CalDAV/CardDAV redirect isn&rsquo;t working. Check the <code>nc-dav</code>
middleware labels (step 3) and that the router includes them via
<code>...routers.nc.middlewares=nc-dav</code>. The regex must match the full <code>https://…</code> URL –
<code>redirectregex</code> checks the complete URL, not just the path.</p>
<p><strong>Warning &ldquo;The &lsquo;Strict-Transport-Security&rsquo; HTTP header is not set&rdquo;.</strong> The HSTS header is
missing. Set it as a Traefik middleware and attach it to the router:
<code>traefik.http.middlewares.nc-secure.headers.stsSeconds=15552000</code>. Attach it additionally to
the <code>nc-dav</code> middleware on the router (<code>...routers.nc.middlewares=nc-dav,nc-secure</code>). The
header must come from the proxy, not from Nextcloud. In <a href="/en/tutorials/harden-optimize-nextcloud/">part
2</a> we set up exactly this <code>nc-secure</code> middleware
fully (including <code>includeSubdomains</code>).</p>
<p><strong>Large uploads break off or end with a timeout / &ldquo;413&rdquo;.</strong> The PHP limit is too small.
Increase <code>PHP_UPLOAD_LIMIT</code> and <code>PHP_MEMORY_LIMIT</code> (step 3) and restart the container. In
rare cases these variables don&rsquo;t take effect – then mount your own <code>php.ini</code> snippet into the
image.</p>
<p><strong>On the first start the installation aborts with &ldquo;MySQL server has gone away&rdquo; or &ldquo;Connection
refused&rdquo;.</strong> The app container was faster than the database. That&rsquo;s exactly what the
<code>healthcheck</code> with <code>depends_on: condition: service_healthy</code> is for – check that both are
present in your Compose file, and restart with <code>docker compose up -d</code>.</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<ul>
<li>
<p><strong>Backups are mandatory for a cloud.</strong> A consistent backup needs <strong>three things</strong>: the
<strong>database</strong> (MariaDB dump), the <strong>data directory</strong> (your files) and the <strong>configuration</strong>
(<code>config.php</code>). Put Nextcloud into maintenance mode briefly before the backup so the
database and files match each other:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker <span class="nb">exec</span> -u www-data nc-app php occ maintenance:mode --on
</span></span><span class="line"><span class="cl">docker <span class="nb">exec</span> nc-db mariadb-dump -u root -pA_STRONG_ROOT_PASSWORD nextcloud &gt; nextcloud-db.sql
</span></span><span class="line"><span class="cl">docker <span class="nb">exec</span> -u www-data nc-app php occ maintenance:mode --off</span></span></code></pre></div>
</div>
<p>Back up the dump together with the <code>nc_html</code> volume <strong>encrypted and off-site</strong> with
<a href="/en/tutorials/restic-backups/">Restic</a> – thanks to the dump you don&rsquo;t need to additionally
back up the raw <code>nc_db</code> volume. The <code>nc_html</code> volume contains both the program code and the
<code>config.php</code> as well as – under <code>data/</code> – <strong>your actual user files</strong>. If your cloud grows a
lot, you can later move this <code>data/</code> directory to a separate, larger volume; for now one
volume for everything is enough.</p>
</li>
<li>
<p><strong>Updates: only one major version per step.</strong> From 34 to 35, never directly to 36. Bump the
image tag (<code>nextcloud:35-apache</code>), then <code>docker compose pull</code> and <code>docker compose up -d</code>.
The container runs the necessary <code>occ upgrade</code> <strong>automatically</strong> on start. <strong>Always make a
backup first.</strong> Bump the cron sidecar to the same version.</p>
</li>
<li>
<p><strong>Check security.</strong> After setup, run the official
<a href="https://scan.nextcloud.com/">Nextcloud Security Scan</a> against your domain (target: grade A)
and work through the warnings from the admin overview. The overview under <strong>Administration →
Overview</strong> runs automatic checks and shows exactly what&rsquo;s still missing. Keep Nextcloud up
to date – security holes are closed promptly, but only if you update.</p>
</li>
<li>
<p><strong>Honest about the effort:</strong> 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.</p>
</li>
</ul>
]]></content:encoded></item><item><title>Vaultwarden: your own password manager behind Traefik</title><link>https://serverkueche.de/en/tutorials/vaultwarden-password-manager/</link><pubDate>Sun, 19 Jul 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/vaultwarden-password-manager/</guid><description>Self-host Vaultwarden: a lean, Bitwarden-compatible password manager behind Traefik with HTTPS, an admin panel and an encrypted backup.</description><content:encoded><![CDATA[<p>Passwords belong in a password manager – but do they have to live in the cloud of some
third-party provider? With <strong>Vaultwarden</strong> 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 <strong>your</strong> server.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>By the end, <strong>Vaultwarden 1.36</strong> runs in a container behind your Traefik proxy, reachable
at <code>https://vault.YOUR_DOMAIN</code> 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 <strong>Bitwarden clients</strong> (browser
extension, phone app, desktop) with your server, store your credentials – end-to-end
encrypted – and manage the service via a secured <strong>admin panel</strong>.</p>
<p>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.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-sky-300 bg-sky-50 dark:border-sky-800 dark:bg-sky-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">ℹ️</span>Vaultwarden ≠ Bitwarden
  </p>
  <div class="prose-kitchen text-sm">Vaultwarden is an <strong>unofficial</strong>, community-driven project that reimplements the Bitwarden
server API. It&rsquo;s not affiliated with Bitwarden Inc. For private and small-team use it&rsquo;s
excellent; the clients (apps, extensions) are the real, official ones from Bitwarden.</div>
</div>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>A running <strong>Traefik reverse proxy</strong> with the shared <code>proxy</code> network and the Let&rsquo;s
Encrypt resolver <code>le</code> – exactly the setup from the tutorial
<a href="/en/tutorials/traefik-reverse-proxy/">reverse proxy with Traefik</a>. Without Traefik this
recipe doesn&rsquo;t work: Vaultwarden <strong>requires HTTPS</strong>.</li>
<li>A subdomain, e.g. <code>vault.YOUR_DOMAIN</code>, whose DNS record (A/AAAA) points to your server
IP – see <a href="/en/tutorials/connect-domain-to-server/">connecting a domain to your server</a>.</li>
<li>A working <strong>backup</strong> of your server. A password manager is the place where data loss
hurts most – if you haven&rsquo;t yet, first set up
<a href="/en/tutorials/restic-backups/">backups with Restic</a>.</li>
</ul>
<p>If you want to combine several services later, the <a href="/en/server-calculator/">server calculator</a>
helps with the right server size.</p>
<div class="not-prose my-6 overflow-hidden rounded-xl border border-paprika-200 bg-paprika-50 dark:border-paprika-800 dark:bg-paprika-900/20">
  <div class="flex items-center justify-between border-b border-paprika-200 bg-paprika-100 px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-paprika-700 dark:border-paprika-800 dark:bg-paprika-900/40 dark:text-paprika-300">
    <span>🍳 Recommendation</span>
    <span title="Links marked with * are affiliate links.">Ad</span>
  </div>
  <div class="flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between">
    <div>
      <p class="text-lg font-bold text-slate-900 dark:text-white">VPS 1000 G12</p>
      <p class="mt-1 text-sm text-slate-600 dark:text-slate-300">4 vCores · 8 GB RAM · 256 GB NVMe</p>
      <p class="mt-1 text-sm font-semibold text-paprika-700 dark:text-paprika-400">from €10.36/month</p>
      <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">Vaultwarden is so frugal that even the smallest VPS is easily enough.</p>
    </div>
    <a href="https://www.netcup.com/en/server/vps/vps-1000-g12-iv-12m?ref=44083" rel="sponsored noopener" target="_blank"
   class="inline-flex shrink-0 items-center justify-center rounded-lg bg-paprika-600 px-5 py-2.5 font-semibold text-white transition-colors hover:bg-paprika-700">
  Go to netcup →
</a>

  </div><div class="px-4 pb-4"><p class="not-prose my-3 flex flex-wrap items-center gap-x-2 gap-y-1 rounded-lg border border-herb-500/40 bg-herb-50 px-3 py-2 text-sm text-slate-700 dark:bg-herb-900/20 dark:text-slate-200">
  <span>💶 <strong>5 € voucher</strong> for new netcup customers:</span>
  <code class="rounded bg-white px-2 py-0.5 font-mono text-sm font-semibold text-herb-800 dark:bg-slate-800 dark:text-herb-400">36nc17844976032</code>
  <span class="text-xs text-slate-500 dark:text-slate-400">(new customers only, no domains)</span>
</p></div>
</div>

<h2 id="step-by-step">Step by step</h2>
<h3 id="step-1-generate-the-admin_token">Step 1: Generate the ADMIN_TOKEN</h3>
<p>Vaultwarden has an admin panel at <code>/admin</code>. Access to it is protected by an <code>ADMIN_TOKEN</code>.
You should <strong>not</strong> store it in plaintext, but as a hash – then your panel password appears
nowhere readable in the Compose file.</p>
<p>Vaultwarden brings its own command for this. We run it briefly in a throwaway container:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker run --rm -it vaultwarden/server:1.36.0 /vaultwarden <span class="nb">hash</span> --preset owasp</span></span></code></pre></div>
</div>
<p>The command asks you twice for a password (input stays invisible) and then outputs an
<strong>Argon2id hash</strong> – a long string that starts like this:</p>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Generate an Argon2id PHC string using the &#39;owasp&#39; preset.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Password:
</span></span><span class="line"><span class="cl">Confirm Password:
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">ADMIN_TOKEN=&#39;$argon2id$v=19$m=19456,t=2,p=1$FkxFEQ64Wy4zlQOWMI1fJ...$TxULe6MSND3By6GPVPKB1...&#39;</span></span></code></pre></div>
</div>
<p>Copy out the complete string between the quotes (including the <code>$argon2id$…</code> parts) – you
need it in a moment. The password you entered here is your <strong>admin panel password</strong>; store
it in your password manager (in the old one, for now).</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-herb-400 bg-herb-50 dark:border-herb-700 dark:bg-herb-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🧑‍🍳</span>Why the detour via the hash?
  </p>
  <div class="prose-kitchen text-sm">You could also set the <code>ADMIN_TOKEN</code> as a plaintext password. The hash is safer, though:
even someone who gets hold of your <code>compose.yaml</code> can&rsquo;t compute your panel password back
from it. <code>--preset owasp</code> chooses Argon2 parameters per the current OWASP recommendation.</div>
</div>
<h3 id="step-2-create-the-composeyaml">Step 2: Create the compose.yaml</h3>
<p>Create a dedicated folder and change into it:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mkdir -p ~/vaultwarden <span class="o">&amp;&amp;</span> <span class="nb">cd</span> ~/vaultwarden</span></span></code></pre></div>
</div>
<p>Create the <code>compose.yaml</code>. Replace <code>vault.YOUR_DOMAIN</code> with your real subdomain and the
<code>ADMIN_TOKEN</code> with the hash from step 1:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">vaultwarden</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">vaultwarden/server:1.36.0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./vw-data:/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">DOMAIN</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;https://vault.YOUR_DOMAIN&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">SIGNUPS_ALLOWED</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;true&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">ADMIN_TOKEN</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;$$argon2id$$v=19$$m=19456,t=2,p=1$$FkxFE...$$TxULe...&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">labels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.enable=true&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.vaultwarden.rule=Host(`vault.YOUR_DOMAIN`)&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.vaultwarden.entrypoints=websecure&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.vaultwarden.tls.certresolver=le&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.services.vaultwarden.loadbalancer.server.port=80&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">proxy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">proxy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">external</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span></span></span></code></pre></div>
</div>
<p>The most important lines in detail:</p>
<ul>
<li><strong><code>image: vaultwarden/server:1.36.0</code></strong> – we deliberately pin the version instead of
taking <code>latest</code>. That way you update in a controlled manner (see &ldquo;Maintenance&rdquo;). For an
even smaller image there&rsquo;s also <code>:1.36.0-alpine</code>.</li>
<li><strong><code>volumes: ./vw-data:/data</code></strong> – this is where your database (<code>db.sqlite3</code>), the
encryption keys and the attachments live. <strong>This volume is mandatory.</strong> Without a volume
Vaultwarden deliberately refuses to start, so you don&rsquo;t lose your data in an ephemeral
container.</li>
<li><strong><code>DOMAIN</code></strong> – the full HTTPS URL. Vaultwarden needs it for WebAuthn/2FA and invitation
links, among other things. Must match your router rule exactly.</li>
<li><strong><code>SIGNUPS_ALLOWED: &quot;true&quot;</code></strong> – allows registration for now so you can create your first
account. We turn that off again shortly.</li>
<li><strong><code>ADMIN_TOKEN</code></strong> – your hash from step 1. <strong>Important:</strong> in a Compose file every dollar
sign must be <strong>doubled</strong> (<code>$</code> → <code>$$</code>), otherwise Compose tries to interpret it as a
variable. So <code>$argon2id$…</code> becomes <code>$$argon2id$$…</code>.</li>
<li><strong><code>loadbalancer.server.port=80</code></strong> – Vaultwarden listens on <strong>port 80</strong> 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.</li>
<li><strong>No <code>ports:</code></strong> – as with every app behind Traefik, Vaultwarden is only reachable via the
proxy, never directly from outside.</li>
</ul>
<h3 id="step-3-start-it-and-the-first-call">Step 3: Start it and the first call</h3>
<p>Start the container:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose up -d</span></span></code></pre></div>
</div>
<p>On the first start, Vaultwarden creates the data directory and initializes the database.
Take a look at the log:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose logs -f vaultwarden</span></span></code></pre></div>
</div>
<p>At the end you should see this line – it confirms the service is running:</p>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">[INFO] Rocket has launched from http://0.0.0.0:80</span></span></code></pre></div>
</div>
<p>With <code>Ctrl+C</code> you leave the log view again (the container keeps running). Now open
<code>https://vault.YOUR_DOMAIN</code> in the browser. On the first access Traefik fetches the Let&rsquo;s
Encrypt certificate – that can take a few seconds. After that the Bitwarden web interface
appears.</p>
<p>Click <strong>Create account</strong> and create your first account – with your email address and a
name:</p>
<p><figure class="my-6"><img src="/en/tutorials/vaultwarden-password-manager/vaultwarden-konto-erstellen_hu_442c987fd222aaf3.webp" srcset="/en/tutorials/vaultwarden-password-manager/vaultwarden-konto-erstellen_hu_171ec18b0766c4f0.webp 480w, /en/tutorials/vaultwarden-password-manager/vaultwarden-konto-erstellen_hu_442c987fd222aaf3.webp 768w, /en/tutorials/vaultwarden-password-manager/vaultwarden-konto-erstellen_hu_fe725a264430f1c1.webp 1200w, /en/tutorials/vaultwarden-password-manager/vaultwarden-konto-erstellen_hu_19943411b9fb06d0.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/vaultwarden-password-manager/vaultwarden-konto-erstellen_hu_da55256e86e78bf2.webp"
    alt="The Vaultwarden web interface shows the form to create a new account with email address and name" title="Create account – Vaultwarden uses the official Bitwarden web interface"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Create account – Vaultwarden uses the official Bitwarden web interface</figcaption></figure></p>
<p>In the next step you set your <strong>master password</strong>. That&rsquo;s the one key that unlocks your
entire vault:</p>
<p><figure class="my-6"><img src="/en/tutorials/vaultwarden-password-manager/vaultwarden-master-passwort_hu_94b00d92f8745395.webp" srcset="/en/tutorials/vaultwarden-password-manager/vaultwarden-master-passwort_hu_817569daa8f2d151.webp 480w, /en/tutorials/vaultwarden-password-manager/vaultwarden-master-passwort_hu_94b00d92f8745395.webp 768w, /en/tutorials/vaultwarden-password-manager/vaultwarden-master-passwort_hu_eba5c53116ba25e8.webp 1200w, /en/tutorials/vaultwarden-password-manager/vaultwarden-master-passwort_hu_1fe5f1251d11ceab.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/vaultwarden-password-manager/vaultwarden-master-passwort_hu_5f16260444ccea7e.webp"
    alt="The Vaultwarden page for setting a strong master password with a password field and strength indicator" title="The master password encrypts your entire vault – choose it carefully"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The master password encrypts your entire vault – choose it carefully</figcaption></figure></p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-paprika-400 bg-paprika-50 dark:border-paprika-700 dark:bg-paprika-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🔥</span>The master password is not recoverable
  </p>
  <div class="prose-kitchen text-sm">Your master password is <strong>never</strong> transmitted to the server; it decrypts your vault
locally. If you forget it, <strong>all</strong> 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).</div>
</div>
<h3 id="step-4-close-registration-again">Step 4: Close registration again</h3>
<p>Once your account is set up, you want to <strong>prevent strangers from registering too</strong> – your
Vaultwarden is, after all, open on the internet. Set <code>SIGNUPS_ALLOWED</code> to <code>false</code> in the
<code>compose.yaml</code>:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">DOMAIN</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;https://vault.YOUR_DOMAIN&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">SIGNUPS_ALLOWED</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;false&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">ADMIN_TOKEN</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;$$argon2id$$...&#34;</span></span></span></code></pre></div>
</div>
<p>And apply the change:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose up -d</span></span></code></pre></div>
</div>
<p>Compose detects the changed environment variable and restarts the container. From now on
the login page rejects new registrations. Further users (e.g. for the family) you invite
specifically via the admin panel when needed – more on that shortly.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-amber-400 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">⚠️</span>Don&#39;t forget
  </p>
  <div class="prose-kitchen text-sm">An open <code>SIGNUPS_ALLOWED: &quot;true&quot;</code> 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.</div>
</div>
<h3 id="step-5-the-admin-panel">Step 5: The admin panel</h3>
<p>Open <code>https://vault.YOUR_DOMAIN/admin</code> and log in with the <strong>password</strong> you entered in
step 1 when generating the hash (not with the hash itself). You land in the management
interface:</p>
<p><figure class="my-6"><img src="/en/tutorials/vaultwarden-password-manager/vaultwarden-admin_hu_473707ac677392b3.webp" srcset="/en/tutorials/vaultwarden-password-manager/vaultwarden-admin_hu_9aeb46075faece5c.webp 480w, /en/tutorials/vaultwarden-password-manager/vaultwarden-admin_hu_473707ac677392b3.webp 768w, /en/tutorials/vaultwarden-password-manager/vaultwarden-admin_hu_5667882637e7d363.webp 1200w, /en/tutorials/vaultwarden-password-manager/vaultwarden-admin_hu_d5ec00afc73e4d04.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/vaultwarden-password-manager/vaultwarden-admin_hu_6e7aad8d3eec6a20.webp"
    alt="The Vaultwarden admin panel with the areas General, SMTP Email and Backup Database" title="The admin panel – here you manage users, SMTP and global settings"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The admin panel – here you manage users, SMTP and global settings</figcaption></figure></p>
<p>Here you control the service centrally:</p>
<ul>
<li><strong>Users</strong> – view existing users, <strong>invite</strong> new ones (even with <code>SIGNUPS_ALLOWED=false</code>),
deactivate accounts.</li>
<li><strong>Settings → SMTP Email Settings</strong> – enter your mail server&rsquo;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&rsquo;t.</li>
<li><strong>Diagnostics</strong> – shows you whether your <code>DOMAIN</code> is set correctly and whether Vaultwarden
is reachable from outside – handy for troubleshooting.</li>
</ul>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-herb-400 bg-herb-50 dark:border-herb-700 dark:bg-herb-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🧑‍🍳</span>Settings in the panel vs. environment variables
  </p>
  <div class="prose-kitchen text-sm">What you save in the admin panel is stored in <code>vw-data/config.json</code> and <strong>overrides</strong> the
environment variables from the <code>compose.yaml</code>. Decide per setting on one way to avoid
confusion. For the basic configuration (domain, token, signups) we deliberately stick with
the Compose file – it&rsquo;s versionable and reproducible.</div>
</div>
<h3 id="step-6-connect-the-clients">Step 6: Connect the clients</h3>
<p>Now comes the real benefit. Install the <strong>official Bitwarden app</strong> or browser extension
(from the respective app or add-on store). Before you log in, you switch the server:</p>
<ol>
<li>In the app/extension, <strong>before logging in</strong>, open the settings for the <strong>self-hosted
environment</strong> (gear icon or &ldquo;Region: Self-hosted&rdquo;).</li>
<li>Enter <code>https://vault.YOUR_DOMAIN</code> as the <strong>server URL</strong> and save.</li>
<li>Now log in with your email and your master password – the app talks to your server from
now on.</li>
</ol>
<p>The nice part: it&rsquo;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.</p>
<p><strong>Import existing passwords:</strong> if you&rsquo;re switching from another manager (or the browser&rsquo;s
password store), you don&rsquo;t have to retype everything. Export your entries there as CSV or
JSON and import them via the <strong>web interface</strong> under <code>Tools → Import data</code>. Vaultwarden
understands the export formats of the common managers (KeePass, LastPass, 1Password,
Chrome/Firefox, etc.) directly.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-amber-400 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">⚠️</span>Securely delete export files afterwards
  </p>
  <div class="prose-kitchen text-sm">An export CSV contains all passwords <strong>in plaintext</strong>. Delete the file immediately after a
successful import – and empty the trash. Never leave it lying in Downloads or a cloud
folder.</div>
</div>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-herb-400 bg-herb-50 dark:border-herb-700 dark:bg-herb-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🧑‍🍳</span>Enable two-factor authentication
  </p>
  <div class="prose-kitchen text-sm">In your vault&rsquo;s account settings, enable <strong>2FA</strong> (e.g. via an authenticator app). That way,
even with a cracked master password, your vault isn&rsquo;t immediately open. For email-based 2FA,
SMTP must be set up in the admin panel first.</div>
</div>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong>The container won&rsquo;t start, the log says something like <code>Running without a persistent volume is not recommended</code>.</strong> The <code>volumes:</code> mapping is missing. Vaultwarden deliberately
refuses to start without a data directory so your passwords don&rsquo;t end up in an ephemeral
container. Add <code>./vw-data:/data</code> as in step 2 and restart.</p>
<p><strong>The web interface shows &ldquo;You need to enable HTTPS!&rdquo; or the login fails with crypto
errors.</strong> Vaultwarden uses the browser&rsquo;s Web Crypto API, which is only available in a
<strong>secure context</strong> (real HTTPS). You opened the page over <code>http://</code> or with an invalid
certificate. Make sure Traefik fetched a valid Let&rsquo;s Encrypt certificate (check the Traefik
log) and that you reach the page over <code>https://</code>. The <code>DOMAIN</code> variable must also start with
<code>https://</code>.</p>
<p><strong>The admin panel rejects your password, even though it&rsquo;s correct.</strong> Probably the dollar
signs in the <code>ADMIN_TOKEN</code> aren&rsquo;t doubled. In the <code>compose.yaml</code> every <code>$</code> must become <code>$$</code>.
Check with <code>docker compose config</code> how the token actually arrives (Compose shows the
resolved value there). Also remember: at login you enter the <strong>password</strong>, not the hash.</p>
<p><strong>The phone app can&rsquo;t find the server or reports &ldquo;Server URL invalid&rdquo;.</strong> The server URL must
be the full <code>https://</code> address without a trailing path (<code>https://vault.YOUR_DOMAIN</code>). 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.</p>
<p><strong>Despite <code>SIGNUPS_ALLOWED=false</code>, someone was able to register.</strong> The setting was probably
set in the admin panel and overrides the environment variable, or the container wasn&rsquo;t
restarted after the change. Check the value under <strong>Settings → General settings</strong> in the
panel and restart with <code>docker compose up -d</code>.</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<ul>
<li>
<p><strong>Backups are non-negotiable for a password manager.</strong> Your entire vault is in the
<code>vw-data/</code> directory (SQLite database, keys, attachments). Back it up <strong>encrypted and
off-site</strong> with <a href="/en/tutorials/restic-backups/">Restic</a> – add the folder
<code>~/vaultwarden/vw-data</code> to your backup sources. For a consistent database state, briefly
run <code>docker compose stop</code> before the backup, or use the &ldquo;Backup Database&rdquo; function in the
admin panel. A mere file copy while the container is <strong>running</strong> can catch an inconsistent
state (SQLite writes into WAL files) – with a password manager that&rsquo;s not a risk worth
taking.</p>
</li>
<li>
<p><strong>Apply updates in a controlled way.</strong> Because we pinned the version, you update
deliberately: before the switch read the
<a href="https://github.com/dani-garcia/vaultwarden/releases">release notes</a>, then raise the tag
in the <code>compose.yaml</code> (e.g. to the next <code>1.x</code>) and pull anew:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose pull <span class="o">&amp;&amp;</span> docker compose up -d</span></span></code></pre></div>
</div>
<p>Afterwards check the <code>Rocket has launched</code> line in the log again and test a login.</p>
</li>
<li>
<p><strong>Keep registration closed.</strong> Occasionally check that <code>SIGNUPS_ALLOWED</code> is still <code>false</code> –
always invite new users specifically via the admin panel.</p>
</li>
<li>
<p><strong>Rotate the ADMIN_TOKEN</strong> if it might be compromised: generate a new hash with the <code>hash</code>
command from step 1, replace it in the <code>compose.yaml</code>, restart.</p>
</li>
<li>
<p><strong>Never lose the master password.</strong> 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.</p>
</li>
</ul>
]]></content:encoded></item></channel></rss>