Zum Inhalt

Docker-Compose-Container härten: Dateisystem schreibgeschützt starten und Rechte begrenzen

Veröffentlicht am 10. Oktober 2026 · Geschätzte Lesezeit: 11 Minuten

Ein laufender Container ist nicht automatisch ein gehärteter Container. Standardmäßig hat der Hauptprozess in einem Docker-Container weitreichende Rechte – er kann Programme im Dateisystem ablegen, neue Pakete installieren oder Konfigurationen überschreiben. Mit read_only, tmpfs, cap_drop und no-new-privileges schränkst du diese Möglichkeiten gezielt ein, ohne die Anwendung selbst zu ändern. Ein Angreifer, der in einen so gehärteten Container eindringt, kann keine dauerhaften Veränderungen am Dateisystem vornehmen, keine neuen Binärdateien hinterlegen und keine Rechteausweitung über su oder sudo nutzen.[5][7] Plane für die Einrichtung 30 bis 60 Minuten einschließlich Sicherung, Probelauf und Fehleranalyse ein.

Voraussetzungen

  • Ein Linux-Server mit Docker Engine und dem Docker-Compose-Plugin
  • Ein vorhandenes Compose-Projekt mit einer compose.yaml
  • Terminalzugriff auf den Projektordner
  • Ein aktuelles Backup der Compose-Datei, Datenbanken und eingebundenen Volumes

1. Ausgangszustand dokumentieren

Wechsle in den Ordner deines Compose-Projekts. Ersetze <projektordner> durch den vollständigen Pfad zu diesem Ordner:

cd <projektordner>

Zeige die aktuell aufgelöste Konfiguration an:

docker compose config

Liste die laufenden Container des Projekts auf:

docker compose ps

Zeige die letzten Logs deines Dienstes an. Ersetze <dienstname> durch den Eintrag direkt unter services: in deiner compose.yaml:

docker compose logs --tail=100 <dienstname>

Sichere die vollständig aufgelöste Konfiguration in eine nur für Administratoren lesbare Datei:

umask 077
docker compose config > compose.before-hardening.yaml

Sicherungsdatei enthält aufgelöste Umgebungsvariablen

docker compose config zeigt Passwörter, Token und andere Geheimnisse aus der .env-Datei unverschlüsselt an. Die Datei compose.before-hardening.yaml darf nicht veröffentlicht, in kein öffentliches Git eingecheckt und nur mit chmod 600 gespeichert werden.

2. Persistente Daten sichern

Erstelle vor jeder Konfigurationsänderung eine datierte Sicherung der Compose-Datei:

cp compose.yaml "compose.yaml.vor-haertung-$(date +%F-%H%M)"

Liste die eingebundenen Volumes und Datenverzeichnisse auf:

docker compose config --volumes

Notiere alle benannten Volumes und Host-Pfade, damit du später entscheiden kannst, welche davon wirklich beschreibbar sein müssen. Deine Datenbank-Volumes und Upload-Verzeichnisse bleiben ohnehin beschreibbar – die Härtung gilt nur für das Root-Dateisystem des Containers.

3. Benötigte Schreibpfade ermitteln

Bevor du das Dateisystem schreibschützt, musst du wissen, welche Pfade die Anwendung zum Schreiben benötigt. Typische Kandidaten sind /tmp, /run, /var/cache/<anwendung> (ersetze <anwendung> durch den Namen der Anwendung, beispielsweise nginx oder php) oder anwendungsspezifische Arbeitsverzeichnisse.[6]

Starte den Dienst ohne die Härtungsoptionen und lass ihn eine Weile laufen. Ersetze <dienstname> durch den tatsächlichen Compose-Dienstnamen:

docker compose up -d

Warte danach einige Sekunden und führe dann den Vergleich aus. Ersetze <dienstname> durch den tatsächlichen Compose-Dienstnamen:

docker diff $(docker compose ps -q <dienstname>) 2>/dev/null || echo "docker diff wird von diesem Container nicht unterstützt"

docker diff zeigt die Dateien und Verzeichnisse an, die der Container im Overlay-Dateisystem angelegt oder verändert hat.[6] Falls dein System docker diff nicht unterstützt (ältere Speicher-Treiber), notiere stattdessen die Pfade aus der Anwendungsdokumentation oder teste mit read_only: true Schritt für Schritt (siehe Schritt 9).

