Zum Inhalt springen
Serverküche
Suche

Die Suche wird geladen … (nur in der veröffentlichten Seite verfügbar).

Sicherheit Schwierigkeit: Profi

Docker-Container absichern: cap_drop, read_only & Least Privilege

Container härten mit Docker-Bordmitteln: Capabilities entziehen, no-new-privileges, read_only mit tmpfs und Ressourcenlimits – real gegengeprüft.

· 13 Min. Lesezeit ·Dauer: ca. 50 Minuten
Inhaltsverzeichnis

Ein Container ist keine virtuelle Maschine. Standardmäßig läuft darin root, 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.

Was bauen wir?

Ein Härtungsmuster, das du auf jeden bestehenden Compose-Stack anwenden kannst: Capabilities komplett entziehen und nur das Nötigste zurückgeben, Rechteausweitung per no-new-privileges sperren, das Dateisystem schreibgeschützt mounten und Arbeitsspeicher sowie Prozessanzahl begrenzen.

Als Übungsobjekt dient ein nginx 1.31-alpine hinter deinem Traefik. Am Ende läuft er ohne eine einzige Capability, als nicht-privilegierter Benutzer, mit schreibgeschütztem Root-Dateisystem – und liefert trotzdem ganz normal aus. Getestet auf Debian 13 mit Docker 29.8.2 und Compose v5.6.0.

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.

Voraussetzungen

Härtung ersetzt keine Updates

Ein gehärteter Container mit veralteter Software ist immer noch verwundbar – die Härtung begrenzt nur den Schaden. Sie ist die zweite Verteidigungslinie hinter einem funktionierenden Update-Prozess, nicht ihr Ersatz.
🍳 Empfehlung Anzeige

VPS 1000 G12.5

4 vCore · 8 GB RAM · 128 GB SSD

ab 14,50 €/Monat

Härtung kostet keine Ressourcen – das läuft auf dem kleinsten VPS.

Zu netcup →

💶 1 Monat gratis:

Einmal einlösbar, gilt für VPS 1000 G12.5. Im Warenkorb einlösen →

💶 5,95 € Gutschein für netcup-Neukunden: immer gültig · nicht für Domains und VPS Lite

Alle netcup-Gutscheine →

Schritt für Schritt

Schritt 1: Bestandsaufnahme – wie offen läuft dein Bestand gerade?

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:

Terminal
docker ps -q | xargs docker inspect --format \
  '{{printf "%-14s" .Name}} user={{if .Config.User}}{{.Config.User}}{{else}}root{{end}}  readonly={{.HostConfig.ReadonlyRootfs}}  cap_drop={{.HostConfig.CapDrop}}'

Auf einem typischen Server sieht das so aus:

Ausgabe
/traefik-traefik-1 user=root  readonly=false  cap_drop=[]

Übersetzt: Der Container läuft als root, darf überall hinschreiben und hat alle Standard-Capabilities. Das ist der Docker-Default – nicht die Schuld der Anwendung.

Ein zweiter Blick gilt dem gefährlichsten Mount überhaupt, dem Docker-Socket:

Terminal
docker ps -q | xargs docker inspect --format '{{.Name}}{{range .Mounts}} {{.Source}}{{end}}' | grep docker.sock
Ausgabe
/traefik-traefik-1 /opt/traefik/acme.json /var/run/docker.sock

Der Docker-Socket ist Root auf dem Host

Wer /var/run/docker.sock im Container hat, kann einen neuen Container mit gemountetem Host-Dateisystem starten – und ist damit root auf dem Server, 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". Für alle anderen Container gilt: Socket niemals mounten.

Schritt 2: Warum „root im Container" ein echtes Problem ist

Sieh dir an, wer in einem ganz normalen Container arbeitet:

Terminal
docker run --rm alpine:3 id
Ausgabe
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)

Das ist dieselbe UID 0 wie auf dem Host – getrennt nur durch Namespaces, nicht durch eine andere Identität. Und diese UID 0 bekommt vom Docker-Daemon vierzehn Capabilities mit auf den Weg:

Terminal
docker run --rm alpine:3 sh -c 'apk add -q --no-cache libcap && capsh --print | grep -E "^Current:"'
Ausgabe
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

Capabilities sind die Einzelteile, in die Linux die früheren Allmachtsrechte von root zerlegt hat. cap_chown 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:

