<?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/tags/capabilities/</link><description>Capabilities – Neueste Beiträge von Serverküche</description><generator>Hugo</generator><language>de-DE</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/tags/capabilities/index.xml" rel="self" type="application/rss+xml"/><item><title>Docker-Container absichern: cap_drop, read_only &amp; Least Privilege</title><link>https://serverkueche.de/tutorials/docker-container-absichern/</link><pubDate>Thu, 15 Oct 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/tutorials/docker-container-absichern/</guid><description>Container härten mit Docker-Bordmitteln: Capabilities entziehen, no-new-privileges, read_only mit tmpfs und Ressourcenlimits – real gegengeprüft.</description><content:encoded><![CDATA[<p>Ein Container ist keine virtuelle Maschine. Standardmäßig läuft darin <strong>root</strong>, mit
vierzehn Kernel-Capabilities und einem beschreibbaren Dateisystem – und genau das
brauchen die wenigsten Anwendungen. Dieses Rezept nimmt einem Container Stück für Stück
alles weg, was er nicht benötigt, und prüft nach jedem Schritt, ob er noch tut.</p>
<h2 id="was-bauen-wir">Was bauen wir?</h2>
<p>Ein <strong>Härtungsmuster</strong>, das du auf jeden bestehenden Compose-Stack anwenden kannst:
Capabilities komplett entziehen und nur das Nötigste zurückgeben, Rechteausweitung per
<code>no-new-privileges</code> sperren, das Dateisystem schreibgeschützt mounten und Arbeitsspeicher
sowie Prozessanzahl begrenzen.</p>
<p>Als Übungsobjekt dient ein <strong>nginx 1.31-alpine</strong> hinter deinem Traefik. Am Ende läuft er
<strong>ohne eine einzige Capability</strong>, als nicht-privilegierter Benutzer, mit
schreibgeschütztem Root-Dateisystem – und liefert trotzdem ganz normal aus. Getestet auf
Debian 13 mit <strong>Docker 29.8.2</strong> und <strong>Compose v5.6.0</strong>.</p>
<p>Der Gewinn ist konkret: Wenn eine Anwendung eine Lücke hat, entscheidet die Härtung
darüber, ob ein Angreifer im Container nur herumsteht oder anfängt, Dateirechte zu
ändern, Binaries nachzuladen und den Host anzugreifen.</p>
<h2 id="voraussetzungen">Voraussetzungen</h2>
<ul>
<li>Die <a href="/tutorials/docker-compose-grundlagen/">Compose-Grundlagen</a> – wir arbeiten
durchgehend in <code>compose.yaml</code>.</li>
<li>Ein <a href="/tutorials/ssh-absichern/">abgesicherter Server</a> mit Docker; für den Demo-Teil ein
laufender <a href="/tutorials/reverse-proxy-traefik/">Traefik-Reverse-Proxy</a> mit dem
<code>proxy</code>-Netzwerk und dem Resolver <code>le</code>.</li>
<li>Grundverständnis von <a href="/tutorials/linux-benutzer-und-rechte/">Benutzern und Rechten unter Linux</a> –
UID/GID tauchen hier ständig auf.</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>Härtung ersetzt keine Updates
  </p>
  <div class="prose-kitchen text-sm">Ein gehärteter Container mit veralteter Software ist immer noch verwundbar – die Härtung