4. Benutzer festlegen

Viele offizielle Images laufen standardmäßig als root. Das ist für einen gehärteten Container unnötig riskant. Verwende einen festen Benutzer mit minimalen Rechten.

Finde heraus, mit welchem Benutzer die Anwendung läuft. Ersetze <dienstname> durch den Namen deines Dienstes:

docker compose exec <dienstname> id

Die Ausgabe zeigt uid=0(root) für Root oder eine Zahlen-UID wie uid=1001(app). Falls das Image einen eigenen Anwendungsbenutzer mitbringt (beispielsweise nginx mit user: nginx), notiere dessen UID und GID.

Ergänze in deiner compose.yaml den Benutzer. Verwende root nur, wenn die Anwendung ausdrücklich Root-Rechte im Container benötigt (das ist die Ausnahme, nicht die Regel):

services:
  <dienstname>:
    image: <vorhandenes-image>
    user: "1001:1001"

Ersetze <dienstname> durch deinen Dienstnamen, <vorhandenes-image> durch das verwendete Image und 1001:1001 durch die tatsächliche UID und GID aus dem vorigen Schritt. Bei selbst erstellten Images definierst du den Benutzer bereits im Dockerfile mit RUN addgroup -S appgroup && adduser -S appuser -G appgroup und USER appuser.

5. Linux-Capabilities begrenzen

Linux-Capabilities gewähren einem Prozess bestimmte Kernel-Vorrechte – wie das Binden an privilegierte Ports (NET_BIND_SERVICE) oder das Ändern der Systemuhr (SYS_TIME). Ein normaler Anwendungscontainer braucht die wenigsten dieser Rechte. Mit cap_drop: ALL entziehst du dem Container alle, mit cap_add gibst du nur die wirklich benötigten zurück.[1][5]

Ergänze in deiner compose.yaml:

services:
  <dienstname>:
    image: <vorhandenes-image>
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE

NET_BIND_SERVICE ist die häufigste benötigte Capability – sie erlaubt das Binden an Ports unter 1024. Falls deine Anwendung einen höheren Port verwendet (beispielsweise 8080), kannst du cap_add ganz weglassen. Weitere Capabilities wie CHOWN oder SYS_PTRACE fügst du nur hinzu, wenn die Anwendung sie nachweislich benötigt.[5]

6. Rechteausweitung verhindern

Der Parameter no-new-privileges verhindert, dass ein Prozess im Container mehr Rechte erlangt, als er beim Start hatte – selbst wenn eine Datei das setuid-Bit trägt oder der Benutzer su/sudo aufruft. Das Flag wird vom Kernel erzwungen und kann zur Laufzeit nicht zurückgenommen werden.[7]

Ergänze in deiner compose.yaml:

services:
  <dienstname>:
    image: <vorhandenes-image>
    security_opt:
      - no-new-privileges:true

Bekannte Einschränkung

no-new-privileges verträgt sich nicht mit Images, deren Entrypoint Benutzerwechsel über su oder sudo vornimmt (etwa ältere PHP- oder Node-Images). In diesem Fall musst du das Image anpassen oder auf gosu/su-exec umstellen. Der Parameter user: unter compose.yaml ist für solche Fälle die sauberere Alternative.[7]

7. Dateisystem schreibgeschützt starten

Mit read_only: true wird das gesamte Root-Dateisystem des Containers schreibgeschützt. Der Container kann keine neuen Dateien außerhalb der explizit beschreibbaren Pfade anlegen – ein Angreifer kann also keinen Webshell, Cron-Job oder Backdoor-Skript dauerhaft platzieren.[5][6]

Ergänze in deiner compose.yaml:

services:
  <dienstname>:
    image: <vorhandenes-image>
    read_only: true

Jetzt darf kein Prozess mehr in /etc, /usr, /var (außerhalb der Ausnahmen) oder andere systemeigene Pfade schreiben. Das klingt radikal – und genau das soll es sein.

8. Temporäre Schreibpfade als tmpfs bereitstellen