Terminal
mkdir -p /tmp/captest && echo test > /tmp/captest/datei.txt
sudo chown 1000:1000 /tmp/captest/datei.txt

Jetzt darf der Container sie sich aneignen:

Terminal
docker run --rm -v /tmp/captest:/d alpine:3 sh -c 'chown 0:0 /d/datei.txt && echo "chown OK"'
ls -ln /tmp/captest/datei.txt
Ausgabe
chown OK
-rw-r----- 1 0 0 5 Okt  4 23:24 /tmp/captest/datei.txt

Die Datei gehört jetzt root – geändert aus einem Container heraus. Nimm dieselbe Capability weg, und der Angriff läuft ins Leere:

Terminal
sudo chown 1000:1000 /tmp/captest/datei.txt
docker run --rm --cap-drop CHOWN -v /tmp/captest:/d alpine:3 sh -c 'chown 0:0 /d/datei.txt'
Ausgabe
chown: /d/datei.txt: Operation not permitted

Genau darum geht es beim Least Privilege: nicht „root abschaffen", sondern root entwaffnen. Räum den Testordner wieder weg:

Terminal
rm -rf /tmp/captest

Schritt 3: Rechteausweitung sperren mit no-new-privileges

Die billigste Härtung zuerst – eine Zeile, die praktisch nie etwas kaputt macht. no-new-privileges verbietet dem Kernel, einem Prozess nachträglich mehr Rechte zu geben, etwa über ein setuid-Binary. Der Unterschied ist direkt sichtbar:

Terminal
docker run --rm alpine:3 grep NoNewPrivs /proc/self/status
docker run --rm --security-opt no-new-privileges:true alpine:3 grep NoNewPrivs /proc/self/status
Ausgabe
NoNewPrivs:	0
NoNewPrivs:	1

Steht dort 1, kann ein Angreifer, der Codeausführung im Container erreicht hat, keine setuid-Binaries mehr als Sprungbrett benutzen. In Compose ist das:

YAML
    security_opt:
      - no-new-privileges:true

Schritt 4: Alle Capabilities entziehen – und nur das Nötigste zurückgeben

Jetzt der Kern. Die Regel lautet: erst alles wegnehmen, dann anhand echter Fehlermeldungen zurückgeben, was die Anwendung wirklich braucht. Ohne alles ist die Liste tatsächlich leer:

Terminal
docker run --rm --cap-drop ALL alpine:3 sh -c 'apk add -q --no-cache libcap && capsh --print | grep -E "^Current:"'
Ausgabe
Current: =

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:

Terminal
docker run --rm --cap-drop ALL nginx:1.31-alpine
Ausgabe
/docker-entrypoint.sh: Configuration complete; ready for start up
2026/09/09 19:38:42 [emerg] 1#1: chown("/var/cache/nginx/client_temp", 101) failed (1: Operation not permitted)
nginx: [emerg] chown("/var/cache/nginx/client_temp", 101) failed (1: Operation not permitted)

Der Einstiegspunkt des Images will das Cache-Verzeichnis dem nginx-Benutzer übereignen – dafür braucht er CHOWN. Gibst du nur die zurück, passiert etwas Lehrreiches: Der Container läuft, antwortet aber nicht.

Terminal
docker run -d --name probe --cap-drop ALL --cap-add CHOWN nginx:1.31-alpine
sleep 5 && docker exec probe ps -o pid,user,comm
Ausgabe
PID   USER     COMMAND
    1 root     nginx
   34 root     ps

Nur der Master-Prozess ist übrig (PID 34 ist das ps selbst), kein einziger Worker. Das Log verrät den Grund:

Terminal
docker logs probe 2>&1 | grep alert | tail -1
Ausgabe
2026/09/09 19:38:42 [alert] 1#1: worker process 33 exited with fatal code 2 and cannot be respawned

Der Master darf ohne SETUID/SETGID nicht in den unprivilegierten nginx-Benutzer wechseln, also sterben die Worker sofort. Mit den beiden dazu läuft es:

Terminal
docker rm -f probe
docker run -d --name probe --cap-drop ALL \
  --cap-add CHOWN --cap-add SETUID --cap-add SETGID nginx:1.31-alpine
sleep 5 && docker exec probe ps -o pid,user,comm
Ausgabe
PID   USER     COMMAND
    1 root     nginx
   30 nginx    nginx
   31 nginx    nginx
   32 nginx    nginx
   33 nginx    nginx
   34 root     ps

