Docker Compose absichern: Secrets, Healthchecks, Non-Root und Read-Only für den Produktivbetrieb
Docker-Compose-Stacks gehärtet betreiben: Secrets als Dateien, Healthchecks mit service_healthy, Non-Root, Read-Only, cap_drop und Ressourcenlimits, mit getestetem Vorher/Nachher-Template.
Geprüft am 01.10.2026 · für postgres 18.6
WerbelinksMit * markierte Links sind Werbelinks: Bei einem Kauf erhalten wir eine Provision, der Preis bleibt gleich. Als Amazon-Partner verdiene ich an qualifizierten Verkäufen. Mehr dazu

Eine funktionierende docker-compose.yml aus der Entwicklung ist noch nicht produktionsreif: Passwörter im Klartext in Umgebungsvariablen, depends_on ohne echte Bereitschaftsprüfung, Root im Container, beschreibbares Dateisystem. Diese Anleitung behebt jeden Punkt, mit Vorher/Nachher-Template und Prüfbefehlen. Alle Beispiele sind mit Docker Engine 29.1 und postgres:18 getestet.
Voraussetzungen
- Docker Engine 24 oder neuer mit Docker Compose v2 (Befehl
docker composeohne Bindestrich) - Compose-Datei
compose.yamloderdocker-compose.ymlnach der aktuellen Compose Specification (ohneversion:-Zeile) - Verzeichnis
./secrets/auf dem Host mitchmod 700 - 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 - Für den Test des Templates: 1 CPU-Kern und 1 GB freier RAM. Geschätzte Zeit für einen bestehenden Stack: etwa 60 Minuten
Schritt 1: Secrets aus Umgebungsvariablen herauslösen
docker inspect <container> gibt alle Umgebungsvariablen unter Config.Env im Klartext aus; jeder mit Zugriff auf den Docker-Socket liest sämtliche Passwörter. Die Lösung ist der Abschnitt secrets in Compose.
Legen Sie das Verzeichnis und die Secret-Dateien an. printf schreibt ohne abschließenden Zeilenumbruch, der sonst Teil des Passworts würde:
mkdir -p ./secrets
chmod 700 ./secrets
printf 'meinSicheresPasswort' > ./secrets/db_password.txt
printf 'meinApiKey42' > ./secrets/api_key.txt
chmod 644 ./secrets/db_password.txt ./secrets/api_key.txtCompose hängt Datei-Secrets ohne Swarm als Bind-Mount mit Rechten und Besitzer der Host-Datei ein. Mit chmod 600 kann der Postgres-Prozess (UID 999) die Datei nicht lesen, das Entrypoint-Skript bricht mit /run/secrets/db_password: Permission denied ab. Den Schutz übernimmt deshalb das Verzeichnis mit chmod 700. Alternativ setzen Sie den Besitzer auf die Container-UID (sudo chown 999:999) und behalten chmod 600.
Deklarieren Sie die Secrets auf oberster Ebene und weisen Sie sie den Services zu. Offizielle Images wie postgres unterstützen die _FILE-Konvention: POSTGRES_PASSWORD_FILE liest den Wert aus der Datei:
secrets:
db_password:
file: ./secrets/db_password.txt
api_key:
file: ./secrets/api_key.txt
services:
db:
image: postgres:18
secrets:
- db_password
environment:
POSTGRES_USER: appuser
POSTGRES_DB: appdb
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
# KEIN POSTGRES_PASSWORD als Klartext!
Verifizieren: Das Secret liegt schreibgeschützt unter /run/secrets/db_password. docker inspect zeigt nur POSTGRES_PASSWORD_FILE=/run/secrets/db_password, nicht das Passwort:
# Darf nur POSTGRES_PASSWORD_FILE zeigen, kein Passwort:
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
depends_on: - db wartet nur, bis der Container läuft. PostgreSQL nimmt aber erst einige Sekunden später Verbindungen an, die App startet zu früh. Healthchecks lösen das.
PostgreSQL mit pg_isready (im offiziellen Image enthalten):
db:
image: postgres:18
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 aus BusyBox ist enthalten:
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
Während start_period zählen fehlgeschlagene Checks nicht als Fehlversuch, ein erfolgreicher Check beendet die Startphase vorzeitig.
Verifizieren: docker compose ps zeigt bei db nach wenigen Sekunden (healthy), erst danach startet api. Die letzte Prüfung samt Ausgabe zeigt docker inspect:
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 Ausbruch aus dem Container hat dann Root-Rechte auf dem Host. user: überschreibt den Benutzer aus dem Image. Für das Debian-basierte postgres-Image ist UID/GID 999 korrekt (bei postgres:18-alpine 70):
db:
image: postgres:18
user: "999:999"
Verifizieren: id zeigt uid=999(postgres), ps aux den Benutzer postgres für PID 1:
docker exec <container_name> id
docker exec <container_name> ps aux
read_only: true macht das Root-Dateisystem schreibgeschützt. Viele Images schreiben beim Start in /tmp, /var/run oder eigene Verzeichnisse und brechen dann ab; dafür gibt es tmpfs-Einträge:
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. Die Daten liegen ohnehin in einem Volume, das trotz read_only beschreibbar bleibt:
db:
image: postgres:18
user: "999:999"
read_only: true
tmpfs:
- /tmp:size=64M,mode=1777
- /var/run/postgresql:uid=999,gid=999,mode=755
volumes:
- db-data:/var/lib/postgresql
Ab PostgreSQL 18 gehört das Volume auf /var/lib/postgresql statt /var/lib/postgresql/data; das Image legt darunter ein versioniertes Unterverzeichnis an. mode=1777 setzt das Sticky-Bit, damit 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.
Verifizieren: docker exec <container_name> touch /x endet mit Read-only file system, touch /tmp/x funktioniert.
Schritt 4: Capabilities einschränken und Privilege-Escalation verhindern
Container starten mit 14 Linux-Capabilities (darunter CHOWN, NET_BIND_SERVICE, SETUID). cap_drop: [ALL] entfernt alle, benötigte fügen Sie gezielt wieder hinzu. no-new-privileges:true verhindert neue Rechte über setuid-/setgid-Programme:
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
Startet PostgreSQL wie im offiziellen Image als root und wechselt dann selbst zu postgres, braucht es vier Capabilities zurück. Mit user: "999:999" aus Schritt 3 entfällt der Benutzerwechsel, im Test lief PostgreSQL dann auch ganz ohne cap_add:
db:
image: postgres:18
cap_drop:
- ALL
cap_add: # nur ohne user: "999:999" noetig
- CHOWN
- FOWNER
- SETUID
- SETGID
OWASP nennt no-new-privileges im Docker Security Cheat Sheet als Grundmaßnahme. Sitzt ein Reverse Proxy davor, binden Sie Ports an Loopback:
ports:
- "127.0.0.1:8080:8080" # Nur localhost, kein 0.0.0.0!Verifizieren: docker exec <container_name> grep -E 'CapEff|NoNewPrivs' /proc/1/status zeigt CapEff: 0000000000000000 und NoNewPrivs: 1. docker port <container_name> zeigt 8080/tcp -> 127.0.0.1:8080.
Schritt 5: Ressourcenlimits und Restart-Policies
Ohne Limits legt ein Container mit Speicherleck oder Fork-Bombe den ganzen Host lahm. Compose v2 setzt deploy.resources.limits auch ohne Swarm um. Die älteren Felder mem_limit und cpus funktionieren ebenfalls, sollten aber nicht mit deploy gemischt werden. pids begrenzt die Prozesszahl:
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, nach manuellem Stop spätestens beim Neustart des Docker-Dienstes | 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.
Verifizieren: docker inspect --format '{{.HostConfig.Memory}} {{.HostConfig.NanoCpus}} {{.HostConfig.PidsLimit}}' <container_name> zeigt für den API-Service 268435456 500000000 100.
Schritt 6: Vollständiges Vorher/Nachher-Template
Unsicherer Entwicklungs-Stack und gehärtete Fassung im Vergleich. myapi:latest steht für Ihr eigenes Image. Das Feld version: ist in Compose v2 veraltet und erzeugt nur eine Warnung:
# ============================================================
# VORHER (unsicher – typischer Dev-Stack)
# ============================================================
version: '3'
services:
db:
image: postgres:18
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:18
user: "999:999"
read_only: true
tmpfs:
- /tmp:size=64M,mode=1777
- /var/run/postgresql:uid=999,gid=999,mode=755
volumes:
- db-data:/var/lib/postgresql
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
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
volumes:
db-data:
networks:
backend:
frontend:Verifizieren: docker compose up -d, dann zeigt docker compose ps beide Dienste mit (healthy). docker compose exec db psql -U appuser -d appdb -tAc 'select 1' gibt 1 aus. Im Test lief das Template mit postgres:18 (18.6) und einem Alpine-Container als Platzhalter für myapi.
Troubleshooting / Typische Fehler
- PostgreSQL 18 beendet sich sofort mit „Error: in 18+, these Docker images are configured to store database data in a …“: Das Volume hängt noch auf
/var/lib/postgresql/data, richtig ist/var/lib/postgresql. Bestandsdaten aus PostgreSQL 16 übernimmt das 18er-Image nicht: perpg_dumpallin ein neues Volume umziehen oder beipostgres:16bleiben. - Container startet nicht nach
read_only: true:docker logs <container>zeigt „Read-only file system“ oder „Permission denied“. Pfade ermitteln wie unter Häufige Fragen beschrieben und alstmpfseintragen. 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:
mode=1777oderuid=1000,gid=1000angeben (Schritt 3). - App startet zu früh trotz
depends_on: Ohnecondition: service_healthywartet Compose nur auf den Start des Containers (Schritt 2). - Healthcheck schlägt fehl, weil Tool fehlt: Alpine-Images enthalten kein
curl, distroless- und scratch-Images keine Shell. Lösung:wget --spider,curlim Dockerfile nachinstallieren oder ein eigener Prüfbefehl (pg_isready,redis-cli PING).docker inspect --format='{{json .State.Health}}'zeigt die Fehlermeldung. - „/run/secrets/…: Permission denied“: Secret-Datei mit
chmod 600gehört nicht dem Container-Prozess. Lösung siehe Schritt 1. cap_drop: ALLbricht PostgreSQL:user: "999:999"setzen oderCHOWN,FOWNER,SETUID,SETGIDpercap_addzurückgeben (Schritt 4).deploy.resources.limitswird ignoriert: Das altedocker-compose1.x (mit Bindestrich) setztdeployohne--compatibilitynicht um. Verwenden Siedocker composev2.- Secret in CI/CD leer: Bei
secrets: … environment:muss die Variable auf dem Host gesetzt sein. In Produktion istfile:in einem geschützten Ordner robuster.
Häufige Fragen
Warum sind Secrets sicherer als Umgebungsvariablen, wenn der Container-User die Datei doch lesen kann?
Umgebungsvariablen erscheinen in docker inspect, in /proc/*/environ und oft in Crash-Dumps oder Logs. Datei-Secrets tauchen in der Docker-API nicht auf, ihre Rechte lassen sich gezielt steuern.
Was ist der Unterschied zwischen restart: always und restart: unless-stopped?
Nach einem manuellen docker stop bleiben beide zunächst gestoppt. Beim nächsten Neustart des Docker-Dienstes oder Hosts startet always den Container wieder, unless-stopped lässt ihn gestoppt und unterläuft so keine geplante Wartung.
Muss ich read_only und cap_drop ALL gleichzeitig einsetzen, oder reicht eine Maßnahme?
Ja: read_only verhindert, dass Schadcode Dateien ablegt, cap_drop ALL begrenzt privilegierte Systemaufrufe.
Wie finde ich heraus, welche tmpfs-Pfade ein Image braucht?
Starten Sie den Container ohne read_only und lassen Sie ihn normal arbeiten. docker diff <container> listet alle geänderten Pfade (A = neu, C = geändert). Danach mit read_only: true starten und docker logs auf „Read-only file system“ prüfen.
Kann ich einen Healthcheck deaktivieren, der bereits im Image definiert ist?
Ja, mit healthcheck: disable: true; das deaktiviert auch HEALTHCHECK aus dem Dockerfile. Zum Ersetzen genügt ein eigener healthcheck:-Block.
Was passiert, wenn ein Container sein Memory-Limit erreicht?
OOM-Kill mit Exit-Code 137 und Neustart je nach Restart-Policy (Schritt 5). docker inspect meldet "OOMKilled": true.
Fazit
Ein gehärteter Compose-Stack kostet wenig Aufwand: Secrets als Dateien, condition: service_healthy, user:, read_only, cap_drop ALL und no-new-privileges. Das Template aus Schritt 6 dient als Ausgangspunkt. Testen Sie read_only: true zuerst in einer Testumgebung, es bricht Images am häufigsten.
Dazu gehören regelmäßige Image-Scans und ein Reverse Proxy mit TLS, siehe Traefik als Docker Reverse Proxy mit HTTPS. Metriken: cAdvisor und Prometheus für Docker-Container-Metriken. Netzwerkgrundlagen: Docker-Netzwerke und Volumes verstehen.
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 | Docker Docs: Compose Deploy Specification


