<?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>Capabilities – Serverküche</title><link>https://serverkueche.de/en/tags/capabilities/</link><description>Capabilities – 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>Thu, 15 Oct 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://serverkueche.de/en/tags/capabilities/index.xml" rel="self" type="application/rss+xml"/><item><title>Hardening Docker Containers: cap_drop, read_only &amp; Least Privilege</title><link>https://serverkueche.de/en/tutorials/harden-docker-containers/</link><pubDate>Thu, 15 Oct 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/harden-docker-containers/</guid><description>Harden containers with Docker's own tools: drop capabilities, no-new-privileges, read_only with tmpfs and resource limits – every step verified for real.</description><content:encoded><![CDATA[<p>A container is not a virtual machine. By default <strong>root</strong> runs inside it, with fourteen
kernel capabilities and a writable filesystem – and hardly any application needs that.
This recipe takes everything away from a container piece by piece, checking after each
step whether it still works.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>A <strong>hardening pattern</strong> you can apply to any existing Compose stack: drop capabilities
entirely and hand back only the bare minimum, block privilege escalation with
<code>no-new-privileges</code>, mount the filesystem read-only, and cap memory and process count.</p>
<p>The practice object is an <strong>nginx 1.31-alpine</strong> behind your Traefik. By the end it runs
<strong>without a single capability</strong>, as an unprivileged user, with a read-only root
filesystem – and still serves perfectly normally. Tested on Debian 13 with <strong>Docker
29.8.2</strong> and <strong>Compose v5.6.0</strong>.</p>
<p>The benefit is concrete: when an application has a hole, hardening decides whether an
attacker merely stands around inside the container or starts changing file ownership,
loading binaries and attacking the host.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>The <a href="/en/tutorials/docker-compose-basics/">Compose basics</a> – we work in <code>compose.yaml</code>
throughout.</li>
<li>A <a href="/en/tutorials/harden-ssh/">hardened server</a> with Docker; for the demo part a running
<a href="/en/tutorials/traefik-reverse-proxy/">Traefik reverse proxy</a> with the <code>proxy</code> network
and the <code>le</code> resolver.</li>
<li>A basic grasp of <a href="/en/tutorials/linux-users-and-permissions/">users and permissions on Linux</a> –
UIDs and GIDs come up constantly here.</li>
</ul>
<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>Hardening does not replace updates
  </p>
  <div class="prose-kitchen text-sm">A hardened container running outdated software is still vulnerable – hardening only
