Docker-Container härten: Rootless, Secrets und Ressourcenlimits
Mache deinen Setup production-ready Teil 5
Mache deinen Setup production-ready Teil 5
Container isolieren Prozesse, die Docker-Standardeinstellungen sind aber auf Bequemlichkeit ausgelegt, nicht auf Sicherheit. Ab Werk laufen viele Container als Root, haben unbegrenzten Zugriff auf RAM und CPU, und Zugangsdaten wandern im Klartext durch Compose-Dateien. In diesem Teil der Reihe „Mache deinen Setup production-ready" sicherst du dein Docker-Setup in drei Dimensionen ab: weniger Rechte (Non-Root und Rootless), geschützte Zugangsdaten (Secrets) und begrenzte Ressourcen (Limits), damit ein kompromittierter oder defekter Container nicht den ganzen Server mitreißt.
Container als Non-Root-User starten, Capabilities auf das Nötigste reduzieren, Zugangsdaten aus der Compose-Datei in eine geschützte .env-Datei oder Secrets auslagern und jedem Container RAM- und CPU-Limits setzen. Wer weiter gehen will, betreibt den Docker-Daemon komplett rootless.
Zwei Szenarien treiben dieses Tutorial an. Erstens der Ausbruch: Eine Web-Anwendung im Container hat eine Sicherheitslücke, ein Angreifer erlangt Code-Ausführung. Läuft der Container als Root mit vollen Capabilities, ist der Weg zum Host deutlich kürzer als nötig. Zweitens die Ressourcen-Kaskade: Ein einzelner Container mit Speicherleck frisst den gesamten RAM, der Kernel beginnt wahllos Prozesse zu beenden — und plötzlich sind alle Dienste down, nicht nur der defekte. Beide Szenarien lassen sich mit wenigen Zeilen Konfiguration entschärfen.
Der wichtigste Einzelschritt. Prüfe zuerst, welche deiner Container als Root laufen:
Ein leeres Feld hinter dem Namen bedeutet: läuft als Root (sofern das Image nichts anderes vorgibt). Viele offizielle Images bringen bereits einen Non-Root-User mit — Grafana etwa läuft ab Werk als User 472. Wo das nicht der Fall ist, erzwingst du es in der Compose-Datei:
Wichtig dabei: Der angegebene User braucht Schreibrechte auf die gemounteten Volumes. Bei Bind-Mounts passt du die Besitzrechte auf dem Host an (chown -R 1000:1000 ./daten), bei benannten Volumes übernimmt das meist das Image beim ersten Start. Wenn ein Container nach der Umstellung mit „Permission denied" abbricht, ist das fast immer die Ursache.
Auch ein Root-Prozess im Container hat nicht automatisch alle Rechte. Docker vergibt ein Standard-Set an Kernel-Capabilities. Produktionsreif ist der umgekehrte Ansatz: alles entziehen, nur das Nötige zurückgeben. Für die meisten Web-Anwendungen reicht:
Die vier Bausteine im Einzelnen: cap_drop: ALL entzieht sämtliche Sonderrechte, braucht ein Dienst doch eine (etwa NET_BIND_SERVICE für Ports unter 1024), gibst du gezielt nur diese per cap_add zurück. no-new-privileges verhindert, dass Prozesse im Container über setuid-Binaries zusätzliche Rechte erlangen. read_only macht das Container-Dateisystem unveränderlich — ein Angreifer kann keine Dateien nachladen oder Binaries austauschen. Und tmpfs gibt der Anwendung einen beschreibbaren Ort für temporäre Dateien zurück, den viele trotzdem brauchen.
Taste dich hier Dienst für Dienst vor: Option setzen, Container neu erstellen, Logs prüfen. Nicht jede Anwendung verträgt read_only, deshalb lieber gezielt einzelne Pfade als Volume beschreibbar machen, statt die Option ganz zu streichen.
Passwörter direkt im environment:-Block zu speichern (wie unser Grafana-Beispiel aus Teil 1 es der Einfachheit halber getan hat) haben zwei Probleme: Sie landen in Git, Backups und jedem docker inspect. Die pragmatische Lösung ist eine .env-Datei neben der Compose-Datei:
In der Compose-Datei referenzierst du die Werte nur noch:
Docker Compose liest die .env-Datei automatisch. Damit ist die Compose-Datei selbst frei von Geheimnissen und kann bedenkenlos versioniert werden.
Einen Schritt weiter gehen Compose-Secrets: Der Wert liegt in einer eigenen Datei und wird dem Container nur als Datei unter /run/secrets/ bereitgestellt — er taucht damit weder in der Umgebung noch in docker inspect auf:
Voraussetzung ist, dass die Anwendung _FILE-Varianten ihrer Variablen unterstützt — viele offizielle Images (PostgreSQL, MySQL, Nextcloud) tun das. Wo nicht, bleibt die .env-Datei mit chmod 600 der solide Standard.
Ohne Limits darf jeder Container den kompletten Server beanspruchen. Setze deshalb für jeden Dienst eine Obergrenze:
memory ist das wichtigste Limit: Überschreitet der Container die Grenze, beendet der Kernel gezielt diesen Container (OOM-Kill) — statt wahllos irgendeinen Prozess auf dem Host. Dank restart: unless-stopped startet er danach neu, und alle anderen Dienste laufen unberührt weiter. cpus begrenzt die Rechenzeit relativ (1.0 = ein Kern), und pids_limit verhindert Fork-Bomben.
Welche Werte sind richtig? Miss den Ist-Zustand, statt zu raten:
Nimm den beobachteten RAM-Spitzenwert plus rund 50 % Reserve. Für den Monitoring-Stack aus Teil 1 sind etwa 512M für Prometheus, 256M für Grafana und je 128M für die Exporter realistische Startwerte. Ob ein Container an sein Limit stößt, siehst du übrigens in genau diesem Stack: cAdvisor liefert die Metrik, Grafana zeigt sie an — und ein Prometheus-Alert kann dich warnen, bevor der OOM-Killer zuschlägt.
Zwei Härtungen auf Netzwerkebene runden das Bild ab. Erstens: ein Netzwerk pro Stack statt einem großen für alles. Container sehen sich nur innerhalb ihres Netzwerks, weshalb die Web-App aus Projekt A dann gar nicht erst mit der Datenbank aus Projekt B reden kann. Reine Backend-Netze (etwa zwischen App und Datenbank) markierst du zusätzlich mit internal: true, dann gibt es daraus keinerlei Weg ins Internet. Und wie in der ganzen Serie gilt: Ports nur an 127.0.0.1 binden und den Zugang dem Reverse Proxy überlassen.
Zweitens der Docker-Socket: /var/run/docker.sock in einen Container zu mounten bedeutet faktisch Root-Zugriff auf den Host, Wer den Socket kontrolliert, kann privilegierte Container starten. Mounte ihn nur, wo es wirklich nötig ist (Traefik aus Teil 3, Alloy aus Teil 2), immer read-only, und niemals in Container, die selbst öffentlich erreichbar sind. Für exponierte Setups lohnt ein Socket-Proxy, der die Docker-API auf Lesezugriffe filtert.
Alles bisher härtet Container innerhalb eines Daemons, der selbst als Root läuft. Rootless Docker eliminiert auch dieses Restrisiko: Der komplette Daemon läuft als normaler Benutzer, ein Ausbruch endet bestenfalls bei dessen Rechten statt bei Root. Die Einrichtung ist überschaubar:
Danach zeigt die Shell die nötigen Umgebungsvariablen an (DOCKER_HOST), die du in deine .bashrc übernimmst, und mit loginctl enable-linger $USER läuft der Daemon auch ohne aktive SSH-Session weiter.
Die ehrliche Einordnung: Rootless hat Trade-offs. Ports unter 1024 erfordern Zusatzkonfiguration, die Netzwerk-Performance ist etwas geringer, und Tools, die den privilegierten Socket erwarten (cAdvisor aus Teil 1 gehört dazu), brauchen Anpassungen. Für einen Server, auf dem fremde oder besonders exponierte Workloads laufen, lohnt sich das. Für ein typisches Self-Hosting-Setup sind die Schritte 1–5 der deutlich bessere Aufwand-Nutzen-Deal, wobei Rootless die Kür ist, nicht die Pflicht.
Die beste Härtung nützt wenig, wenn das Image selbst kompromittiert oder uralt ist. Pinne Versionen statt :latest zu ziehen (unsere Beispiele aus Teil 1–4 solltest du für den Dauerbetrieb entsprechend anpassen), beziehe Images nur aus vertrauenswürdigen Quellen und aktualisiere regelmäßig per docker compose pull && docker compose up -d.
Dein Docker-Setup folgt jetzt dem Prinzip der geringsten Rechte: Container laufen ohne Root und ohne überflüssige Capabilities, Zugangsdaten liegen nicht mehr im Klartext in versionierten Dateien, und Ressourcenlimits sorgen dafür, dass ein einzelner Ausreißer nicht den ganzen Server lahmlegt. Zusammen mit Monitoring (Teil 1), Logging (Teil 2) und Uptime-Checks (Teil 4) merkst du nicht nur, wenn etwas schiefgeht, sondern es geht auch deutlich weniger schief.
Im letzten Teil der Reihe „Der produktionsreife Server" wird es ernst: Server-Umzug ohne Ausfall, wie du Daten und Dienste auf eine neue Maschine migrierst, ohne dass deine Nutzer etwas davon merken.
Nein — und du solltest es auch nicht. Gehe Dienst für Dienst vor: erst Non-Root-User, dann Limits, dann Capabilities, nach jedem Schritt testen. So findest du sofort heraus, welche Option ein Problem verursacht. Die Reihenfolge der Schritte im Artikel ist zugleich eine sinnvolle Prioritätenliste.
Die zwei häufigsten Ursachen: fehlende Schreibrechte auf Volumes nach der User-Umstellung (docker logs zeigt „Permission denied", dann müssen die Besitzrechte per chown angepassen werden) und ein zu striktes read_only (die App braucht einen beschreibbaren Pfad — per tmpfs oder Volume gezielt freigeben).
VPS auf AMD EPYC mit NVMe SSD, volle Kontrolle, gehostet in Deutschland.
VPS konfigurieren
Der Kernel beendet den Container per OOM-Kill, die restart-Policy startet ihn neu. Hier von bleiben alle anderen Dienste unberührt. Genau das ist der Zweck: ein kontrollierter, lokaler Ausfall statt eines unkontrollierten auf dem ganzen Host. Wiederholte OOM-Kills siehst du in docker inspect und im Monitoring aus Teil 1.
Nein. user: legt fest, als wer der Prozess im Container läuft, aber der Docker-Daemon selbst bleibt Root. Rootless Docker betreibt auch den Daemon als normalen Benutzer. Ersteres ist der wichtige Alltagsschutz mit minimalem Aufwand, letzteres die zusätzliche Absicherung für erhöhte Anforderungen.
docker ps -q | xargs docker inspect --format '{{.Name}}: {{.Config.User}}'services:
meine-app:
image: meine-app:1.4.2
user: "1000:1000"services:
meine-app:
image: meine-app:1.4.2
user: "1000:1000"
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
read_only: true
tmpfs:
- /tmp# ~/monitoring/.env
GRAFANA_ADMIN_PASSWORD=ein-langes-zufaelliges-passwort
SMTP_PASSWORD=noch-ein-zufaelliges-passwortchmod 600 .env
echo ".env" >> .gitignore grafana:
environment:
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD}services:
meine-app:
image: meine-app:1.4.2
secrets:
- db_password
environment:
- DB_PASSWORD_FILE=/run/secrets/db_password
secrets:
db_password:
file: ./secrets/db_password.txtservices:
meine-app:
image: meine-app:1.4.2
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
pids_limit: 200docker stats --no-streamsudo apt install -y uidmap dbus-user-session
dockerd-rootless-setuptool.sh install