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

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

Verschlossener Serverschrank mit Schlüssel im Schloss, dahinter Server mit grünen Status-LEDs

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 compose ohne Bindestrich)
  • Compose-Datei compose.yaml oder docker-compose.yml nach der aktuellen Compose Specification (ohne version:-Zeile)
  • Verzeichnis ./secrets/ auf dem Host mit chmod 700
  • 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
  • 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.txt

Compose 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-PolicyVerhaltenEmpfehlung
noKein automatischer NeustartNur für Einmalaufgaben
alwaysImmer neu starten, nach manuellem Stop spätestens beim Neustart des Docker-DienstesMonitoring-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.

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: per pg_dumpall in ein neues Volume umziehen oder bei postgres:16 bleiben.
  • 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 als tmpfs eintragen. 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=1777 oder uid=1000,gid=1000 angeben (Schritt 3).
  • App startet zu früh trotz depends_on: Ohne condition: service_healthy wartet 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, curl im 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 600 gehört nicht dem Container-Prozess. Lösung siehe Schritt 1.
  • cap_drop: ALL bricht PostgreSQL: user: "999:999" setzen oder CHOWN, FOWNER, SETUID, SETGID per cap_add zurückgeben (Schritt 4).
  • deploy.resources.limits wird ignoriert: Das alte docker-compose 1.x (mit Bindestrich) setzt deploy ohne --compatibility nicht um. Verwenden Sie docker compose v2.
  • Secret in CI/CD leer: Bei secrets: … environment: muss die Variable auf dem Host gesetzt sein. In Produktion ist file: 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

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