Viele Anwendungen brauchen kurzfristig Schreibzugriff, etwa für Cache-Dateien (/tmp), PID-Dateien (/run) oder kompilierte Templates (/var/cache/<anwendung>, ersetze <anwendung> durch den Anwendungsnamen). Mit tmpfs stellst du genau diese Pfade als RAM-basierte Dateisysteme bereit: beschreibbar, aber ohne dauerhafte Speicherung. Sobald der Container stoppt, sind die Daten weg.[2][7]

Ergänze in deiner compose.yaml:

services:
  <dienstname>:
    image: <vorhandenes-image>
    read_only: true
    tmpfs:
      - /tmp:rw,noexec,nosuid,nodev,size=128m
      - /run:rw,noexec,nosuid,size=32m

Bedeutung der Optionen: - rw – beschreibbar (explizit zur Verdeutlichung) - noexec – keine ausführbaren Programme von diesem Mount starten - nosuid – setuid- und setgid-Bits ignorieren - nodev – Gerätedateien auf diesem Mount ignorieren - size=<menge> – maximale Größe des RAM-Dateisystems; ersetze <menge> durch einen Wert wie 128m (128 Megabyte) oder 1g (1 Gigabyte)

Füge weitere Pfade hinzu, die deine Anwendung benötigt. Die genauen Pfade hast du in Schritt 3 ermittelt. Häufig benötigte zusätzliche tmpfs-Pfade sind:

  • /var/cache/nginx – Nginx-Cache (falls Nginx verwendet wird)
  • /var/lib/php/sessions – PHP-Session-Dateien
  • /tmp/sessions – anwendungsspezifische temporäre Daten

9. Komplette Konfiguration zusammenführen

Füge alle bisherigen Blöcke in deine compose.yaml ein. Ein vollständig gehärteter Dienst kann so aussehen:

services:
  web:
    image: nginx:1.27-alpine
    user: "101:101"
    read_only: true
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE
    security_opt:
      - no-new-privileges:true
    tmpfs:
      - /tmp:rw,noexec,nosuid,nodev,size=128m
      - /run:rw,noexec,nosuid,size=32m
      - /var/cache/nginx:rw,noexec,nosuid,size=32m
    restart: unless-stopped

Ersetze nginx:1.27-alpine, den Benutzer und die tmpfs-Pfade durch die Werte deiner tatsächlichen Anwendung. Bei einem Datenbank-Container (PostgreSQL, MariaDB) bleibt das Daten-Volume ohnehin beschreibbar – die Härtung betrifft nur das Root-Dateisystem. Der Datenbankdienst kann parallel mit eigenen volumes:-Einträgen laufen.

Prüfe die Syntax der zusammengeführten Datei:

docker compose config --quiet

Wenn dieser Befehl fehlerfrei endet, ist die Compose-Datei syntaktisch korrekt. Zeige die aufgelöste Konfiguration an:

docker compose config

Kontrolliere besonders die Abschnitte read_only, cap_drop, security_opt, tmpfs und den Benutzer, bevor du den Container neu erstellst.

10. Gehärteten Container starten

Starte den Dienst mit der neuen Konfiguration neu:

docker compose up -d

Prüfe den Zustand des Containers:

docker compose ps

Der Dienst sollte den Status Up oder healthy (sofern konfiguriert) anzeigen. Zeige die letzten Logs an:

docker compose logs --tail=200 <dienstname>

Achte auf Fehlermeldungen mit Read-only file system, EROFS, Permission denied oder operation not permitted. Jede dieser Meldungen zeigt einen Schreibpfad, den du in Schritt 8 als zusätzlichen tmpfs-Eintrag ergänzen musst.

11. Härtungsattribute prüfen

Verifiziere, dass die Härtung tatsächlich aktiv ist. Ersetze <containername> durch den tatsächlichen Containernamen aus docker compose ps:

docker inspect <containername> \
  --format 'ReadonlyRootfs={{.HostConfig.ReadonlyRootfs}} CapDrop={{json .HostConfig.CapDrop}} SecurityOpt={{json .HostConfig.SecurityOpt}}'

Erwartete Ausgabe (Reihenfolge kann abweichen):

ReadonlyRootfs=true CapDrop=["ALL"] SecurityOpt=["no-new-privileges"]

Prüfe das Root-Dateisystem direkt. Ersetze <containername> durch den tatsächlichen Namen:

docker exec <containername> sh -c 'touch /hardening-test 2>&1' || echo "OK – Schreibzugriff wurde blockiert"

