Docker Compose ist das Schweizer Taschenmesser des Container-Orchestrierens auf Einzelserver-Ebene. Schnell aufgesetzt, gut lesbar, versionierbar. In der Entwicklung läuft es problemlos – und dann kommt der erste echte Produktions-Deploy und man merkt, dass man sich ein paar Dinge hätte überlegen sollen.
Dieser Artikel zeigt, was den Unterschied zwischen einem Compose-Setup, das irgendwie funktioniert, und einem, das auch um 3 Uhr morgens zuverlässig läuft, wirklich ausmacht. Kein Kubernetes-Overkill – sondern Compose produktionsreif gemacht.
Warum Docker Compose für Production überhaupt?
Für viele Projekte – Web-Apps mit ein paar Services, interne Tools, Staging-Umgebungen, KMU-Infrastruktur – ist Kubernetes schlicht überdimensioniert. Der Overhead für Cluster-Setup, RBAC, Ingress-Controller und den täglichen Betrieb lohnt sich erst ab einer gewissen Komplexität.
Docker Compose auf einem dedizierten VPS oder Bare-Metal-Server ist eine ernsthafte Alternative: nachvollziehbar, schnell iterierbar, günstig zu betreiben. Die Bedingung ist, dass man es richtig aufsetzt – und das ist weniger aufwendig als viele denken.
Faustregel: Wenn dein Stack weniger als 10 Services hat und du keinen Multi-Node-Betrieb brauchst, ist ein sauber konfiguriertes Compose-Setup oft die bessere Wahl als ein Kubernetes-Cluster.
Projektstruktur aufräumen
Bevor wir zu den einzelnen Konfigurationsdetails kommen: Die Dateistruktur entscheidet darüber, wie übersichtlich und wartbar ein Setup langfristig bleibt.
Ein solides Layout für ein produktives Compose-Projekt:
myapp/
├── docker-compose.yml # Basis-Konfiguration (Services, Volumes, Networks)
├── docker-compose.override.yml # Lokale Dev-Overrides (wird nicht committed)
├── docker-compose.prod.yml # Prod-spezifische Overrides
├── .env.example # Template für Umgebungsvariablen (committed)
├── .env # Echte Werte (NICHT committed!)
├── nginx/
│ └── default.conf
├── data/ # Persistente Volumes (gitignored)
└── deploy.sh # Deploy-Skript
Der docker-compose.override.yml-Mechanismus ist unterschätzt: Compose lädt ihn automatisch zusätzlich zur Basis-Datei. Entwickler können lokal überlagern (Ports freigeben, Debug-Optionen aktivieren), ohne die produktive Konfiguration anzufassen.
Für Production deployen mit explizitem File-Switch:
# Production: Basis + Prod-Overrides, KEIN override.yml
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
Umgebungsvariablen & Secrets
Das ist die häufigste Schwachstelle in Compose-Setups: Passwörter und API-Keys landen direkt in der docker-compose.yml oder schlimmer noch im Image. Beides ist falsch.
Schicht 1: .env-Datei
Compose lädt automatisch eine .env-Datei aus dem Projektverzeichnis. Diese Datei kommt niemals ins Repository.
# docker-compose.yml - Variablen referenzieren, niemals hardcoden
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
# .env (NICHT ins Git!)
DB_NAME=myapp_prod
DB_USER=myapp
DB_PASSWORD=sehr-langes-zufälliges-passwort-hier
# .env.example (committed, ohne echte Werte)
DB_NAME=
DB_USER=
DB_PASSWORD=
Schicht 2: Docker Secrets (für sensiblere Daten)
Docker unterstützt auch nativ Secrets, die als Dateien in /run/secrets/ im Container gemountet werden – ohne dass sie als Umgebungsvariablen auftauchen (und damit in docker inspect sichtbar sind).
services:
app:
image: myapp:latest
secrets:
- db_password
environment:
DB_PASSWORD_FILE: /run/secrets/db_password
secrets:
db_password:
file: ./secrets/db_password.txt # Lokale Datei, gitignored
Die Anwendung liest dann den Inhalt der Datei statt einer Umgebungsvariablen. Viele Frameworks und Datenbank-Images unterstützen das _FILE-Suffix bereits nativ.
Brauchst du Unterstützung bei der Umsetzung?
30-Min Call — kostenlos, unverbindlich, konkret.
Restart Policies richtig setzen
Ein Service, der abstürzt und nicht neu startet, ist in Production ein Ausfall. Docker bietet vier Restart Policies:
| Policy | Verhalten | Einsatz |
|---|---|---|
| no | Kein Neustart | Dev/Tests |
| always | Immer neu starten, auch nach manuellem Stop | Vorsichtig einsetzen |
| on-failure | Neustart nur bei Exit-Code ≠ 0 | Gut für Batch-Jobs |
| unless-stopped | Neustart außer nach manuellem Stop | Production-Standard |
unless-stopped ist in den meisten Production-Szenarien die richtige Wahl: Der Service startet nach Server-Reboot oder Absturz automatisch neu, bleibt aber gestoppt, wenn man ihn manuell anhält (z.B. für Wartungsarbeiten).
services:
app:
image: myapp:latest
restart: unless-stopped
worker:
image: myapp:latest
command: python worker.py
restart: on-failure:5 # Max. 5 Versuche, dann aufgeben
Health Checks konfigurieren
Ohne Health Checks weiß Compose nicht, ob ein Service wirklich bereit ist – nur ob der Prozess läuft. Das führt zu Race Conditions beim Start: Der App-Container startet, bevor die Datenbank bereit ist, und schlägt fehl.
services:
db:
image: postgres:16-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 10s # Alle 10s prüfen
timeout: 5s # Max. Wartezeit pro Check
retries: 5 # 5 Fehlschläge = unhealthy
start_period: 30s # Anlaufzeit, bevor Health Checks zählen
restart: unless-stopped
app:
image: myapp:latest
depends_on:
db:
condition: service_healthy # Wartet auf gesunden Zustand
restart: unless-stopped
nginx:
image: nginx:alpine
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 3
depends_on:
app:
condition: service_healthy
Der condition: service_healthy-Mechanismus ist der entscheidende Unterschied zu einem einfachen depends_on. Nur wenn der Health Check grün ist, startet der abhängige Service.
Health Check Endpunkt in der App
Für Web-Services empfiehlt sich ein dedizierter /health-Endpunkt, der auch abhängige Services (DB-Connection, Cache) prüft:
# Flask Beispiel
@app.route('/health')
def health():
try:
db.session.execute('SELECT 1')
return jsonify({'status': 'ok', 'db': 'connected'}), 200
except Exception as e:
return jsonify({'status': 'error', 'db': str(e)}), 503
Netzwerk-Isolation
Standardmäßig landen alle Services im selben Bridge-Netzwerk und können sich gegenseitig erreichen. In Production sollte man das einschränken: Nur Services, die sich kennen müssen, teilen ein Netzwerk.
services:
nginx:
image: nginx:alpine
networks:
- frontend
ports:
- "80:80"
- "443:443"
app:
image: myapp:latest
networks:
- frontend # Erreichbar für nginx
- backend # Kann auf DB zugreifen
# Kein "ports" - nicht direkt erreichbar von außen!
db:
image: postgres:16-alpine
networks:
- backend # NUR im Backend-Netzwerk
# Kein "ports" - niemals direkt von außen erreichbar!
redis:
image: redis:7-alpine
networks:
- backend
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true # Kein Internetzugang aus diesem Netzwerk
Das internal: true bei backend verhindert, dass Datenbank-Container überhaupt ins Internet kommunizieren können – selbst wenn ein Angreifer Zugriff auf einen Container bekommt.
Ressourcen-Limits
Ohne Limits kann ein einzelner Container den gesamten Server zum Erliegen bringen. Ein Memory Leak in der App darf nicht dazu führen, dass auch die Datenbank abraucht.
services:
app:
image: myapp:latest
deploy:
resources:
limits:
cpus: '1.0' # Max. 1 CPU-Core
memory: 512M # Max. 512 MB RAM
reservations:
cpus: '0.25' # Mindestens 0.25 Cores reserviert
memory: 128M # Mindestens 128 MB reserviert
db:
image: postgres:16-alpine
deploy:
resources:
limits:
cpus: '2.0'
memory: 1G
reservations:
memory: 256M
Achtung: Das deploy-Schlüsselwort unter Compose V2 gilt für Standalone-Compose. In Swarm-Kontexten verhält es sich anders. Alternativ kann man auch direkt mem_limit und cpus auf Service-Ebene setzen.
Logging & Log-Rotation
Container schreiben Logs in /var/lib/docker/containers/ – ohne Begrenzung. Wer das nicht im Griff hat, erlebt irgendwann einen vollen Disk, der alle Services lahmlegt.
services:
app:
image: myapp:latest
logging:
driver: "json-file"
options:
max-size: "50m" # Max. 50 MB pro Log-Datei
max-file: "5" # Max. 5 rotierende Dateien = 250 MB gesamt
# Alternativ: systemd journal
nginx:
image: nginx:alpine
logging:
driver: "journald"
options:
tag: "nginx-prod"
Für zentralisiertes Logging empfiehlt sich ein Loki-Stack (Grafana + Loki + Promtail) oder zumindest ein strukturierter Log-Output im JSON-Format, den man mit journald oder einem externen Aggregator weiterverarbeitet.
Zero-Downtime Deployments
Ein einfaches docker compose up -d --build erzeugt kurze Downtime: Der alte Container wird gestoppt, der neue gestartet. Für viele Setups ist das tolerierbar – aber es geht besser.
Strategie: Blue/Green mit zwei Compose-Projekten
Die pragmatischste Lösung ohne Kubernetes: Zwei parallele Compose-Projekte (app-blue und app-green), zwischen denen ein Nginx-Upstream umschaltet.
#!/bin/bash
# deploy.sh - Blue/Green Deployment
CURRENT=$(cat /tmp/current_slot 2>/dev/null || echo "blue")
NEW=$([ "$CURRENT" = "blue" ] && echo "green" || echo "blue")
echo "Deploying to $NEW slot..."
# Neuen Slot aufbauen
docker compose -p "app-${NEW}" -f docker-compose.yml up -d --build --wait
# Warten bis Health Checks grün
echo "Waiting for health checks..."
sleep 10
# Nginx-Upstream auf neuen Slot umschalten
sed -i "s/app-${CURRENT}/app-${NEW}/g" /etc/nginx/conf.d/upstream.conf
nginx -s reload
# Alten Slot herunterfahren
docker compose -p "app-${CURRENT}" down
echo "$NEW" > /tmp/current_slot
echo "Deployment complete. Active slot: $NEW"
Einfachere Alternative: Recreate mit kurzem Ausfall
Für Services, bei denen 5–30 Sekunden Downtime akzeptabel sind (interne Tools, APIs mit Retry-Logik):
# Neues Image pullen und Services rolling neu starten
docker compose pull
docker compose up -d --no-deps --build app worker
# Oder mit explizitem Wait auf Health Checks
docker compose up -d --wait app
Häufige Fehler – und wie man sie vermeidet
1. Volumes für flüchtige Daten mounten
Named Volumes sind für persistente Daten (Datenbank, Uploads). Flüchtige Cache-Daten sollten tmpfs-Mounts bekommen:
services:
app:
tmpfs:
- /tmp
- /app/cache:size=100m # RAM-basierter Cache, max 100 MB
2. latest-Tag in Production
image: myapp:latest in Production ist gefährlich – man weiß nie, was tatsächlich deployed ist, und Rollbacks werden schwierig. Immer konkrete Tags (Git-SHA oder Semantic Versioning) verwenden:
# Schlecht:
image: myapp:latest
# Gut:
image: myapp:1.4.2
# Oder mit Git-SHA aus CI:
image: myapp:${GIT_SHA}
3. Keine Backup-Strategie für Volumes
Named Volumes liegen unter /var/lib/docker/volumes/ und sind bei einem docker compose down -v weg. Automatisierte Backups, bevor Deployments laufen:
# PostgreSQL-Dump vor Deployment
docker compose exec db pg_dump -U ${DB_USER} ${DB_NAME} \
| gzip > /backups/pre-deploy-$(date +%Y%m%d-%H%M%S).sql.gz
4. Port-Binding auf 0.0.0.0
ports: "5432:5432" bindet PostgreSQL an alle Interfaces – damit ist die Datenbank direkt aus dem Internet erreichbar, wenn keine Firewall davorsteht. Immer explizit auf localhost binden:
# Schlecht - öffentlich erreichbar:
ports:
- "5432:5432"
# Gut - nur lokal:
ports:
- "127.0.0.1:5432:5432"
5. Fehlende Graceful Shutdown-Konfiguration
Docker sendet beim Stoppen erst SIGTERM, wartet dann 10 Sekunden und sendet SIGKILL. Für Services mit langen Requests (Datei-Uploads, Datenbank-Transaktionen) ist das zu kurz:
services:
app:
image: myapp:latest
stop_grace_period: 60s # 60s Zeit für sauberes Herunterfahren
stop_signal: SIGTERM
Fazit: Compose produktionsreif in 8 Punkten
Docker Compose ist kein Spielzeug für die Entwicklung – es ist ein vollwertiges Deployment-Werkzeug, solange man es ernstnimmt. Die acht wichtigsten Punkte zusammengefasst:
- Klare Dateistruktur mit separaten Prod- und Dev-Overrides
- Secrets aus dem Code raus –
.env-Datei oder Docker Secrets - Restart Policy
unless-stoppedfür alle Production-Services - Health Checks für alle Services,
depends_onmitcondition: service_healthy - Netzwerk-Isolation – Datenbank-Container nicht direkt erreichbar
- Ressourcen-Limits – ein Service darf nicht alle anderen aushungern
- Log-Rotation konfigurieren, bevor der Disk voll ist
- Konkrete Image-Tags statt
latest, plus Backup-Strategie vor Deployments
Das sind keine exotischen Optimierungen – das ist das Minimum, damit ein Compose-Setup nachts ruhig schlafen lässt. Wer diese Punkte abgehakt hat, hat ein Setup, das für viele Production-Szenarien vollkommen ausreicht.
Hast du Fragen zu deinem spezifischen Setup oder möchtest du ein Compose-Deployment für deinen Stack aufsetzen? Schreib mir – ich helfe gerne konkret weiter.