Master als root, Worker als nginx – 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:

Terminal
docker exec probe grep CapEff /proc/1/status
docker run --rm alpine:3 sh -c 'apk add -q --no-cache libcap && capsh --decode=00000000000000c1'
Ausgabe
CapEff:	00000000000000c1
0x00000000000000c1=cap_chown,cap_setgid,cap_setuid

Drei statt vierzehn. Vergiss den Testcontainer nicht:

Terminal
docker rm -f probe

NET_BIND_SERVICE brauchst du meistens nicht

Viele Anleitungen geben reflexhaft NET_BIND_SERVICE 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:

Terminal
docker run --rm alpine:3 cat /proc/sys/net/ipv4/ip_unprivileged_port_start
Ausgabe
0

Damit darf jeder Prozess im Container jeden Port binden. Nachgemessen: nginx läuft mit CHOWN,SETUID,SETGID auf Port 80, ganz ohne NET_BIND_SERVICE. Gib nichts zurück, was du nicht durch eine echte Fehlermeldung belegt hast.

Schritt 5: Das Dateisystem schreibgeschützt mounten

read_only: true macht das Root-Dateisystem des Containers unbeschreibbar. Damit kann Schadcode weder Binaries ablegen noch Konfigurationen manipulieren. Der Effekt ist hart:

Terminal
docker run --rm --read-only alpine:3 sh -c 'touch /tmp/x'
Ausgabe
touch: /tmp/x: Read-only file system

Fast jede Anwendung braucht aber irgendwo Schreibrechte. Die Lösung ist tmpfs: ein RAM-Dateisystem, das beim Neustart verschwindet und nichts auf der Platte hinterlässt.

Terminal
docker run --rm --read-only --tmpfs /tmp alpine:3 sh -c 'touch /tmp/x && echo "schreiben OK (nur im RAM)"'
Ausgabe
schreiben OK (nur im RAM)

Welche Pfade das sind, sagt dir wieder die Fehlermeldung. Bei nginx:

Ausgabe
nginx: [emerg] mkdir() "/var/cache/nginx/client_temp" failed (30: Read-only file system)

Drei Verzeichnisse reichen: /var/cache/nginx, /var/run und /tmp.

Schritt 6: Ganz ohne root – der Container als eigener Benutzer

Die Königsdisziplin: Wenn im Container gar kein root mehr arbeitet, werden Capabilities weitgehend gegenstandslos. Das nginx-Image bringt den Benutzer nginx mit UID/GID 101 schon mit.

Ein naiver Versuch scheitert allerdings:

Terminal
docker run --rm --user 101:101 --cap-drop ALL --read-only \
  --tmpfs /var/cache/nginx nginx:1.31-alpine
Ausgabe
nginx: [emerg] mkdir() "/var/cache/nginx/client_temp" failed (13: Permission denied)

Der Grund ist unscheinbar: Ein tmpfs gehört standardmäßig root, und UID 101 darf dort nicht schreiben. Du musst den Besitzer beim Mounten mitgeben:

Terminal
docker run -d --name probe --user 101:101 --cap-drop ALL \
  --security-opt no-new-privileges:true --read-only \
  --tmpfs /var/cache/nginx:uid=101,gid=101 \
  --tmpfs /var/run:uid=101,gid=101 \
  --tmpfs /tmp:uid=101,gid=101 nginx:1.31-alpine
sleep 5 && docker exec probe ps -o pid,user,comm
Ausgabe
PID   USER     COMMAND
    1 nginx    nginx
   21 nginx    nginx
   22 nginx    nginx
   23 nginx    nginx
   24 nginx    nginx
   25 nginx    ps

Jetzt läuft auch PID 1 als nginx – und die drei Capabilities aus Schritt 4 werden nicht mehr gebraucht, weil niemand mehr den Benutzer wechseln oder Cache-Verzeichnisse übereignen muss. Aufräumen:

Terminal
docker rm -f probe

Nicht jedes Image kann das

user: funktioniert nur, wenn das Image dafür gebaut ist – die Verzeichnisse müssen dem gewählten Benutzer gehören oder über tmpfs mit passender UID bereitgestellt werden. Bei Images, die beim Start selbst chown ausführen (viele Datenbank-Images), scheitert das. Dann bleibt es bei Schritt 4: root im Container, aber ohne Capabilities.