Erwartet wird eine Fehlermeldung touch: /hardening-test: Read-only file system oder ein Nicht-Null-Exitcode, den das || abfängt. Falls das Image keine Shell oder kein touch enthält, ist dieser Test nicht anwendbar – das ist kein Fehler des Dienstes.

Prüfe, ob der tmpfs-Schreibpfad funktioniert. Ersetze <containername> durch den tatsächlichen Namen:

docker exec <containername> sh -c 'touch /tmp/hardening-test && rm /tmp/hardening-test && echo "tmpfs beschreibbar"'

Dieser Befehl muss tmpfs beschreibbar ausgeben, wenn /tmp als tmpfs konfiguriert ist und touch im Image vorhanden ist. Liefert er eine Read-only file system-Meldung, fehlt der tmpfs-Eintrag oder der Pfad wird nicht auf den tmpfs-Mount geschrieben.

12. Fachliche Anwendungstests durchführen

Die Härtung ist nur dann erfolgreich, wenn die Anwendung wie erwartet funktioniert. Führe mindestens diese Prüfungen durch:

  • Öffne die Weboberfläche im Browser und melde dich an
  • Führe eine ungefährliche Testaktion aus (Eintrag anlegen, suchen, anzeigen)
  • Prüfe Upload, Export, Suche oder einen Hintergrundjob, falls vorhanden
  • Starte den Container einmal kontrolliert neu:
docker compose restart <dienstname>
  • Prüfe nach dem Neustart, ob persistente Daten (Datenbankinhalte, Uploads) noch vorhanden sind und ob temporäre Daten (Cache, Sessions) erwartungsgemäß verschwunden sind

13. Risiken und Grenzen beachten

  • Ein schreibgeschütztes Root-Dateisystem verhindert keine Angriffe auf die Anwendungsebene (SQL-Injection, XSS) und schützt nicht vor Kompromittierung des Host-Systems.
  • read_only: true gilt nicht für benannte Volumes und Bind-Mounts – sie bleiben beschreibbar. Ein Angreifer, der in den Container eindringt, kann weiterhin Daten in diese Pfade schreiben.[6]
  • no-new-privileges verhindert keine Rechteausweitung, die auf Kernel-Ebene oder durch unsichere Mounts erfolgt.
  • cap_drop: ALL kann Container lahmlegen, die bestimmte Kernel-Operationen benötigen (beispielsweise SYS_PTRACE für Debugging oder CHOWN für Dateibesitzwechsel im Volume).
  • Die Härtung ersetzt kein regelmäßiges Patchen der Images, keine Netzwerksegmentierung und kein Überwachungssystem. Sie ist eine zusätzliche Verteidigungsschicht (Defense-in-Depth) – kein Ersatz für andere Sicherheitsmaßnahmen.[5]

Die öffentlich auffindbaren Videos „How To Secure & Harden Docker Containers“[8] und „Docker Security Essentials“[9] führen die Konzepte ergänzend vor. Für technische Entscheidungen sind die aktuellen Docker-Dokumentationen maßgeblich.[1][3][7]

14. Typische Fehler beheben

  • Read-only file system im Anwendungslog: Der Dienst benötigt einen bislang unbekannten Schreibpfad. Lies den genauen Pfad aus der Fehlermeldung (beispielsweise /var/cache/app oder /tmp/sessions) und ergänze ihn als tmpfs-Eintrag in Schritt 8. Ändere nicht die Option read_only auf false – das würde den gesamten Härtungseffekt aufheben.[6]

  • Container startet in einer Neustartschleife: Prüfe docker compose ps und die letzten Logs. Eine fehlende tmpfs-Mount für /run oder einen benötigten Cache-Pfad ist die häufigste Ursache. Setze nicht privileged: true als schnellen Ausweg – das hebt alle Härtungsoptionen auf.[3]

  • Berechtigungsfehler im Volume: Die UID/GID in user: stimmen nicht mit den Besitzrechten des Host-Mounts überein. Passe entweder die user:-Angabe an oder korrigiere die Besitzer auf dem Host mit chown -R <uid>:<gid> ./datenverzeichnis. Ersetze <uid> und <gid> durch die Zahlen aus Schritt 4 (beispielsweise 1001:1001).

  • Verlorene Sitzungen nach Neustart: Ein Pfad, der persistente Session-Daten enthalten sollte, wurde fälschlich als tmpfs eingebunden. Verwende für dauerhafte Daten ein benanntes Volume, nicht tmpfs.

  • Fehlende Funktion nach cap_drop: ALL: Deine Anwendung vermisst eine Capability. Identifiziere die fehlende Fähigkeit anhand der Logmeldung (beispielsweise CAP_CHOWN beim Verschieben von Dateien) und füge sie gezielt in cap_add hinzu. Füge nicht mehrere Capabilities pauschal hinzu.

  • Konfigurationsänderungen werden nicht gespeichert: Der Konfigurationspfad liegt außerhalb der Volumes und tmpfs-Mounts. Binde das Konfigurationsverzeichnis als benanntes Volume ein, damit Änderungen dauerhaft bleiben.

  • Speicherdruck durch zu großen tmpfs: tmpfs belegt Arbeitsspeicher. Setze die size=-Angabe konservativ und überwache die Speicherauslastung mit docker stats <containername>.