begrenzt nur den Schaden. Sie ist die <strong>zweite</strong> Verteidigungslinie hinter einem
funktionierenden <a href="/tutorials/docker-stack-aktuell-halten/">Update-Prozess</a>, nicht ihr
Ersatz.</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 · /tutorials/docker-container-absichern/" 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>🍳 Empfehlung</span>
    <span title="Mit * markierte Links sind Affiliate-Links.">Anzeige</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 vCore · 8 GB RAM · 128 GB SSD</p>
      <p class="mt-1 text-sm font-semibold text-paprika-700 dark:text-paprika-400">ab 14,50 €/Monat</p>
      <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">Härtung kostet keine Ressourcen – das läuft auf dem kleinsten VPS.</p>
    </div>
    <a href="https://www.netcup.com/de/server/vps/vps-1000-g12.5-24m-eu?ref=44083#vps-1000-g12.5-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">
  Zu 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="Gerade ist für diesen Tarif kein Code frei – nimm den 5,95-€-Code." data-voucher-msg-limit="Für heute sind keine weiteren Codes drin – nimm den 5,95-€-Code oder versuch es morgen." data-voucher-msg-error="Der Code ließ sich gerade nicht abrufen – nimm den 5,95-€-Code."
     data-voucher-hint="Warenkorb vorher leeren – der Code legt den Server selbst hinein.">
  <p class="flex flex-wrap items-center gap-x-2 gap-y-1"><span>💶 <span class="font-semibold">1 Monat gratis</span>:</span>
    <button type="button" data-voucher-reveal data-copy-title="Zum Kopieren klicken"
            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">Code anzeigen</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">anderer Code</button>
  </p><p class="mt-1 text-xs text-slate-500 dark:text-slate-400" data-voucher-hint-target>
    Einmal einlösbar, gilt für VPS 1000 G12.5.
    <a href="https://www.netcup.com/de/checkout/warenkorb?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">Im Warenkorb einlösen →</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 € Gutschein</span> für netcup-Neukunden:</span>
    <button type="button" data-voucher-code data-track-voucher="36nc17844976032"
            title="Zum Kopieren klicken" 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">immer gültig · nicht für Domains und VPS Lite</span>
  </p><p class="mt-1 text-xs"><a href="/netcup-gutschein/" class="font-medium text-herb-800 underline underline-offset-2 hover:text-herb-900 dark:text-herb-400">Alle netcup-Gutscheine →</a></p>
</div></div>
</div>

<h2 id="schritt-für-schritt">Schritt für Schritt</h2>
<h3 id="schritt-1-bestandsaufnahme--wie-offen-läuft-dein-bestand-gerade">Schritt 1: Bestandsaufnahme – wie offen läuft dein Bestand gerade?</h3>
<p>Bevor du etwas änderst, verschaff dir einen Überblick. Dieser Einzeiler fragt alle
laufenden Container ab und zeigt die drei Werte, um die es in diesem Rezept geht:</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>Auf einem typischen Server sieht das so aus:</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>Übersetzt: Der Container läuft als <strong>root</strong>, darf <strong>überall hinschreiben</strong> und hat <strong>alle
Standard-Capabilities</strong>. Das ist der Docker-Default – nicht die Schuld der Anwendung.</p>
<p>Ein zweiter Blick gilt dem gefährlichsten Mount überhaupt, dem 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>Der Docker-Socket ist Root auf dem Host
  </p>
  <div class="prose-kitchen text-sm">Wer <code>/var/run/docker.sock</code> im Container hat, kann einen neuen Container mit gemountetem