limits the damage. It is the <strong>second</strong> line of defence behind a working
<a href="/en/tutorials/keep-docker-stack-updated/">update process</a>, not a substitute for it.</div>
</div>
<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"
     data-track-content data-content-name="Affiliate-Box · /en/tutorials/harden-docker-containers/" data-content-piece="VPS 1000 G12.5">
  <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.5</p>
      <p class="mt-1 text-sm text-slate-600 dark:text-slate-300">4 vCores · 8 GB RAM · 128 GB SSD</p>
      <p class="mt-1 text-sm font-semibold text-paprika-700 dark:text-paprika-400">from €14.50/month</p>
      <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">Hardening costs no resources – this runs on the smallest VPS.</p>
    </div>
    <a href="https://www.netcup.com/en/server/vps/vps-1000-g12.5-iv-24m-eu?ref=44083#vps-1000-g12.5-iv-12m-eu" rel="sponsored noopener" target="_blank"
   data-track-event="Affiliate|netcup: Affiliate-Box|VPS 1000 G12.5 · {page}"
   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"><div class="not-prose my-3 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"
     data-voucher-api="vps-1000" data-voucher-msg-empty="No code is available for this plan right now – use the €5.95 code." data-voucher-msg-limit="No more codes today – use the €5.95 code or try again tomorrow." data-voucher-msg-error="The code couldn&#39;t be fetched right now – use the €5.95 code."
     data-voucher-hint="Empty the cart first – the code adds the server itself.">
  <p class="flex flex-wrap items-center gap-x-2 gap-y-1"><span>💶 <span class="font-semibold">1 month free</span>:</span>
    <button type="button" data-voucher-reveal data-copy-title="Click to copy"
            class="cursor-pointer rounded bg-white px-2 py-0.5 font-mono text-sm font-semibold text-herb-800 hover:ring-2 hover:ring-herb-500/40 dark:bg-slate-800 dark:text-herb-400">Show code</button>
    <button type="button" data-voucher-next hidden
            class="rounded border border-herb-500/40 px-2 py-0.5 text-xs text-herb-800 hover:bg-white dark:text-herb-400 dark:hover:bg-slate-800">another code</button>
  </p><p class="mt-1 text-xs text-slate-500 dark:text-slate-400" data-voucher-hint-target>
    Single use, valid for VPS 1000 G12.5.
    <a href="https://www.netcup.com/en/checkout/cart?ref=44083" rel="sponsored noopener" target="_blank"
       data-track-event="Affiliate|netcup: Gutschein einlösen|VPS 1000 G12.5 · {page}"
       class="font-medium text-herb-800 underline underline-offset-2 hover:text-herb-900 dark:text-herb-400">Redeem in the cart →</a>
  </p><p class="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-herb-500/30 pt-2">
    <span>💶 <span class="font-semibold">€5.95 voucher</span> for new netcup customers:</span>
    <button type="button" data-voucher-code data-track-voucher="36nc17844976032"
            title="Click to copy" class="cursor-pointer rounded bg-white px-2 py-0.5 font-mono text-sm font-semibold text-herb-800 hover:ring-2 hover:ring-herb-500/40 dark:bg-slate-800 dark:text-herb-400">36nc17844976032</button>
    <span class="text-xs text-slate-500 dark:text-slate-400">always valid · not for domains or VPS Lite</span>
  </p><p class="mt-1 text-xs"><a href="/en/netcup-voucher/" class="font-medium text-herb-800 underline underline-offset-2 hover:text-herb-900 dark:text-herb-400">All netcup vouchers →</a></p>
</div></div>
</div>

<h2 id="step-by-step">Step by step</h2>
<h3 id="step-1-take-stock--how-exposed-are-your-containers-right-now">Step 1: Take stock – how exposed are your containers right now?</h3>
<p>Before changing anything, get an overview. This one-liner queries all running containers
and shows the three values this recipe is about:</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 ps -q <span class="p">|</span> xargs docker inspect --format <span class="se">\
</span></span></span><span class="line"><span class="cl">  <span class="s1">&#39;{{printf &#34;%-14s&#34; .Name}} user={{if .Config.User}}{{.Config.User}}{{else}}root{{end}}  readonly={{.HostConfig.ReadonlyRootfs}}  cap_drop={{.HostConfig.CapDrop}}&#39;</span></span></span></code></pre></div>
</div>
<p>On a typical server that looks 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">/traefik-traefik-1 user=root  readonly=false  cap_drop=[]</span></span></code></pre></div>
</div>
<p>Translated: the container runs as <strong>root</strong>, may <strong>write anywhere</strong>, and holds <strong>all
default capabilities</strong>. That is the Docker default – not the application&rsquo;s fault.</p>
<p>A second look goes to the most dangerous mount of all, the Docker socket:</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 ps -q <span class="p">|</span> xargs docker inspect --format <span class="s1">&#39;{{.Name}}{{range .Mounts}} {{.Source}}{{end}}&#39;</span> <span class="p">|</span> grep docker.sock</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">/traefik-traefik-1 /opt/traefik/acme.json /var/run/docker.sock</span></span></code></pre></div>
</div>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-paprika-400 bg-paprika-50 dark:border-paprika-700 dark:bg-paprika-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🔥</span>The Docker socket is root on the host
  </p>
  <div class="prose-kitchen text-sm">Anyone holding <code>/var/run/docker.sock</code> inside a container can start a new container with
