Docker Compose absichern: Secrets, Healthchecks, Non-Root und Read-Only für den Produktivbetrieb
Aus dem Bastel-Stack wird ein produktionsreifes Setup: Secrets per Datei statt Umgebungsvariable, echte Healthchecks mit pg_isready und curl, Non-Root-User, Read-Only-Filesystem mit tmpfs-Ausnahmen sowie cap_drop ALL und no-new-privileges gegen Privilege-Escalation.

Ein funktionierender docker-compose.yml aus der Entwicklung ist kein produktionsreifes Setup. Passwörter stehen im Klartext in Umgebungsvariablen, depends_on wartet nicht wirklich auf Datenbankbereitschaft, der Container läuft als Root, und das Dateisystem ist beschreibbar. Dieses HowTo zeigt dir Schritt für Schritt, wie du jeden dieser Schwachpunkte behebst – mit einem vollständigen Vorher/Nachher-Template und konkreten Diagnose-Befehlen.
Voraussetzungen
- Docker Engine >= 20.10 mit dem
docker composePlugin (v2) – Befehl ohne Bindestrich:docker compose - Compose-Datei im Format
compose.yamloderdocker-compose.yml(v3.x-Syntax) - Verzeichnis
./secrets/auf dem Host, Secret-Dateien mitchmod 600 - Applikations-Image mit definiertem Non-Root-User (eigenes Dockerfile oder offizielles Image)
- Für
pg_isready-Healthchecks: offiziellespostgres-Image (Tool ist enthalten) - Für
curl-Healthchecks in Alpine-Images:RUN apk add --no-cache curlim Dockerfile - Geschätzte Zeit: ca. 60 Minuten für einen bestehenden Dev-Stack
Schritt 1: Secrets aus Umgebungsvariablen herauslösen
Umgebungsvariablen sind das offensichtlichste Sicherheitsproblem: docker inspect <container> gibt alle Werte unter Config.Env im Klartext aus – jeder mit Zugriff auf den Docker-Socket liest sämtliche Passwörter. Die Lösung ist die secrets-Sektion in Compose.
Zuerst das Verzeichnis anlegen und die Secret-Dateien erstellen. Wichtig: kein abschließendes Newline im Passwort, sonst landet es mit im Wert:
mkdir -p ./secrets
printf 'meinSicheresPasswort' > ./secrets/db_password.txt
printf 'meinApiKey42' > ./secrets/api_key.txt
chmod 600 ./secrets/db_password.txt ./secrets/api_key.txt
In der Compose-Datei werden die Secrets auf oberster Ebene deklariert und dann den Services zugewiesen. Offizielle Images wie postgres unterstützen die _FILE-Konvention: Die Variable POSTGRES_PASSWORD_FILE liest den Wert aus der Datei statt aus einer direkten Umgebungsvariablen:
secrets:
db_password:
file: ./secrets/db_password.txt
api_key:
file: ./secrets/api_key.txt
services:
db:
image: postgres:16
secrets:
- db_password
environment:
POSTGRES_USER: appuser
POSTGRES_DB: appdb
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
# KEIN POSTGRES_PASSWORD als Klartext!
Das Secret wird als Read-Only-Datei unter /run/secrets/db_password in den Container gemountet und erscheint nicht in docker inspect. Zum Verifizieren:
# Sollte kein Passwort ausgeben:
docker inspect <container_name> | grep -i password
# Secret im Container lesbar (nur fuer Tests!):
docker exec <container_name> cat /run/secrets/db_password
Schritt 2: Echte Healthchecks einrichten
Ein simples depends_on: - db wartet nur darauf, dass der Container den Status „running" erreicht – PostgreSQL braucht aber mehrere Sekunden nach dem Start, bis es Verbindungen akzeptiert. Das führt zu Race-Conditions beim App-Start. Echte Healthchecks lösen das Problem.
PostgreSQL mit pg_isready (im offiziellen Image enthalten):
db:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
HTTP-Dienst mit curl -f (Exit-Code 1 bei HTTP 4xx/5xx):
api:
image: myapi:latest
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"]
interval: 15s
timeout: 5s
retries: 3
start_period: 30s
depends_on:
db:
condition: service_healthy # Wartet auf bestandenen Healthcheck!
Alpine-Images ohne curl – wget --spider ist meist vorhanden:
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1"]
interval: 15s
timeout: 5s
retries: 3
start_period: 40s
Wichtig zu verstehen: Während start_period läuft, zählen fehlgeschlagene Checks nicht als Retry. Erst ein erfolgreicher Check beendet die Grace-Period vorzeitig. Den aktuellen Health-Status prüfst du so:
docker compose ps
docker inspect --format='{{json .State.Health}}' <container_name> | python3 -m json.tool
Schritt 3: Non-Root-User und Read-Only-Filesystem
Container laufen standardmäßig als Root – ein Exploit, der aus dem Container ausbricht, hat damit sofort Root-Rechte auf dem Host. user: in Compose überschreibt den Image-Default. Für das offizielle postgres-Image ist UID/GID 999 korrekt:
db:
image: postgres:16
user: "999:999"
Den laufenden User im Container prüfen:
docker exec <container_name> id
docker exec <container_name> ps aux
read_only: true setzt das Root-Filesystem auf Nur-Lesen. Das bricht viele Images zunächst, weil sie beim Start in /tmp, /var/run oder applikationsspezifische Verzeichnisse schreiben. Die Lösung: tmpfs-Einträge für genau diese Pfade. Für den API-Service sieht das so aus:
api:
image: myapi:latest
user: "1000:1000"
read_only: true
tmpfs:
- /tmp:size=32M,mode=1777
- /var/run:uid=1000,gid=1000,mode=755
Für PostgreSQL braucht der Socket-Pfad eine eigene tmpfs-Ausnahme:
db:
image: postgres:16
user: "999:999"
read_only: true
tmpfs:
- /tmp:size=64M,mode=1777
- /var/run/postgresql:uid=999,gid=999,mode=755
Das mode=1777 setzt das Sticky-Bit, sodass mehrere Prozesse in /tmp schreiben dürfen. Ohne uid/gid-Option gehört der tmpfs-Mount Root und ist für den Non-Root-Prozess nicht beschreibbar.
Schritt 4: Capabilities einschränken und Privilege-Escalation verhindern
Standardmäßig starten Container mit etwa 14 Linux-Capabilities (darunter CHOWN, NET_BIND_SERVICE, SETUID). cap_drop: [ALL] entfernt alle auf einmal; danach nur gezielt benötigte zurückfügen. no-new-privileges:true verhindert zusätzlich, dass Prozesse über setuid/setgid-Binärdateien neue Rechte erwerben:
api:
image: myapi:latest
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add: [] # Leer lassen, ausser etwas wird konkret benoetigt
# Falls Port < 1024 benoetigt:
# cap_add:
# - NET_BIND_SERVICE
PostgreSQL benötigt nach dem Drop einige Capabilities zurück, da es intern User wechselt:
db:
image: postgres:16
cap_drop:
- ALL
cap_add:
- CHOWN
- FOWNER
- SETUID
- SETGID
Auch OWASP listet no-new-privileges als Pflichtmaßnahme im Docker Security Cheat Sheet. Ports sollten in Produktion nur auf Loopback gebunden werden, um den Dienst nicht auf allen Interfaces zu exponieren:
ports:
- "127.0.0.1:8080:8080" # Nur localhost, kein 0.0.0.0!
Schritt 5: Ressourcenlimits und Restart-Policies
Ohne Limits kann ein einzelner Container bei einem Memory-Leak oder einer Fork-Bomb den gesamten Host destabilisieren. Ressourcenlimits gehören unter deploy.resources.limits – die veralteten Top-Level-Felder mem_limit/cpu_shares funktionieren mit dem modernen docker compose Plugin nicht mehr zuverlässig. Das pids-Limit verhindert Fork-Bomben:
api:
image: myapi:latest
restart: unless-stopped
deploy:
resources:
limits:
cpus: '0.5'
memory: 256M
pids: 100
reservations:
cpus: '0.25'
memory: 128M
| Restart-Policy | Verhalten | Empfehlung |
|---|---|---|
no | Kein automatischer Neustart | Nur für Einmalaufgaben |
always | Immer neu starten, auch nach manuellem Stop und Host-Reboot | Monitoring-Sidecar |
unless-stopped | Neustart außer nach manuellem docker stop | Produktionsdienste |
on-failure:3 | Nur bei Fehler-Exit, max. 3 Versuche | Einmalige Migrations-Jobs |
Erreicht ein Container sein Memory-Limit, löst der Linux-Kernel einen OOM-Kill aus – der Container endet mit Exit-Code 137. Mit unless-stopped startet er automatisch neu, sichtbar in docker events.
Schritt 6: Vollständiges Vorher/Nachher-Template
Der folgende Block zeigt den unsicheren Dev-Stack und das gehärtete Produktions-Setup direkt im Vergleich:
# ============================================================
# VORHER (unsicher – typischer Dev-Stack)
# ============================================================
version: '3'
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: geheim123 # Sichtbar in docker inspect!
ports:
- "5432:5432" # Oeffentlich erreichbar!
api:
image: myapi:latest
environment:
DB_PASSWORD: geheim123
depends_on:
- db # Kein Healthcheck-Wait!
# ============================================================
# NACHHER (produktionsreif + gehaertet)
# ============================================================
services:
db:
image: postgres:16
user: "999:999"
read_only: true
tmpfs:
- /tmp:size=64M,mode=1777
- /var/run/postgresql:uid=999,gid=999,mode=755
secrets:
- db_password
environment:
POSTGRES_USER: appuser
POSTGRES_DB: appdb
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
- FOWNER
- SETUID
- SETGID
restart: unless-stopped
deploy:
resources:
limits:
cpus: '1.0'
memory: 512M
pids: 200
networks:
- backend
# KEIN ports-Mapping nach aussen!
api:
image: myapi:latest
user: "1000:1000"
read_only: true
tmpfs:
- /tmp:size=32M,mode=1777
secrets:
- api_key
- db_password
environment:
DB_PASSWORD_FILE: /run/secrets/db_password
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1"]
interval: 15s
timeout: 5s
retries: 3
start_period: 40s
depends_on:
db:
condition: service_healthy
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
restart: unless-stopped
deploy:
resources:
limits:
cpus: '0.5'
memory: 256M
pids: 100
ports:
- "127.0.0.1:8080:8080"
networks:
- backend
- frontend
secrets:
db_password:
file: ./secrets/db_password.txt
api_key:
file: ./secrets/api_key.txt
networks:
backend:
frontend:
Troubleshooting / Typische Fehler
- Container startet nicht nach
read_only: true:docker logs <container>zeigt „Read-only file system" oder „Permission denied". Diagnose: Zuerst ohneread_onlystarten und mitstraceoderinotifywaitdie schreibenden Pfade ermitteln. Dann für genau diese Pfadetmpfs-Einträge anlegen. Häufige Kandidaten: PHP/WordPress (/tmp,/var/run), Keycloak (/opt/keycloak/data/tmp), Java-Apps mit Temp-Verzeichnis. - tmpfs nicht beschreibbar für Non-Root-User: Ohne
uid/gid-Option gehört der tmpfs-Mount Root (755). Lösung:- /tmp:size=64M,mode=1777(Sticky-Bit erlaubt allen das Schreiben) oderuid=1000,gid=1000explizit angeben. depends_onreicht nicht, App startet trotzdem zu früh: Simplesdepends_onohneconditionwartet nur auf Container-Status „running". Healthcheck am Ziel-Service korrekt konfigurieren undcondition: service_healthysetzen.- Healthcheck schlägt fehl, weil Tool fehlt: Alpine-Images enthalten kein
curl. Lösung:wget --spiderverwenden (meist vorhanden), oderRUN apk add --no-cache curlim Dockerfile, oder nativen Prüfbefehl (pg_isready,redis-cli PING) nutzen. cap_drop: ALLbricht PostgreSQL: Postgres wechselt intern den User und benötigtCHOWN,FOWNER,SETUID,SETGID. Diese vier mitcap_addzurückfügen.deploy.resources.limitswird ignoriert: Das veraltetedocker-compose(Python-Binary, mit Bindestrich) ignoriert diedeploy-Sektion. Immerdocker compose(Plugin, ohne Bindestrich) verwenden.- Secret in CI/CD leer: Bei
secrets: environment:muss die Variable auf dem Host gesetzt sein. In Produktion immerfile:mitchmod 600bevorzugen.
Häufige Fragen
Warum sind Secrets sicherer als Umgebungsvariablen, wenn der Container-User die Datei doch lesen kann?
Der entscheidende Unterschied ist die externe Sichtbarkeit: Umgebungsvariablen erscheinen in docker inspect, in Prozesslisten (/proc/*/environ) und häufig in Crash-Dumps oder Logs. Secrets als Dateien sind nur im Container lesbar und tauchen nicht in der Docker-Daemon-Metadaten-API auf. Zusätzlich lassen sich Dateiberechtigungen feingranular steuern.
Was ist der Unterschied zwischen restart: always und restart: unless-stopped?
always startet den Container immer neu – auch nach einem manuellen docker stop und nach einem Host-Neustart. unless-stopped respektiert einen manuellen Stop: Wird der Container explizit angehalten, bleibt er nach dem nächsten Host-Neustart gestoppt. Für Produktionsdienste ist unless-stopped meistens sinnvoller, weil du kontrollierten Wartungsfenstern damit nicht entgegenwirkst.
Muss ich read_only und cap_drop ALL gleichzeitig einsetzen, oder reicht eine Maßnahme?
Beide adressieren unterschiedliche Angriffsvektoren: read_only verhindert, dass Malware oder Exploits Dateien auf dem Container-Filesystem persistieren. cap_drop ALL begrenzt, welche privilegierten Syscalls ein Prozess aufrufen darf. Sie ergänzen sich und sollten beide gesetzt werden.
Wie finde ich heraus, welche tmpfs-Pfade ein Image braucht?
Zuerst den Container ohne read_only starten und im normalen Betrieb laufen lassen. Dann read_only: true aktivieren, starten, und docker logs <container> beobachten – alle „Read-only file system"-Fehler zeigen genau die Pfade. Alternativ: strace oder inotifywait im Container während des Starts.
Kann ich einen Healthcheck deaktivieren, der bereits im Image definiert ist?
Ja, mit healthcheck: disable: true im Compose-Service. Das deaktiviert auch im Dockerfile definierte HEALTHCHECK-Direktiven. Sinnvoll, wenn du einen eigenen, präzisen Healthcheck definieren willst statt des generischen Image-Defaults.
Was passiert, wenn ein Container sein Memory-Limit erreicht?
Der Linux-Kernel löst einen OOM-Kill aus, der Container endet mit Exit-Code 137. Mit restart: unless-stopped oder on-failure startet er automatisch neu. Das Ereignis ist sichtbar in docker events und im Healthcheck-Status-Verlauf.
Fazit
Ein produktionsreifer Docker-Compose-Stack ist kein großer Aufwand – aber er erfordert bewusste Entscheidungen bei jeder Konfigurationszeile. Secrets als Dateien statt Umgebungsvariablen, condition: service_healthy statt blindem depends_on, user: plus read_only plus cap_drop ALL plus no-new-privileges: Jede dieser Maßnahmen schließt einen konkreten Angriffsvektor. Das Template aus Schritt 6 kannst du direkt als Ausgangspunkt verwenden und auf deinen Stack anpassen. Teste read_only: true immer zuerst in einer Staging-Umgebung – es ist die Maßnahme, die am häufigsten Images unvorhergesehen bricht.
Für einen vollständig gehärteten Docker-Host empfiehlt sich außerdem ein regelmäßiger Image-Scan sowie ein Reverse Proxy mit TLS davor – wie in der Anleitung zu Traefik als Docker Reverse Proxy mit HTTPS beschrieben. Wer Container-Metriken im Blick behalten möchte, findet in der Anleitung zu cAdvisor und Prometheus für Docker-Container-Metriken den passenden Einstieg. Die Netzwerkgrundlagen sind in Docker-Netzwerke und Volumes verstehen erklärt.
Weiterführende Anleitungen und Quellen
- Docker Compose Grundlagen: Stacks aufbauen und verwalten
- Traefik als Docker Reverse Proxy mit HTTPS einrichten
- Docker-Netzwerke und Volumes verstehen
- Linux-Server absichern mit UFW und Fail2ban
Quellen: Docker Docs: Manage secrets securely in Docker Compose | Docker Docs: Compose File Reference – services | OWASP Docker Security Cheat Sheet | Last9: Docker Compose Health Checks Guide