Host-Dateisystem starten – und ist damit <strong>root auf dem Server</strong>, unabhängig von allen
anderen Härtungsmaßnahmen. Traefik braucht den Socket für seine Auto-Konfiguration; das
ist der klassische Zielkonflikt. Wie du ihn entschärfst, steht unten unter „Wartung&quot;.
Für alle anderen Container gilt: Socket <strong>niemals</strong> mounten.</div>
</div>
<h3 id="schritt-2-warum-root-im-container-ein-echtes-problem-ist">Schritt 2: Warum „root im Container&quot; ein echtes Problem ist</h3>
<p>Sieh dir an, wer in einem ganz normalen Container arbeitet:</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>Das ist <strong>dieselbe UID 0 wie auf dem Host</strong> – getrennt nur durch Namespaces, nicht durch
eine andere Identität. Und diese UID 0 bekommt vom Docker-Daemon vierzehn Capabilities
mit auf den Weg:</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 sind die Einzelteile, in die Linux die früheren Allmachtsrechte von root
zerlegt hat. <code>cap_chown</code> etwa erlaubt, den Besitzer beliebiger Dateien zu ändern – auch
in einem eingehängten Host-Verzeichnis. Das lässt sich in zehn Sekunden zeigen. Leg eine
Datei an, die einem anderen Benutzer gehört:</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>Jetzt darf der Container sie sich aneignen:</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 Okt  4 23:24 /tmp/captest/datei.txt</span></span></code></pre></div>
</div>
<p>Die Datei gehört jetzt root – geändert aus einem Container heraus. Nimm dieselbe
Capability weg, und der Angriff läuft ins Leere:</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>Genau darum geht es beim Least Privilege: nicht „root abschaffen&quot;, sondern <strong>root
entwaffnen</strong>. Räum den Testordner wieder weg:</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="schritt-3-rechteausweitung-sperren-mit-no-new-privileges">Schritt 3: Rechteausweitung sperren mit no-new-privileges</h3>
<p>Die billigste Härtung zuerst – eine Zeile, die praktisch nie etwas kaputt macht.
<code>no-new-privileges</code> verbietet dem Kernel, einem Prozess <strong>nachträglich</strong> mehr Rechte zu
geben, etwa über ein <code>setuid</code>-Binary. Der Unterschied ist direkt sichtbar:</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>Steht dort <code>1</code>, kann ein Angreifer, der Codeausführung im Container erreicht hat, keine
<code>setuid</code>-Binaries mehr als Sprungbrett benutzen. In Compose ist das:</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="schritt-4-alle-capabilities-entziehen--und-nur-das-nötigste-zurückgeben">Schritt 4: Alle Capabilities entziehen – und nur das Nötigste zurückgeben</h3>
<p>Jetzt der Kern. Die Regel lautet: erst <strong>alles</strong> wegnehmen, dann anhand echter
Fehlermeldungen zurückgeben, was die Anwendung wirklich braucht. Ohne alles ist die
Liste tatsächlich leer:</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>Bei nginx scheitert der Start dann sofort. Der Einstiegspunkt arbeitet noch seine
Skripte ab, dann bricht es ab – die letzten beiden Zeilen sagen exakt, was fehlt:</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>Der Einstiegspunkt des Images will das Cache-Verzeichnis dem nginx-Benutzer übereignen –
dafür braucht er <code>CHOWN</code>. Gibst du nur die zurück, passiert etwas Lehrreiches: Der
Container <strong>läuft</strong>, antwortet aber nicht.</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>Nur der Master-Prozess ist übrig (PID 34 ist das <code>ps</code> selbst), kein einziger Worker. Das Log verrät den Grund:</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>Der Master darf ohne <code>SETUID</code>/<code>SETGID</code> nicht in den unprivilegierten nginx-Benutzer
wechseln, also sterben die Worker sofort. Mit den beiden dazu läuft es:</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 als root, Worker als <code>nginx</code> – so soll es sein. (Wie viele Worker es sind, hängt an den
CPU-Kernen; der VPS 1000 hat vier.) Kontrollier gegen, was der
Kernel dem Prozess tatsächlich zugesteht:</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>Drei statt vierzehn. Vergiss den Testcontainer nicht:</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>NET_BIND_SERVICE brauchst du meistens nicht
  </p>
  <div class="prose-kitchen text-sm"><p>Viele Anleitungen geben reflexhaft <code>NET_BIND_SERVICE</code> zurück, weil der Dienst auf Port 80