15. Rückweg durchführen

Zeige die angelegten Sicherungen an:

ls -1 compose.yaml.vor-haertung-*

Stoppe weitere Tests und stelle die ursprüngliche Konfiguration wieder her. Ersetze <sicherungsdatei> durch den vollständigen Dateinamen aus der vorherigen Ausgabe:

cp <sicherungsdatei> compose.yaml

Prüfe die wiederhergestellte Datei:

docker compose config --quiet

Starte den Dienst mit der alten Konfiguration neu:

docker compose up -d

Prüfe abschließend Status, Logs und die Anwendung wie in Schritt 11 und 12.

Fertig

Dein Docker-Container läuft jetzt mit einem schreibgeschützten Root-Dateisystem, eingeschränkten Linux-Capabilities und aktiviertem no-new-privileges. Ein Angreifer, der eine Schwachstelle in der Anwendung ausnutzt, kann keine dauerhaften Veränderungen am Dateisystem vornehmen und keine Rechteausweitung über su oder sudo nutzen.

Die wichtigsten Prüfungen nach der Härtung:

  1. Container läuft – docker compose ps zeigt den Dienst als Up oder healthy
  2. Keine Schreibfehler – docker compose logs --tail=200 <dienstname> ohne wiederholte Read-only file system-Meldungen
  3. Härtungsattribute aktiv – docker inspect <containername> --format 'ReadonlyRootfs={{.HostConfig.ReadonlyRootfs}} CapDrop={{json .HostConfig.CapDrop}} SecurityOpt={{json .HostConfig.SecurityOpt}}' zeigt alle drei Werte wie erwartet
  4. Root-Dateisystem wirklich schreibgeschützt – docker exec <containername> sh -c 'touch /hardening-test 2>&1' schlägt fehl (sofern das Image Shell und touch enthält)
  5. Anwendung funktioniert – Die fachlichen Tests aus Schritt 12 sind erfolgreich

Grundlage dieser Anleitung sind die offiziellen Docker-Referenzen zu read_only, tmpfs, cap_drop und no-new-privileges, geprüft am 10. Oktober 2026.[1][2][3][4] Ergänzend wurden die OWASP Docker Security Cheat Sheet (Regeln 3, 4, 8) und eine aktuelle Analyse von der RunBook Academy ausgewertet.[5][7]

Sources

[1] https://docs.docker.com/reference/compose-file/services — Docker Compose Services Reference [2] https://docs.docker.com/engine/storage/tmpfs — Docker tmpfs Mounts [3] https://docs.docker.com/compose/trust-model — Docker Compose Trust Model [4] https://docs.docker.com/reference/cli/docker/inspect — Docker Inspect CLI Reference [5] https://cheatsheetseries.owasp.org/cheatsheets/Docker_Security_Cheat_Sheet.html — OWASP Docker Security Cheat Sheet [6] https://docs.linuxserver.io/misc/read-only — LinuxServer.io: Running Containers Read-Only [7] https://runbook.academy/courses/docker/lessons/docker-read-only-no-new-privileges — Read-only root and no-new-privileges [8] https://www.youtube.com/watch?v=CQLtT_qeB40 — How To Secure & Harden Docker Containers [9] https://www.youtube.com/watch?v=KINjI1tlo2w — Docker Security Essentials: How To Secure Docker Containers