Zum Hauptinhalt springen
S-EDV news
← Alle Anleitungen
📘 Anleitung Docker 04.06.2026 · 10 min Lesezeit

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.

Gehärteter Server-Rack mit gesicherten Verbindungen in blau-grünem Rechenzentrum-Licht

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 compose Plugin (v2) – Befehl ohne Bindestrich: docker compose
  • Compose-Datei im Format compose.yaml oder docker-compose.yml (v3.x-Syntax)
  • Verzeichnis ./secrets/ auf dem Host, Secret-Dateien mit chmod 600
  • Applikations-Image mit definiertem Non-Root-User (eigenes Dockerfile oder offizielles Image)
  • Für pg_isready-Healthchecks: offizielles postgres-Image (Tool ist enthalten)
  • Für curl-Healthchecks in Alpine-Images: RUN apk add --no-cache curl im 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 curlwget --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-PolicyVerhaltenEmpfehlung
noKein automatischer NeustartNur für Einmalaufgaben
alwaysImmer neu starten, auch nach manuellem Stop und Host-RebootMonitoring-Sidecar
unless-stoppedNeustart außer nach manuellem docker stopProduktionsdienste
on-failure:3Nur bei Fehler-Exit, max. 3 VersucheEinmalige 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 ohne read_only starten und mit strace oder inotifywait die schreibenden Pfade ermitteln. Dann für genau diese Pfade tmpfs-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) oder uid=1000,gid=1000 explizit angeben.
  • depends_on reicht nicht, App startet trotzdem zu früh: Simples depends_on ohne condition wartet nur auf Container-Status „running". Healthcheck am Ziel-Service korrekt konfigurieren und condition: service_healthy setzen.
  • Healthcheck schlägt fehl, weil Tool fehlt: Alpine-Images enthalten kein curl. Lösung: wget --spider verwenden (meist vorhanden), oder RUN apk add --no-cache curl im Dockerfile, oder nativen Prüfbefehl (pg_isready, redis-cli PING) nutzen.
  • cap_drop: ALL bricht PostgreSQL: Postgres wechselt intern den User und benötigt CHOWN, FOWNER, SETUID, SETGID. Diese vier mit cap_add zurückfügen.
  • deploy.resources.limits wird ignoriert: Das veraltete docker-compose (Python-Binary, mit Bindestrich) ignoriert die deploy-Sektion. Immer docker compose (Plugin, ohne Bindestrich) verwenden.
  • Secret in CI/CD leer: Bei secrets: environment: muss die Variable auf dem Host gesetzt sein. In Produktion immer file: mit chmod 600 bevorzugen.

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

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