lauscht. In Containern ist das seit Jahren unnötig – Docker setzt den Schwellenwert für
privilegierte Ports auf null:</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>Damit darf <strong>jeder</strong> Prozess im Container jeden Port binden. Nachgemessen: nginx läuft
mit <code>CHOWN,SETUID,SETGID</code> auf Port 80, ganz ohne <code>NET_BIND_SERVICE</code>. Gib nichts zurück,
was du nicht durch eine echte Fehlermeldung belegt hast.</p>
</div>
</div>
<h3 id="schritt-5-das-dateisystem-schreibgeschützt-mounten">Schritt 5: Das Dateisystem schreibgeschützt mounten</h3>
<p><code>read_only: true</code> macht das Root-Dateisystem des Containers unbeschreibbar. Damit kann
Schadcode weder Binaries ablegen noch Konfigurationen manipulieren. Der Effekt ist hart:</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>Fast jede Anwendung braucht aber <em>irgendwo</em> Schreibrechte. Die Lösung ist <code>tmpfs</code>: ein
RAM-Dateisystem, das beim Neustart verschwindet und nichts auf der Platte hinterlässt.</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;schreiben OK (nur im RAM)&#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">schreiben OK (nur im RAM)</span></span></code></pre></div>
</div>
<p>Welche Pfade das sind, sagt dir wieder die Fehlermeldung. Bei 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>Drei Verzeichnisse reichen: <code>/var/cache/nginx</code>, <code>/var/run</code> und <code>/tmp</code>.</p>
<h3 id="schritt-6-ganz-ohne-root--der-container-als-eigener-benutzer">Schritt 6: Ganz ohne root – der Container als eigener Benutzer</h3>
<p>Die Königsdisziplin: Wenn im Container gar kein root mehr arbeitet, werden Capabilities
weitgehend gegenstandslos. Das nginx-Image bringt den Benutzer <code>nginx</code> mit UID/GID 101
schon mit.</p>
<p>Ein naiver Versuch scheitert allerdings:</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>Der Grund ist unscheinbar: Ein <code>tmpfs</code> gehört standardmäßig <strong>root</strong>, und UID 101 darf
dort nicht schreiben. Du musst den Besitzer beim Mounten mitgeben:</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>Jetzt läuft <strong>auch PID 1</strong> als <code>nginx</code> – und die drei Capabilities aus Schritt 4 werden
nicht mehr gebraucht, weil niemand mehr den Benutzer wechseln oder Cache-Verzeichnisse
übereignen muss. Aufräumen:</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>Nicht jedes Image kann das
  </p>
  <div class="prose-kitchen text-sm"><code>user:</code> funktioniert nur, wenn das Image dafür gebaut ist – die Verzeichnisse müssen dem