the host filesystem mounted – and is therefore <strong>root on the server</strong>, regardless of
every other hardening measure. Traefik needs the socket for its auto-configuration; that
is the classic conflict of goals. How to defuse it is under &ldquo;Maintenance&rdquo; below. For
every other container the rule is: <strong>never</strong> mount the socket.</div>
</div>
<h3 id="step-2-why-root-in-the-container-is-a-real-problem">Step 2: Why &ldquo;root in the container&rdquo; is a real problem</h3>
<p>Look at who is working inside a perfectly ordinary 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 alpine:3 id</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">uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)</span></span></code></pre></div>
</div>
<p>That is <strong>the same UID 0 as on the host</strong> – separated only by namespaces, not by a
different identity. And that UID 0 gets fourteen capabilities from the Docker daemon:</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 alpine:3 sh -c <span class="s1">&#39;apk add -q --no-cache libcap &amp;&amp; capsh --print | grep -E &#34;^Current:&#34;&#39;</span></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">Current: cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap=ep</span></span></code></pre></div>
</div>
<p>Capabilities are the pieces Linux split root&rsquo;s former omnipotence into. <code>cap_chown</code>, for
instance, allows changing the owner of arbitrary files – including inside a mounted host
directory. That takes ten seconds to demonstrate. Create a file owned by another user:</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 /tmp/captest <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="nb">test</span> &gt; /tmp/captest/datei.txt
</span></span><span class="line"><span class="cl">sudo chown 1000:1000 /tmp/captest/datei.txt</span></span></code></pre></div>
</div>
<p>Now the container may claim 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">docker run --rm -v /tmp/captest:/d alpine:3 sh -c <span class="s1">&#39;chown 0:0 /d/datei.txt &amp;&amp; echo &#34;chown OK&#34;&#39;</span>
</span></span><span class="line"><span class="cl">ls -ln /tmp/captest/datei.txt</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">chown OK
</span></span><span class="line"><span class="cl">-rw-r----- 1 0 0 5 Oct  4 23:24 /tmp/captest/datei.txt</span></span></code></pre></div>
</div>
<p>The file now belongs to root – changed from inside a container. Take that one capability
away and the attack goes nowhere:</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 chown 1000:1000 /tmp/captest/datei.txt
</span></span><span class="line"><span class="cl">docker run --rm --cap-drop CHOWN -v /tmp/captest:/d alpine:3 sh -c <span class="s1">&#39;chown 0:0 /d/datei.txt&#39;</span></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">chown: /d/datei.txt: Operation not permitted</span></span></code></pre></div>
</div>
<p>That is exactly what least privilege means: not &ldquo;abolish root&rdquo;, but <strong>disarm root</strong>.
Clean up the test 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">rm -rf /tmp/captest</span></span></code></pre></div>
</div>
<h3 id="step-3-block-privilege-escalation-with-no-new-privileges">Step 3: Block privilege escalation with no-new-privileges</h3>
<p>The cheapest hardening first – one line that practically never breaks anything.
<code>no-new-privileges</code> forbids the kernel to grant a process <strong>more</strong> rights after the fact,
for example via a <code>setuid</code> binary. The difference is directly visible:</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 alpine:3 grep NoNewPrivs /proc/self/status
</span></span><span class="line"><span class="cl">docker run --rm --security-opt no-new-privileges:true alpine:3 grep NoNewPrivs /proc/self/status</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">NoNewPrivs:	0
</span></span><span class="line"><span class="cl">NoNewPrivs:	1</span></span></code></pre></div>
</div>
<p>With <code>1</code> there, an attacker who achieved code execution inside the container can no
longer use <code>setuid</code> binaries as a stepping stone. In Compose that is:</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">security_opt</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="kc">no</span>-<span class="l">new-privileges:true</span></span></span></code></pre></div>
</div>
<h3 id="step-4-drop-all-capabilities--and-hand-back-only-what-is-needed">Step 4: Drop all capabilities – and hand back only what is needed</h3>
<p>Now the core. The rule: take <strong>everything</strong> away first, then hand back what the
application really needs, based on real error messages. With everything gone, the list is
genuinely empty:</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 --cap-drop ALL alpine:3 sh -c <span class="s1">&#39;apk add -q --no-cache libcap &amp;&amp; capsh --print | grep -E &#34;^Current:&#34;&#39;</span></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">Current: =</span></span></code></pre></div>
</div>
<p>nginx then fails to start immediately. The entrypoint still works through its scripts,
then it aborts – the last two lines say exactly what is missing:</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 --cap-drop ALL nginx:1.31-alpine</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">/docker-entrypoint.sh: Configuration complete; ready for start up
</span></span><span class="line"><span class="cl">2026/09/09 19:38:42 [emerg] 1#1: chown(&#34;/var/cache/nginx/client_temp&#34;, 101) failed (1: Operation not permitted)
</span></span><span class="line"><span class="cl">nginx: [emerg] chown(&#34;/var/cache/nginx/client_temp&#34;, 101) failed (1: Operation not permitted)</span></span></code></pre></div>
</div>
<p>The image&rsquo;s entrypoint wants to hand the cache directory to the nginx user – that needs
<code>CHOWN</code>. Hand back only that one, and something instructive happens: the container
<strong>runs</strong>, but does not answer.</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 -d --name probe --cap-drop ALL --cap-add CHOWN nginx:1.31-alpine
</span></span><span class="line"><span class="cl">sleep <span class="m">5</span> <span class="o">&amp;&amp;</span> docker <span class="nb">exec</span> probe ps -o pid,user,comm</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">PID   USER     COMMAND
</span></span><span class="line"><span class="cl">    1 root     nginx
</span></span><span class="line"><span class="cl">   34 root     ps</span></span></code></pre></div>
</div>
<p>Only the master process is left (PID 34 is the <code>ps</code> itself), not a single worker. The log
reveals why:</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 logs probe 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="p">|</span> grep alert <span class="p">|</span> tail -1</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">2026/09/09 19:38:42 [alert] 1#1: worker process 33 exited with fatal code 2 and cannot be respawned</span></span></code></pre></div>
</div>
<p>Without <code>SETUID</code>/<code>SETGID</code> the master may not switch to the unprivileged nginx user, so
the workers die instantly. With those two added it works:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker rm -f probe
</span></span><span class="line"><span class="cl">docker run -d --name probe --cap-drop ALL <span class="se">\
</span></span></span><span class="line"><span class="cl">  --cap-add CHOWN --cap-add SETUID --cap-add SETGID nginx:1.31-alpine
</span></span><span class="line"><span class="cl">sleep <span class="m">5</span> <span class="o">&amp;&amp;</span> docker <span class="nb">exec</span> probe ps -o pid,user,comm</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">PID   USER     COMMAND
</span></span><span class="line"><span class="cl">    1 root     nginx
</span></span><span class="line"><span class="cl">   30 nginx    nginx
</span></span><span class="line"><span class="cl">   31 nginx    nginx
</span></span><span class="line"><span class="cl">   32 nginx    nginx
</span></span><span class="line"><span class="cl">   33 nginx    nginx
</span></span><span class="line"><span class="cl">   34 root     ps</span></span></code></pre></div>
</div>
<p>Master as root, workers as <code>nginx</code> – that is how it should be. (The number of workers depends on
the CPU cores; the VPS 1000 has four.) Verify what the kernel
actually grants the 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">docker <span class="nb">exec</span> probe grep CapEff /proc/1/status
</span></span><span class="line"><span class="cl">docker run --rm alpine:3 sh -c <span class="s1">&#39;apk add -q --no-cache libcap &amp;&amp; capsh --decode=00000000000000c1&#39;</span></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">CapEff:	00000000000000c1
</span></span><span class="line"><span class="cl">0x00000000000000c1=cap_chown,cap_setgid,cap_setuid</span></span></code></pre></div>
</div>
<p>Three instead of fourteen. Don&rsquo;t forget the test 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 rm -f probe</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>You usually don&#39;t need NET_BIND_SERVICE
  </p>
  <div class="prose-kitchen text-sm"><p>Many guides reflexively hand back <code>NET_BIND_SERVICE</code> because the service listens on port
80. In containers that has been unnecessary for years – Docker sets the threshold for
privileged ports to zero:</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 alpine:3 cat /proc/sys/net/ipv4/ip_unprivileged_port_start</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">0</span></span></code></pre></div>
</div>
<p>So <strong>every</strong> process in the container may bind any port. Measured: nginx runs with
<code>CHOWN,SETUID,SETGID</code> on port 80, entirely without <code>NET_BIND_SERVICE</code>. Hand back nothing
you haven&rsquo;t proven necessary with a real error message.</p>
</div>
</div>
<h3 id="step-5-mount-the-filesystem-read-only">Step 5: Mount the filesystem read-only</h3>
<p><code>read_only: true</code> makes the container&rsquo;s root filesystem unwritable. Malicious code can
then neither drop binaries nor tamper with configuration. The effect is blunt:</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 --read-only alpine:3 sh -c <span class="s1">&#39;touch /tmp/x&#39;</span></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">touch: /tmp/x: Read-only file system</span></span></code></pre></div>
</div>
<p>Almost every application needs write access <em>somewhere</em> though. The answer is <code>tmpfs</code>: a
RAM filesystem that disappears on restart and leaves nothing on disk.</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 --read-only --tmpfs /tmp alpine:3 sh -c <span class="s1">&#39;touch /tmp/x &amp;&amp; echo &#34;writing OK (RAM only)&#34;&#39;</span></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">writing OK (RAM only)</span></span></code></pre></div>
</div>
<p>Which paths those are is, again, what the error message tells you. For nginx:</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">nginx: [emerg] mkdir() &#34;/var/cache/nginx/client_temp&#34; failed (30: Read-only file system)</span></span></code></pre></div>
</div>
<p>Three directories are enough: <code>/var/cache/nginx</code>, <code>/var/run</code> and <code>/tmp</code>.</p>
<h3 id="step-6-no-root-at-all--the-container-as-its-own-user">Step 6: No root at all – the container as its own user</h3>
<p>The premier discipline: when no root works inside the container at all, capabilities
become largely moot. The nginx image already ships a <code>nginx</code> user with UID/GID 101.</p>
<p>A naive attempt fails, however:</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 --user 101:101 --cap-drop ALL --read-only <span class="se">\
</span></span></span><span class="line"><span class="cl">  --tmpfs /var/cache/nginx nginx:1.31-alpine</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">nginx: [emerg] mkdir() &#34;/var/cache/nginx/client_temp&#34; failed (13: Permission denied)</span></span></code></pre></div>
</div>
<p>The reason is inconspicuous: a <code>tmpfs</code> belongs to <strong>root</strong> by default, and UID 101 may
not write there. You have to pass the owner when mounting:</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 -d --name probe --user 101:101 --cap-drop ALL <span class="se">\
</span></span></span><span class="line"><span class="cl">  --security-opt no-new-privileges:true --read-only <span class="se">\
</span></span></span><span class="line"><span class="cl">  --tmpfs /var/cache/nginx:uid<span class="o">=</span>101,gid<span class="o">=</span><span class="m">101</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  --tmpfs /var/run:uid<span class="o">=</span>101,gid<span class="o">=</span><span class="m">101</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  --tmpfs /tmp:uid<span class="o">=</span>101,gid<span class="o">=</span><span class="m">101</span> nginx:1.31-alpine
</span></span><span class="line"><span class="cl">sleep <span class="m">5</span> <span class="o">&amp;&amp;</span> docker <span class="nb">exec</span> probe ps -o pid,user,comm</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">PID   USER     COMMAND
</span></span><span class="line"><span class="cl">    1 nginx    nginx
</span></span><span class="line"><span class="cl">   21 nginx    nginx
</span></span><span class="line"><span class="cl">   22 nginx    nginx
</span></span><span class="line"><span class="cl">   23 nginx    nginx
</span></span><span class="line"><span class="cl">   24 nginx    nginx
</span></span><span class="line"><span class="cl">   25 nginx    ps</span></span></code></pre></div>
</div>
<p>Now <strong>even PID 1</strong> runs as <code>nginx</code> – and the three capabilities from step 4 are no longer
needed, because nobody has to switch users or hand over cache directories any more.
Clean up:</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 rm -f probe</span></span></code></pre></div>
</div>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-amber-400 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">⚠️</span>Not every image can do this
  </p>
  <div class="prose-kitchen text-sm"><code>user:</code> only works if the image was built for it – the directories must belong to the
chosen user, or be provided via <code>tmpfs</code> with a matching UID. Images that run <code>chown</code>
themselves at startup (many database images) will fail. In that case stick with step 4:
root in the container, but without capabilities.</div>
</div>
<h3 id="step-7-cap-memory-and-processes">Step 7: Cap memory and processes</h3>
<p>Hardening also means that <strong>one</strong> derailed container does not take the whole server with
it. Without a limit a container may claim all the host&rsquo;s RAM. With a limit the OOM killer
steps in – but only inside 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 run -d --name oomtest --memory 128m alpine:3 sh -c <span class="s1">&#39;tail /dev/zero&#39;</span>
</span></span><span class="line"><span class="cl">sleep <span class="m">8</span>
</span></span><span class="line"><span class="cl">docker inspect -f <span class="s1">&#39;OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}}&#39;</span> oomtest</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">OOMKilled=true ExitCode=137</span></span></code></pre></div>
</div>
<p><code>ExitCode 137</code> is the signature of a memory limit – remember the number, it shows up
often in daily work. The counterpart against fork bombs is <code>pids_limit</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">docker run --rm --pids-limit <span class="m">20</span> alpine:3 sh -c <span class="s1">&#39;for i in $(seq 1 40); do sleep 5 &amp; done&#39;</span></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">sh: can&#39;t fork: Resource temporarily unavailable</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker rm -f oomtest</span></span></code></pre></div>
</div>
<h3 id="step-8-the-finished-stack">Step 8: The finished stack</h3>
<p>Everything combined, using a statically served website as the example – say the output of
<a href="/en/tutorials/host-your-website-with-hugo/">your own website with Hugo</a>. 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 ~/webhard/site <span class="o">&amp;&amp;</span> <span class="nb">cd</span> ~/webhard</span></span></code></pre></div>
</div>
<p>Create <code>compose.yaml</code> and replace <code>YOUR_DOMAIN</code>:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">web</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">nginx:1.31-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">web-hart</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">user</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;101:101&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">cap_drop</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">ALL]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">security_opt</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="kc">no</span>-<span class="l">new-privileges:true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">read_only</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">tmpfs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/var/cache/nginx:uid=101,gid=101</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/var/run:uid=101,gid=101</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/tmp:uid=101,gid=101</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">mem_limit</span><span class="p">:</span><span class="w"> </span><span class="l">128m</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">pids_limit</span><span class="p">:</span><span class="w"> </span><span class="m">64</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./site:/usr/share/nginx/html:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w"> </span><span class="p">[</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.hart.rule=Host(`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.hart.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.hart.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.hart.loadbalancer.server.port=80&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">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 points that make the difference:</p>
<ul>
<li><strong><code>user: &quot;101:101&quot;</code></strong> – no root in the container. The quotes matter, otherwise YAML
reads the value as a sexagesimal number.</li>
<li><strong><code>cap_drop: [ALL]</code></strong> with no <code>cap_add</code> at all – possible because <code>user:</code> is set.</li>
<li><strong><code>read_only: true</code></strong> plus three <code>tmpfs</code> mounts with the matching UID.</li>
<li><strong><code>:ro</code> on the volume</strong> – nginx only reads the website, it never has to write there.</li>
<li><strong><code>mem_limit</code> and <code>pids_limit</code></strong> cap the damage of a runaway process.</li>
</ul>
<p>Because nginx now runs as UID 101 rather than root, it also has to be <strong>allowed to read</strong> the
website. On a hardened server with <code>UMASK 027</code> in <code>/etc/login.defs</code> – as the <a href="/en/tutorials/lynis-security-audit/">Lynis audit</a> recommends –
<code>mkdir</code> and <code>echo</code> as root create <code>drwxr-x---</code> and <code>-rw-r-----</code>, and UID 101 can&rsquo;t see any of it;
the stack then answers <code>403 Forbidden</code>. Hence the <code>chmod -R a+rX site</code> below – it makes files
readable and directories traversable without making files executable, and does no harm with
Debian&rsquo;s default umask.</p>
<p>Drop in a test page 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"><span class="nb">echo</span> <span class="s1">&#39;&lt;h1&gt;Hardening worked&lt;/h1&gt;&#39;</span> &gt; site/index.html
</span></span><span class="line"><span class="cl">chmod -R a+rX site
</span></span><span class="line"><span class="cl">docker compose up -d
</span></span><span class="line"><span class="cl">docker compose ps</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">NAME       IMAGE               STATUS
</span></span><span class="line"><span class="cl">web-hart   nginx:1.31-alpine   Up 10 seconds</span></span></code></pre></div>
</div>
<p>Now the actual check – trust is good, <code>docker inspect</code> is better:</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 web-hart --format <span class="s1">&#39;ReadonlyRootfs={{.HostConfig.ReadonlyRootfs}}  CapDrop={{.HostConfig.CapDrop}}  SecurityOpt={{.HostConfig.SecurityOpt}}  PidsLimit={{.HostConfig.PidsLimit}}  User={{.Config.User}}&#39;</span></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">ReadonlyRootfs=true  CapDrop=[ALL]  SecurityOpt=[no-new-privileges:true]  PidsLimit=64  User=101:101</span></span></code></pre></div>
</div>
<p>And the counter-test that the write protection really bites:</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> web-hart sh -c <span class="s1">&#39;touch /etc/x&#39;</span></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">touch: /etc/x: Read-only file system</span></span></code></pre></div>
</div>
<p>Finally from outside, through Traefik with a real certificate:</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 https://YOUR_DOMAIN/</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">&lt;h1&gt;Hardening worked&lt;/h1&gt;</span></span></code></pre></div>
</div>
<p>A container with no capabilities, no root, no write access – serving perfectly normally.
This is the pattern you now attach to your remaining stacks.</p>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong>The container won&rsquo;t start and reports <code>Operation not permitted</code>.</strong> You dropped one
capability too many. The message almost always names the system call – <code>chown(...) failed</code> means <code>CHOWN</code>, <code>setgid</code> means <code>SETGID</code>. Hand back exactly that one
(<code>cap_add: [CHOWN]</code>) and restart; work your way towards the minimum instead of adding
several capabilities on suspicion.</p>
<p><strong>The container is <code>Up</code> according to <code>docker compose ps</code> but doesn&rsquo;t answer.</strong> <code>Up</code> only
means PID 1 is alive – not that the application is working. With nginx the workers die
without <code>SETUID</code>/<code>SETGID</code> while the master keeps running. Check with <code>docker exec CONTAINER ps -o pid,user,comm</code> whether there is more than one process, and look at the
log: <code>docker compose logs | grep -iE &quot;alert|emerg&quot;</code>.</p>
<p><strong><code>Read-only file system</code> at startup.</strong> The application needs a writable directory you
haven&rsquo;t provided as <code>tmpfs</code> yet. The error message names the path; add it under <code>tmpfs:</code>.
Careful: real <strong>data</strong> never belongs in a <code>tmpfs</code> – that is RAM and gone on restart. Keep
using a volume for that.</p>
<p><strong>The page returns <code>403 Forbidden</code>, the log says <code>is forbidden (13: Permission denied)</code>.</strong>
nginx runs as UID 101 and isn&rsquo;t allowed to read the files in the mounted directory – typical for
servers with <code>UMASK 027</code>, where root creates new files without read permission for others. Check
with <code>ls -l</code> and fix with <code>chmod -R a+rX site</code>.</p>
<p><strong><code>Permission denied</code> in the tmpfs even though read_only looks right.</strong> A <code>tmpfs</code> belongs
to root by default. If you set <code>user:</code>, the mount needs the UID:
<code>/var/cache/nginx:uid=101,gid=101</code>. Find the right UID with <code>docker run --rm YOUR_IMAGE id USERNAME</code>.</p>
<p><strong>The container is killed without warning and <code>docker inspect</code> shows ExitCode 137.</strong> That
is the OOM killer: <code>mem_limit</code> is too tight. Measure the real demand in operation with
<code>docker stats --no-stream</code> and set the limit a little above it. Leaving the limit out
entirely is the worse option – then the container may take the server down with it.</p>

</div>
<p class="mt-3 text-sm text-slate-500 dark:text-slate-400">
  Your problem is not listed?
  <a href="/en/errors/" class="font-medium text-paprika-700 hover:underline dark:text-paprika-400">Search all error messages →</a>
</p>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<ul>
<li><strong>Hardening is per-application work, not a one-off for all.</strong> Every image needs a
different minimum. Budget 15–30 minutes per stack for the &ldquo;drop everything, read the
log, hand back what&rsquo;s needed&rdquo; round – and do it <strong>once properly</strong> instead of copying
values from other people&rsquo;s examples. Foreign <code>cap_add</code> lists are almost always too
generous.</li>
<li><strong>Re-check briefly after every image update.</strong> A new major version may want to write to
an additional directory or switch its internal user. The container then won&rsquo;t start –
annoying, but quickly found when you know what to look for. Fold the check into your
<a href="/en/tutorials/keep-docker-stack-updated/">update process</a>.</li>
<li><strong>The audit one-liner from step 1 belongs in your routine.</strong> Running it over all
containers once a month immediately shows which stack slipped through without
hardening on the last deploy.</li>
<li><strong>Defuse the Docker socket.</strong> Traefik genuinely needs it – but not for writing. A <code>:ro</code>
on the mount is a first step, but it does not prevent starting containers via the API.
The clean solution is a <strong>socket proxy</strong>: a small container that passes Traefik only
the read endpoints and blocks everything else. If the socket is there just for Traefik,
that is where you gain the most.</li>
<li><strong>Honest about the benefit.</strong> These measures do not prevent a break-in into the
application. They make sure that &ldquo;attacker can run code in a web application&rdquo; does not
become &ldquo;attacker is root on the server&rdquo;. Together with
<a href="/en/tutorials/traefik-middlewares-hardening/">Traefik hardening</a> in front and
<a href="/en/tutorials/restic-backups/">encrypted backups</a> behind, that makes a resilient
chain.</li>
<li><strong>Backup:</strong> no new data is involved – you back up the volume contents as before. The
hardening itself lives entirely in <code>compose.yaml</code>, which belongs in version control
anyway.</li>
</ul>
]]></content:encoded></item></channel></rss>