DevOps / CI/CD Best Practices

Docker Compose in Production: Best Practices und häufige Fehler

Docker Compose ist schnell aufgesetzt – aber produktionsreif ist es selten von allein. Umgebungsvariablen, Health Checks, Restart Policies und Secrets-Management: Hier sind die Punkte, die im Ernstfall den Unterschied machen.

E
Emre Hayta
· · 11 Min. Lesezeit
Docker Docker Compose DevOps Production CI/CD

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:

Text
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:

Bash
# 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.

yaml
# 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
Bash
# .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).

yaml
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.

Termin buchen →

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).

yaml
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.

yaml
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:

Python
# 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.

yaml
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.

yaml
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.

yaml
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.

Bash
#!/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):

Bash
# 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:

yaml
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:

yaml
# 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:

Bash
# 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:

yaml
# 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:

yaml
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:

  1. Klare Dateistruktur mit separaten Prod- und Dev-Overrides
  2. Secrets aus dem Code raus.env-Datei oder Docker Secrets
  3. Restart Policy unless-stopped für alle Production-Services
  4. Health Checks für alle Services, depends_on mit condition: service_healthy
  5. Netzwerk-Isolation – Datenbank-Container nicht direkt erreichbar
  6. Ressourcen-Limits – ein Service darf nicht alle anderen aushungern
  7. Log-Rotation konfigurieren, bevor der Disk voll ist
  8. 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.

Dein Compose-Setup produktionsreif machen?

Ich helfe kleinen Teams und KMUs, ihre Docker-Infrastruktur sauber aufzusetzen – von der ersten Konfiguration bis zur automatisierten CI/CD-Pipeline.