gewählten Benutzer gehören oder über <code>tmpfs</code> mit passender UID bereitgestellt werden.
Bei Images, die beim Start selbst <code>chown</code> ausführen (viele Datenbank-Images), scheitert
das. Dann bleibt es bei Schritt 4: root im Container, aber ohne Capabilities.</div>
</div>
<h3 id="schritt-7-arbeitsspeicher-und-prozesse-begrenzen">Schritt 7: Arbeitsspeicher und Prozesse begrenzen</h3>
<p>Härtung heißt auch, dass <strong>ein</strong> entgleister Container nicht den ganzen Server mitnimmt.
Ohne Limit darf ein Container allen RAM des Hosts belegen. Mit Limit greift der
OOM-Killer – aber nur im 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> ist die Signatur eines Speicherlimits – merk dir die Zahl, sie taucht im
Alltag oft auf. Das Gegenstück gegen Fork-Bomben ist <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="schritt-8-der-fertige-stack">Schritt 8: Der fertige Stack</h3>
<p>Alles zusammengesetzt am Beispiel eines statisch ausgelieferten Webauftritts – etwa der
Ausgabe aus <a href="/tutorials/eigene-website-mit-hugo/">Website mit Hugo</a>. Leg das Projekt an:</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>Erstelle <code>compose.yaml</code> und ersetze <code>DEINE_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(`DEINE_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>Die Punkte, die den Unterschied machen:</p>
<ul>
<li><strong><code>user: &quot;101:101&quot;</code></strong> – kein root im Container. Die Anführungszeichen sind wichtig,
sonst interpretiert YAML den Wert als Zahl mit Doppelpunkt.</li>
<li><strong><code>cap_drop: [ALL]</code></strong> ohne jedes <code>cap_add</code> – möglich, weil <code>user:</code> gesetzt ist.</li>
<li><strong><code>read_only: true</code></strong> plus drei <code>tmpfs</code> mit passender UID.</li>
<li><strong><code>:ro</code> am Volume</strong> – die Website liest nginx nur, schreiben muss er dort nie.</li>
<li><strong><code>mem_limit</code> und <code>pids_limit</code></strong> deckeln den Schaden eines Amoklaufs.</li>
</ul>
<p>Weil nginx jetzt als UID 101 läuft und nicht mehr als root, muss er die Website auch
<strong>lesen dürfen</strong>. Auf einem gehärteten Server mit <code>UMASK 027</code> in <code>/etc/login.defs</code> – wie sie das
<a href="/tutorials/lynis-sicherheits-audit/">Lynis-Audit</a> empfiehlt – legen <code>mkdir</code> und <code>echo</code> als root <code>drwxr-x---</code> und <code>-rw-r-----</code> an, und
UID 101 sieht davon nichts; der Stack antwortet dann mit <code>403 Forbidden</code>. Deshalb unten das
<code>chmod -R a+rX site</code> – es macht Dateien lesbar und Verzeichnisse betretbar, ohne Dateien
ausführbar zu machen, und schadet bei der Debian-Standard-umask nicht.</p>
<p>Leg eine Testseite ab und starte:</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;Härten geglückt&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>Jetzt die eigentliche Kontrolle – Vertrauen ist gut, <code>docker inspect</code> ist besser:</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>Und die Gegenprobe, dass der Schreibschutz wirklich greift:</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>Zum Schluss von außen, über Traefik mit echtem Zertifikat:</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://DEINE_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;Härten geglückt&lt;/h1&gt;</span></span></code></pre></div>
</div>
<p>Ein Container ohne Capabilities, ohne root, ohne Schreibrechte – der ganz normal
ausliefert. Genau dieses Muster hängst du jetzt an deine übrigen Stacks.</p>
<h2 id="wenn-es-nicht-funktioniert">Wenn es nicht funktioniert</h2>
<div class="troubleshoot not-prose">
<p><strong>Der Container startet nicht und meldet <code>Operation not permitted</code>.</strong> Du hast eine
Capability zu viel entzogen. Die Meldung nennt fast immer den Systemaufruf – <code>chown(...) failed</code> heißt <code>CHOWN</code>, <code>setgid</code> heißt <code>SETGID</code>. Gib genau diese eine zurück
(<code>cap_add: [CHOWN]</code>) und starte neu; arbeite dich so an das Minimum heran, statt pauschal
mehrere Capabilities auf Verdacht zu setzen.</p>
<p><strong>Der Container läuft laut <code>docker compose ps</code>, antwortet aber nicht.</strong> <code>Up</code> heißt nur,
dass PID 1 lebt – nicht, dass die Anwendung arbeitet. Bei nginx sterben ohne
<code>SETUID</code>/<code>SETGID</code> die Worker, während der Master weiterläuft. Prüfe mit <code>docker exec CONTAINER ps -o pid,user,comm</code>, ob mehr als ein Prozess da ist, und sieh ins Log:
<code>docker compose logs | grep -iE &quot;alert|emerg&quot;</code>.</p>
<p><strong><code>Read-only file system</code> beim Start.</strong> Die Anwendung braucht ein Schreibverzeichnis, das
du noch nicht als <code>tmpfs</code> bereitgestellt hast. Den Pfad nennt die Fehlermeldung; trag ihn
unter <code>tmpfs:</code> ein. Achtung: Echte <strong>Daten</strong> gehören nie ins <code>tmpfs</code> – das ist RAM und
beim Neustart weg. Dafür weiterhin ein Volume verwenden.</p>
<p><strong>Die Seite liefert <code>403 Forbidden</code>, im Log steht <code>is forbidden (13: Permission denied)</code>.</strong>
nginx läuft als UID 101 und darf die Dateien im eingehängten Verzeichnis nicht lesen – typisch
für Server mit <code>UMASK 027</code>, wo root neue Dateien ohne Leserecht für andere anlegt. Mit <code>ls -l</code>
prüfen und mit <code>chmod -R a+rX site</code> beheben.</p>
<p><strong><code>Permission denied</code> im tmpfs, obwohl read_only passt.</strong> Ein <code>tmpfs</code> gehört
standardmäßig root. Wenn du <code>user:</code> gesetzt hast, muss der Mount die UID mitbekommen:
<code>/var/cache/nginx:uid=101,gid=101</code>. Die richtige UID findest du mit <code>docker run --rm DEIN_IMAGE id NUTZERNAME</code>.</p>
<p><strong>Der Container wird ohne Vorwarnung beendet, <code>docker inspect</code> zeigt ExitCode 137.</strong> Das
ist der OOM-Killer: <code>mem_limit</code> ist zu knapp. Prüfe den echten Bedarf im Betrieb mit
<code>docker stats --no-stream</code> und setz das Limit mit etwas Luft darüber. Ein Limit ganz
wegzulassen ist die schlechtere Lösung – dann reißt der Container im Zweifel den Server
mit.</p>

</div>
<p class="mt-3 text-sm text-slate-500 dark:text-slate-400">
  Dein Problem ist nicht dabei?
  <a href="/fehler/" class="font-medium text-paprika-700 hover:underline dark:text-paprika-400">Alle Fehlermeldungen durchsuchen →</a>
</p>

<h2 id="wartung--backups">Wartung &amp; Backups</h2>
<ul>
<li><strong>Härtung ist Arbeit pro Anwendung, nicht einmal für alle.</strong> Jedes Image braucht ein
anderes Minimum. Plane pro Stack 15–30 Minuten für die Runde „alles wegnehmen, Log
lesen, Nötiges zurückgeben&quot; – und mach das <strong>einmal richtig</strong>, statt Werte von fremden
Beispielen abzuschreiben. Fremde <code>cap_add</code>-Listen sind fast immer zu großzügig.</li>
<li><strong>Nach jedem Image-Update kurz gegenprüfen.</strong> Ein neues Major kann ein zusätzliches
Verzeichnis beschreiben wollen oder den internen Benutzer wechseln. Der Container
startet dann nicht mehr – ärgerlich, aber schnell gefunden, wenn du weißt, wonach du
suchst. Nimm die Kontrolle mit in deinen
<a href="/tutorials/docker-stack-aktuell-halten/">Update-Prozess</a> auf.</li>
<li><strong>Der Audit-Einzeiler aus Schritt 1 gehört in deine Routine.</strong> Einmal im Monat über
alle Container laufen lassen zeigt sofort, welcher Stack beim letzten Deploy ohne
Härtung durchgerutscht ist.</li>
<li><strong>Den Docker-Socket entschärfen.</strong> Traefik braucht ihn wirklich – aber nicht
schreibend. Ein <code>:ro</code> am Mount ist ein erster Schritt, verhindert aber keinen
Container-Start über die API. Die saubere Lösung ist ein <strong>Socket-Proxy</strong>: ein kleiner
Container, der Traefik nur die lesenden Endpunkte durchreicht und alles andere
blockiert. Wer den Socket ohnehin nur für Traefik braucht, gewinnt damit am meisten.</li>
<li><strong>Ehrlich zum Nutzen.</strong> Diese Maßnahmen verhindern keinen Einbruch in die Anwendung.
Sie sorgen dafür, dass aus „Angreifer kann in einer Webanwendung Code ausführen&quot; nicht
„Angreifer ist root auf dem Server&quot; wird. Zusammen mit
<a href="/tutorials/traefik-middlewares-absichern/">Traefik-Härtung</a> davor und
<a href="/tutorials/backups-mit-restic/">verschlüsselten Backups</a> dahinter ergibt das eine
belastbare Kette.</li>
<li><strong>Backup:</strong> Es kommen keine neuen Daten dazu – gesichert wird wie gehabt der
Volume-Inhalt. Die Härtung selbst steckt vollständig in der <code>compose.yaml</code>; die gehört
ohnehin in deine Versionsverwaltung.</li>
</ul>
]]></content:encoded></item></channel></rss>