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.
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
- Die Compose-Grundlagen – wir arbeiten
durchgehend in
compose.yaml. - Ein abgesicherter Server mit Docker; für den Demo-Teil ein
laufender Traefik-Reverse-Proxy mit dem
proxy-Netzwerk und dem Resolverle. - Grundverständnis von Benutzern und Rechten unter Linux – UID/GID tauchen hier ständig auf.
Härtung ersetzt keine Updates
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.
💶 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
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:
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:
/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:
docker ps -q | xargs docker inspect --format '{{.Name}}{{range .Mounts}} {{.Source}}{{end}}' | grep docker.sock/traefik-traefik-1 /opt/traefik/acme.json /var/run/docker.sockDer Docker-Socket ist Root auf dem Host
/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:
docker run --rm alpine:3 iduid=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:
docker run --rm alpine:3 sh -c 'apk add -q --no-cache libcap && capsh --print | grep -E "^Current:"'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=epCapabilities 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:
mkdir -p /tmp/captest && echo test > /tmp/captest/datei.txt
sudo chown 1000:1000 /tmp/captest/datei.txtJetzt darf der Container sie sich aneignen:
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.txtchown OK
-rw-r----- 1 0 0 5 Okt 4 23:24 /tmp/captest/datei.txtDie Datei gehört jetzt root – geändert aus einem Container heraus. Nimm dieselbe Capability weg, und der Angriff läuft ins Leere:
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'chown: /d/datei.txt: Operation not permittedGenau darum geht es beim Least Privilege: nicht „root abschaffen", sondern root entwaffnen. Räum den Testordner wieder weg:
rm -rf /tmp/captestSchritt 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:
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/statusNoNewPrivs: 0
NoNewPrivs: 1Steht dort 1, kann ein Angreifer, der Codeausführung im Container erreicht hat, keine
setuid-Binaries mehr als Sprungbrett benutzen. In Compose ist das:
security_opt:
- no-new-privileges:trueSchritt 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:
docker run --rm --cap-drop ALL alpine:3 sh -c 'apk add -q --no-cache libcap && capsh --print | grep -E "^Current:"'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:
docker run --rm --cap-drop ALL nginx:1.31-alpine/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.
docker run -d --name probe --cap-drop ALL --cap-add CHOWN nginx:1.31-alpine
sleep 5 && docker exec probe ps -o pid,user,commPID USER COMMAND
1 root nginx
34 root psNur der Master-Prozess ist übrig (PID 34 ist das ps selbst), kein einziger Worker. Das Log verrät den Grund:
docker logs probe 2>&1 | grep alert | tail -12026/09/09 19:38:42 [alert] 1#1: worker process 33 exited with fatal code 2 and cannot be respawnedDer Master darf ohne SETUID/SETGID nicht in den unprivilegierten nginx-Benutzer
wechseln, also sterben die Worker sofort. Mit den beiden dazu läuft es:
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,commPID USER COMMAND
1 root nginx
30 nginx nginx
31 nginx nginx
32 nginx nginx
33 nginx nginx
34 root psMaster 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:
docker exec probe grep CapEff /proc/1/status
docker run --rm alpine:3 sh -c 'apk add -q --no-cache libcap && capsh --decode=00000000000000c1'CapEff: 00000000000000c1
0x00000000000000c1=cap_chown,cap_setgid,cap_setuidDrei statt vierzehn. Vergiss den Testcontainer nicht:
docker rm -f probeNET_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:
docker run --rm alpine:3 cat /proc/sys/net/ipv4/ip_unprivileged_port_start0Damit 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:
docker run --rm --read-only alpine:3 sh -c 'touch /tmp/x'touch: /tmp/x: Read-only file systemFast 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.
docker run --rm --read-only --tmpfs /tmp alpine:3 sh -c 'touch /tmp/x && echo "schreiben OK (nur im RAM)"'schreiben OK (nur im RAM)Welche Pfade das sind, sagt dir wieder die Fehlermeldung. Bei nginx:
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:
docker run --rm --user 101:101 --cap-drop ALL --read-only \
--tmpfs /var/cache/nginx nginx:1.31-alpinenginx: [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:
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,commPID USER COMMAND
1 nginx nginx
21 nginx nginx
22 nginx nginx
23 nginx nginx
24 nginx nginx
25 nginx psJetzt 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:
docker rm -f probeNicht 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:
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}}' oomtestOOMKilled=true ExitCode=137ExitCode 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:
docker run --rm --pids-limit 20 alpine:3 sh -c 'for i in $(seq 1 40); do sleep 5 & done'sh: can't fork: Resource temporarily unavailabledocker rm -f oomtestSchritt 8: Der fertige Stack
Alles zusammengesetzt am Beispiel eines statisch ausgelieferten Webauftritts – etwa der Ausgabe aus Website mit Hugo. Leg das Projekt an:
mkdir -p ~/webhard/site && cd ~/webhardErstelle compose.yaml und ersetze DEINE_DOMAIN:
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: trueDie 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 jedescap_add– möglich, weiluser:gesetzt ist.read_only: trueplus dreitmpfsmit passender UID.:roam Volume – die Website liest nginx nur, schreiben muss er dort nie.mem_limitundpids_limitdeckeln 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:
echo '<h1>Härten geglückt</h1>' > site/index.html
chmod -R a+rX site
docker compose up -d
docker compose psNAME IMAGE STATUS
web-hart nginx:1.31-alpine Up 10 secondsJetzt die eigentliche Kontrolle – Vertrauen ist gut, docker inspect ist besser:
docker inspect web-hart --format 'ReadonlyRootfs={{.HostConfig.ReadonlyRootfs}} CapDrop={{.HostConfig.CapDrop}} SecurityOpt={{.HostConfig.SecurityOpt}} PidsLimit={{.HostConfig.PidsLimit}} User={{.Config.User}}'ReadonlyRootfs=true CapDrop=[ALL] SecurityOpt=[no-new-privileges:true] PidsLimit=64 User=101:101Und die Gegenprobe, dass der Schreibschutz wirklich greift:
docker exec web-hart sh -c 'touch /etc/x'touch: /etc/x: Read-only file systemZum Schluss von außen, über Traefik mit echtem Zertifikat:
curl -sS https://DEINE_DOMAIN/<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
:roam 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