Schritt 7: Arbeitsspeicher und Prozesse begrenzen

Härtung heißt auch, dass ein 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:

Terminal
docker run -d --name oomtest --memory 128m alpine:3 sh -c 'tail /dev/zero'
sleep 8
docker inspect -f 'OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}}' oomtest
Ausgabe
OOMKilled=true ExitCode=137

ExitCode 137 ist die Signatur eines Speicherlimits – merk dir die Zahl, sie taucht im Alltag oft auf. Das Gegenstück gegen Fork-Bomben ist pids_limit:

Terminal
docker run --rm --pids-limit 20 alpine:3 sh -c 'for i in $(seq 1 40); do sleep 5 & done'
Ausgabe
sh: can't fork: Resource temporarily unavailable
Terminal
docker rm -f oomtest

Schritt 8: Der fertige Stack

Alles zusammengesetzt am Beispiel eines statisch ausgelieferten Webauftritts – etwa der Ausgabe aus Website mit Hugo. Leg das Projekt an:

Terminal
mkdir -p ~/webhard/site && cd ~/webhard

Erstelle compose.yaml und ersetze DEINE_DOMAIN:

YAML
services:
  web:
    image: nginx:1.31-alpine
    container_name: web-hart
    restart: unless-stopped
    user: "101:101"
    cap_drop: [ALL]
    security_opt:
      - no-new-privileges:true
    read_only: true
    tmpfs:
      - /var/cache/nginx:uid=101,gid=101
      - /var/run:uid=101,gid=101
      - /tmp:uid=101,gid=101
    mem_limit: 128m
    pids_limit: 64
    volumes:
      - ./site:/usr/share/nginx/html:ro
    networks: [proxy]
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.hart.rule=Host(`DEINE_DOMAIN`)"
      - "traefik.http.routers.hart.entrypoints=websecure"
      - "traefik.http.routers.hart.tls.certresolver=le"
      - "traefik.http.services.hart.loadbalancer.server.port=80"

networks:
  proxy:
    external: true

Die Punkte, die den Unterschied machen:

  • user: "101:101" – kein root im Container. Die Anführungszeichen sind wichtig, sonst interpretiert YAML den Wert als Zahl mit Doppelpunkt.
  • cap_drop: [ALL] ohne jedes cap_add – möglich, weil user: gesetzt ist.
  • read_only: true plus drei tmpfs mit passender UID.
  • :ro am Volume – die Website liest nginx nur, schreiben muss er dort nie.
  • mem_limit und pids_limit deckeln den Schaden eines Amoklaufs.

Weil nginx jetzt als UID 101 läuft und nicht mehr als root, muss er die Website auch lesen dürfen. Auf einem gehärteten Server mit UMASK 027 in /etc/login.defs – wie sie das Lynis-Audit empfiehlt – legen mkdir und echo als root drwxr-x--- und -rw-r----- an, und UID 101 sieht davon nichts; der Stack antwortet dann mit 403 Forbidden. Deshalb unten das chmod -R a+rX site – es macht Dateien lesbar und Verzeichnisse betretbar, ohne Dateien ausführbar zu machen, und schadet bei der Debian-Standard-umask nicht.

Leg eine Testseite ab und starte:

Terminal
echo '<h1>Härten geglückt</h1>' > site/index.html
chmod -R a+rX site
docker compose up -d
docker compose ps
Ausgabe
NAME       IMAGE               STATUS
web-hart   nginx:1.31-alpine   Up 10 seconds

Jetzt die eigentliche Kontrolle – Vertrauen ist gut, docker inspect ist besser:

Terminal
docker inspect web-hart --format 'ReadonlyRootfs={{.HostConfig.ReadonlyRootfs}}  CapDrop={{.HostConfig.CapDrop}}  SecurityOpt={{.HostConfig.SecurityOpt}}  PidsLimit={{.HostConfig.PidsLimit}}  User={{.Config.User}}'
Ausgabe
ReadonlyRootfs=true  CapDrop=[ALL]  SecurityOpt=[no-new-privileges:true]  PidsLimit=64  User=101:101

Und die Gegenprobe, dass der Schreibschutz wirklich greift:

Terminal
docker exec web-hart sh -c 'touch /etc/x'
Ausgabe
touch: /etc/x: Read-only file system

Zum Schluss von außen, über Traefik mit echtem Zertifikat:

Terminal
curl -sS https://DEINE_DOMAIN/
Ausgabe
<h1>Härten geglückt</h1>

Ein Container ohne Capabilities, ohne root, ohne Schreibrechte – der ganz normal ausliefert. Genau dieses Muster hängst du jetzt an deine übrigen Stacks.

Wenn es nicht funktioniert

Der Container startet nicht und meldet Operation not permitted. Du hast eine Capability zu viel entzogen. Die Meldung nennt fast immer den Systemaufruf – chown(...) failed heißt CHOWN, setgid heißt SETGID. Gib genau diese eine zurück (cap_add: [CHOWN]) und starte neu; arbeite dich so an das Minimum heran, statt pauschal mehrere Capabilities auf Verdacht zu setzen.

Der Container läuft laut docker compose ps, antwortet aber nicht. Up heißt nur, dass PID 1 lebt – nicht, dass die Anwendung arbeitet. Bei nginx sterben ohne SETUID/SETGID die Worker, während der Master weiterläuft. Prüfe mit docker exec CONTAINER ps -o pid,user,comm, ob mehr als ein Prozess da ist, und sieh ins Log: docker compose logs | grep -iE "alert|emerg".

Read-only file system beim Start. Die Anwendung braucht ein Schreibverzeichnis, das du noch nicht als tmpfs bereitgestellt hast. Den Pfad nennt die Fehlermeldung; trag ihn unter tmpfs: ein. Achtung: Echte Daten gehören nie ins tmpfs – das ist RAM und beim Neustart weg. Dafür weiterhin ein Volume verwenden.

Die Seite liefert 403 Forbidden, im Log steht is forbidden (13: Permission denied). nginx läuft als UID 101 und darf die Dateien im eingehängten Verzeichnis nicht lesen – typisch für Server mit UMASK 027, wo root neue Dateien ohne Leserecht für andere anlegt. Mit ls -l prüfen und mit chmod -R a+rX site beheben.

Permission denied im tmpfs, obwohl read_only passt. Ein tmpfs gehört standardmäßig root. Wenn du user: gesetzt hast, muss der Mount die UID mitbekommen: /var/cache/nginx:uid=101,gid=101. Die richtige UID findest du mit docker run --rm DEIN_IMAGE id NUTZERNAME.

Der Container wird ohne Vorwarnung beendet, docker inspect zeigt ExitCode 137. Das ist der OOM-Killer: mem_limit ist zu knapp. Prüfe den echten Bedarf im Betrieb mit docker stats --no-stream 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.

Dein Problem ist nicht dabei? Alle Fehlermeldungen durchsuchen →

Wartung & Backups

  • Härtung ist Arbeit pro Anwendung, nicht einmal für alle. Jedes Image braucht ein anderes Minimum. Plane pro Stack 15–30 Minuten für die Runde „alles wegnehmen, Log lesen, Nötiges zurückgeben" – und mach das einmal richtig, statt Werte von fremden Beispielen abzuschreiben. Fremde cap_add-Listen sind fast immer zu großzügig.
  • Nach jedem Image-Update kurz gegenprüfen. 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 Update-Prozess auf.
  • Der Audit-Einzeiler aus Schritt 1 gehört in deine Routine. Einmal im Monat über alle Container laufen lassen zeigt sofort, welcher Stack beim letzten Deploy ohne Härtung durchgerutscht ist.
  • Den Docker-Socket entschärfen. Traefik braucht ihn wirklich – aber nicht schreibend. Ein :ro am Mount ist ein erster Schritt, verhindert aber keinen Container-Start über die API. Die saubere Lösung ist ein Socket-Proxy: 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.
  • Ehrlich zum Nutzen. Diese Maßnahmen verhindern keinen Einbruch in die Anwendung. Sie sorgen dafür, dass aus „Angreifer kann in einer Webanwendung Code ausführen" nicht „Angreifer ist root auf dem Server" wird. Zusammen mit Traefik-Härtung davor und verschlüsselten Backups dahinter ergibt das eine belastbare Kette.
  • Backup: Es kommen keine neuen Daten dazu – gesichert wird wie gehabt der Volume-Inhalt. Die Härtung selbst steckt vollständig in der compose.yaml; die gehört ohnehin in deine Versionsverwaltung.

Feedback per E-Mail: feedback@serverkueche.de

Das könnte dir auch schmecken