Docker Compose Beispiel 2026: Ein Stack, der wirklich läuft — mit eigenen Messungen

Docker Compose Beispiel 2026: Ein Stack, der wirklich läuft — mit eigenen Messungen

Ein Docker Compose Beispiel findet man im Netz an jeder Ecke. Das Problem ist nicht die Verfügbarkeit, sondern dass die meisten dieser Beispiele nie an einem echten Server gemessen wurden. Sie sehen plausibel aus, sie starten sogar — und dann tut die Anwendung etwas anderes als das Tutorial behauptet.

Deshalb ist dieses Docker Compose Beispiel anders aufgebaut: Jede einzelne Aussage unten haben wir am 19. September 2026 auf einem produktiven Linux-Server nachgemessen (Docker 29.2.1, Docker Compose v5.0.2, Ubuntu mit aktivem ufw). Wo eine Messung unserer Erwartung widersprach, steht die Messung im Text — nicht die Erwartung.

Das wichtigste Ergebnis vorweg, weil es das einzige ist, das echten Schaden anrichten kann:

Ein Port in der Form "8412:80" war von einem fremden Server in Helsinki aus mit HTTP 200 erreichbar — obwohl unsere Firewall aktiv war und Port 8412 nie freigegeben hat. Mit "127.0.0.1:8412:80" war derselbe Port von außen dicht.

Das ist kein Docker-Fehler und auch kein Konfigurationsfehler im engeren Sinn. Es ist das dokumentierte Verhalten von Docker — und es steht in fast keinem Compose-Beispiel, obwohl praktisch jedes Beispiel die kurze, offene Schreibweise verwendet.

Ein futuristischer Korridor aus türkisfarbenen Glaspaneelen, der auf einen hell leuchtenden Tunnel zuläuft

Das minimale Docker Compose Beispiel

Fangen wir mit dem kleinstmöglichen Stack an, der wirklich etwas tut. Datei anlegen als compose.yaml:

services:
  web:
    image: nginx:1.27-alpine
    ports:
      - "127.0.0.1:8411:80"

Starten:

docker compose up -d

Bei uns dauerte das 3,99 Sekunden inklusive Image-Pull-Prüfung. Danach:

curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8411/
# 200

Das war’s. Kein version:-Eintrag, kein build:, keine 80 Zeilen Boilerplate. Wenn Ihnen ein Docker Compose Beispiel als Erstes eine version: "3.8"-Zeile zeigt, ist es veraltet — dazu gleich mehr.

Die drei Befehle, die Sie zu 90 % brauchen

docker compose up -d        # starten, im Hintergrund
docker compose ps           # was läuft gerade?
docker compose logs -f web  # Logs eines Dienstes mitlesen
docker compose down         # alles stoppen und entfernen

docker compose ps gab bei uns aus:

NAME             PORTS
minimal-web-1    127.0.0.1:8411->80/tcp

Merken Sie sich diese Ausgabe. Sie ist gleich der Kern des ersten großen Befunds.

docker compose oder docker-compose? Die Antwort ist eindeutig

Das ist die häufigste Verwirrung bei Einsteigern, weil beide Schreibweisen in Tutorials auftauchen — mit und ohne Bindestrich.

  • docker-compose (mit Bindestrich) war Version 1, geschrieben in Python, als separates Programm installiert. Sie ist End of Life und bekommt keine Updates mehr.
  • docker compose (mit Leerzeichen) ist Version 2+, geschrieben in Go, als Plugin Teil von Docker selbst.

Auf unserem System gemessen:

docker compose version
# Docker Compose version v5.0.2

which docker-compose
# NICHT installiert

Das ist der Normalzustand auf aktuellen Installationen: Die alte Variante ist gar nicht mehr da. Wenn ein Tutorial Ihnen docker-compose up sagt und der Befehl nicht existiert, ist nicht Ihre Installation kaputt — das Tutorial ist alt.

Die version:-Zeile ist nicht nur überflüssig, sie wird aktiv bemängelt

Wir haben es ausprobiert und eine version: "3.8"-Zeile in eine funktionierende Datei eingefügt:

level=warning msg="the attribute `version` is obsolete, it will be ignored,
please remove it to avoid potential confusion"

Compose sagt es also selbst: obsolete, wird ignoriert. Die Zeile stammt aus der Zeit vor der Compose Specification und hat heute keinerlei Wirkung. Lassen Sie sie weg.

Auch der Dateiname hat sich geändert: Die Spezifikation bevorzugt compose.yaml. docker-compose.yml funktioniert weiterhin, ist aber die ältere Konvention.

Fund 1: Ihr Port-Mapping kann die Firewall umgehen

Das ist der Teil, bei dem ein hübsches Docker Compose Beispiel echten Schaden anrichten kann. Fast alle Beispiele im Netz schreiben Ports so:

ports:
  - "8412:80"   # ⚠️ offen für die ganze Welt

Das sieht harmlos aus. Es bedeutet aber: an allen Netzwerkschnittstellen lauschen, nicht nur lokal. Wir haben zwei Container gestartet — einen mit der kurzen, einen mit der expliziten Schreibweise — und dann von außen gemessen.

Erst die lokale Sicht auf dem Server selbst:

ss -tlnp | grep 8412
# LISTEN 0 4096 0.0.0.0:8412 0.0.0.0:* users:(("docker-proxy",...))
# LISTEN 0 4096    [::]:8412    [::]:* users:(("docker-proxy",...))

ss -tlnp | grep 8411
# LISTEN 0 4096 127.0.0.1:8411 0.0.0.0:* users:(("docker-proxy",...))

Und jetzt der Teil, den man nicht auf dem eigenen Server messen darf. Ein curl auf die eigene öffentliche IP läuft über das Loopback-Interface und durchläuft die Firewall-Kette nie — das Ergebnis wäre wertlos. Wir haben deshalb von einem anderen Server in Helsinki aus gemessen:

# ausgeführt auf einem fremden Host, Ziel = unser Server
port 8411: zu/gefiltert
port 8412: OFFEN
HTTP 8412 -> 200

Port 8412 lieferte HTTP 200 an einen wildfremden Rechner im Internet. Dabei war ufw die ganze Zeit aktiv, und 8412 stand in keiner einzigen Regel.

Der Grund liegt in der Reihenfolge der Firewall-Ketten. Docker schreibt seine Regeln in eine eigene DOCKER-Kette, die im FORWARD-Pfad vor den ufw-Regeln greift:

iptables -L FORWARD -n | head -4
# Chain FORWARD (policy DROP)
# DOCKER-USER      0 -- 0.0.0.0/0  0.0.0.0/0
# DOCKER-FORWARD   0 -- 0.0.0.0/0  0.0.0.0/0
# ufw-before-forward ...

ufw sagt „verboten”, Docker hat vorher schon „erlaubt” gesagt. Es gibt keine Fehlermeldung, keine Warnung, keinen Logeintrag. Es funktioniert einfach — für alle.

Die Regel: Schreiben Sie jeden Port explizit auf 127.0.0.1, außer der Dienst soll nachweislich öffentlich sein.

ports:
  - "127.0.0.1:8412:80"   # ✅ nur lokal, Reverse Proxy davor

Wir setzen das hier nicht aus Prinzipienreiterei durch. Wir hatten eine Meldung des BSI im Haus, weil eine PostgreSQL-Instanz über genau diese kurze Schreibweise offen im Netz stand. Das Muster ist immer dasselbe: Ein Beispiel aus dem Netz kopiert, gestartet, es funktionierte — und niemand hat je von außen nachgesehen.

Wer den Dienst wirklich öffentlich braucht, stellt einen Reverse Proxy davor, der TLS und Zugriffsregeln übernimmt. Wie das aussieht, haben wir in unserem Artikel zum nginx Reverse Proxy im Detail beschrieben; die Grundidee erklärt Was ist ein Reverse Proxy?.

Fund 2: depends_on wartet nicht auf das, was Sie denken

Das zweite große Missverständnis. Ein typisches Docker Compose Beispiel für App plus Datenbank sieht so aus:

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: lab
      POSTGRES_DB: lab
  app:
    image: postgres:16-alpine
    depends_on:
      - db          # ⚠️ wartet nur auf den Container-Start
    command: >
      sh -c "psql -h db -U postgres -d lab -c 'select 1'
             && echo CONNECT_OK || echo CONNECT_FAILED"
    environment:
      PGPASSWORD: lab

Das sieht vollkommen richtig aus. Gestartet ergab es bei uns:

app-1  | psql: error: connection to server at "db" (172.23.0.2), port 5432 failed:
         Connection refused
app-1  | CONNECT_FAILED

depends_on in dieser Kurzform garantiert nur die Startreihenfolge des Containers, nicht die Bereitschaft des Dienstes darin. Um zu zeigen, wie groß das Zeitfenster wirklich ist, haben wir beide Momente getrennt gestoppt:

EreignisZeit nach up
Docker meldet Container-Status running0,02 s
Postgres nimmt tatsächlich Verbindungen an1,21 s
Lücke, in der „läuft” und „bereit” auseinanderfallen1,19 s
Gemessen am 19.09.2026, postgres:16-alpine, Docker 29.2.1. .State.Running per docker inspect abgefragt, Bereitschaft per pg_isready in einer Schleife mit 0,1 s Auflösung.

Docker meldet Erfolg, 60-mal bevor die Datenbank bereit ist. Deshalb funktionieren solche Stacks auf dem schnellen Entwickler-Laptop und fallen auf dem langsameren Server um.

Die Lösung: healthcheck plus condition: service_healthy

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: lab
      POSTGRES_DB: lab
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d lab"]
      interval: 2s
      timeout: 3s
      retries: 15
      start_period: 3s
  app:
    image: postgres:16-alpine
    depends_on:
      db:
        condition: service_healthy   # ✅ wartet auf Bereitschaft
    command: >
      sh -c "psql -h db -U postgres -d lab -c 'select 1' >/dev/null
             && echo CONNECT_OK || echo CONNECT_FAILED"
    environment:
      PGPASSWORD: lab

Gegenprobe, identischer Stack, identische Images:

app-1  | CONNECT_OK

Aus Connection refused wurde CONNECT_OK, in 6,78 Sekunden Gesamtlaufzeit. Der Unterschied sind sieben Zeilen YAML.

Zwei leuchtende bogenförmige Portale vor dunkelblauem Hintergrund, das linke rahmt aufschlagende Wellen, das rechte strahlt einen ruhigen warmen Farbverlauf aus

Fund 3: interval bestimmt Ihre Wartezeit — nicht die Datenbank

Das ist der Befund, mit dem wir selbst nicht gerechnet hatten, und er betrifft fast jedes Compose-Beispiel im Netz, weil interval: 30s dort als Standardwert kursiert.

Wir haben denselben Stack dreimal gestartet und nur den interval-Wert verändert:

intervalApp startete nach
2s3,58 s
10s11,68 s
30s31,73 s
Gemessen am 19.09.2026, identischer Stack, jeweils frisch mit down -v zurückgesetzt, Zeit von up bis zum Beenden des abhängigen Containers.

Postgres war in allen drei Fällen nach 1,21 Sekunden bereit. Die restlichen 30 Sekunden wartet man nicht auf die Datenbank — man wartet auf die eigene Konfiguration. Der erste Gesundheitscheck läuft nämlich erst nach Ablauf eines vollen Intervalls.

Wir haben den Verlauf während des Wartens gegengeprüft, um sicherzugehen, dass es kein Hänger ist:

docker inspect -f '{{json .State.Health}}' $CID
# Status: starting | Checks im Log: 0

Null Checks nach 25 Sekunden. Kein Fehler, kein Timeout — es war schlicht noch kein Intervall vorbei.

start_period und start_interval lösen genau das

healthcheck:
  test: ["CMD-SHELL", "pg_isready -U postgres -d lab"]
  interval: 30s          # Rhythmus im Normalbetrieb
  timeout: 3s
  retries: 10
  start_period: 30s      # Schonfrist beim Start
  start_interval: 1s     # in der Schonfrist jede Sekunde prüfen

Derselbe interval: 30s, dreimal gemessen:

KonfigurationApp startete nach
interval: 30s, sonst nichts31,73 s
+ start_period: 30s6,81 s
+ start_period: 30s + start_interval: 1s3,75 s
Gemessen am 19.09.2026, identische Images und Hardware. Der mittlere Wert entsteht durch den Standardwert von start_interval (5 s), der innerhalb der Schonfrist greift.

Faktor 8,5 durch zwei Zeilen. start_period allein bringt den größten Sprung, start_interval holt den Rest. Ehrlicherweise: Wir hatten zunächst nur start_interval als Erklärung im Kopf und mussten die mittlere Zeile nachmessen, um den Anteil sauber zu trennen. Wer nur die erste und die letzte Zeile misst, schreibt den ganzen Effekt der falschen Option zu.

Die meisten Tutorials empfehlen start_period — aber als Schutz gegen Fehlalarme, nicht als Beschleuniger. Beides stimmt; der Zeiteffekt wird nur selten beziffert.

Fund 4: Der Volume-Name kommt aus Ihrem Ordnernamen

Volumes sind der Grund, warum Container-Daten einen Neustart überleben. Ein Docker Compose Beispiel dazu:

services:
  keeper:
    image: alpine:3.20
    volumes:
      - lab_data:/data
    command: ["sh","-c","echo wichtig-$(date +%s) >> /data/state.txt; cat /data/state.txt"]
volumes:
  lab_data:

Gemessenes Verhalten über drei Läufe:

Lauf 1:                          wichtig-1789801499
nach 'docker compose down':      wichtig-1789801499
                                 wichtig-1789801500     ← Daten überlebt
nach 'docker compose down -v':   wichtig-1789801501     ← alles weg

down behält die Daten, down -v löscht sie — ohne Rückfrage, ohne Warnung, ohne Ausgabe. Es gibt keinen Unterschied in der Rückmeldung zwischen „Stack gestoppt” und „Stack gestoppt und Ihre Datenbank gelöscht”.

Und jetzt die Falle, die wirklich weh tut

Compose leitet den Projektnamen standardmäßig vom Verzeichnisnamen ab. Das Volume lab_data heißt auf der Platte in Wahrheit:

docker volume ls | grep lab_data
# vol_lab_data        ← Präfix = Ordnername "vol"

Wir haben das Projekt in ein völlig anderes Verzeichnis kopiert — mit demselben Ordnernamen — und beide gestartet:

/tmp/dclab/vol         → wichtig-1789801514
/tmp/dclab/kopie/vol   → wichtig-1789801514     ← dieselben Daten!
                         wichtig-1789801514

docker volume ls | grep -c "^vol_lab_data$"
# 1                    ← nur EIN Volume existiert

Zwei getrennte Projekte an verschiedenen Orten greifen auf dasselbe Volume zu. Wer zwei Kunden-Stacks beide im Ordner app oder docker liegen hat, mischt deren Daten — und merkt es erst, wenn in der Testumgebung plötzlich Produktivdaten auftauchen.

Die Lösung ist eine Zeile:

name: kundenprojekt-a    # expliziter Projektname
services:
  ...

Zwei futuristische zylindrische Behälter mit leuchtenden Partikeln, verbunden durch einen Lichtstrom, der zwischen ihnen überträgt

Named Volume oder Bind Mount?

volumes:
  - lab_data:/data          # Named Volume: Docker verwaltet den Ort
  - ./config:/etc/app:ro    # Bind Mount: konkreter Pfad vom Host

Die Faustregel aus unserer Praxis: Named Volumes für Daten, die die Anwendung schreibt (Datenbanken, Uploads), Bind Mounts für Dinge, die Sie selbst pflegen (Konfigurationsdateien, Zertifikate). Bei Bind Mounts mit Konfiguration gehört :ro dahinter — ein Container, der seine eigene Konfiguration überschreiben kann, ist eine schlechte Idee.

Fund 5: .env ist nicht das, was Sie denken — es sind zwei Ebenen

Hier verwechseln fast alle zwei völlig verschiedene Dinge, weil sie denselben Dateinamen tragen.

Erst die Präzedenz. Eine .env mit zwei Variablen, dazu ein Stack, der beide durchreicht:

A) nur .env-Datei:
   GREETING=aus_env_datei
   SHELL_VAR=aus_env_datei

B) Shell-Variable gesetzt, .env unverändert:
   GREETING=aus_env_datei
   SHELL_VAR=aus_der_shell     ← Shell schlägt Datei

Eine gesetzte Umgebungsvariable der Shell überschreibt den Wert aus .env. Das ist nützlich für einmalige Abweichungen (TAG=v2 docker compose up -d) und eine Fehlerquelle, wenn eine alte exportierte Variable noch in Ihrer Sitzung herumliegt.

Jetzt die eigentliche Falle. Die Datei .env liegt direkt neben compose.yaml — landet sie damit im Container?

A) ohne environment: und ohne env_file:
   im Container: [NICHT_GESETZT]

B) mit env_file: .env
   im Container: [aus_env_datei]

Nein. .env wird von Compose gelesen, um ${...}-Platzhalter in der YAML-Datei zu ersetzen. Das ist eine Ebene vor dem Container. Damit eine Variable im Container ankommt, braucht es environment: oder env_file: — sonst ist sie dort schlicht nicht vorhanden, obwohl die Datei einen Meter danebenliegt.

services:
  app:
    image: alpine:3.20
    env_file: .env              # ganze Datei in den Container
    environment:
      DB_HOST: db               # einzelner fester Wert
      DB_PASS: ${DB_PASS}       # aus .env interpoliert

Ein leuchtendes futuristisches Podest aus durchscheinenden goldenen und türkisfarbenen Ebenen vor dunkelblauem Hintergrund

Ehrlicher Zwischenfall aus dieser Messung: Unser erster Testlauf lieferte für beide Fälle aus_env_datei — scheinbar ein Beweis dafür, dass .env automatisch durchgereicht wird. Tatsächlich hatten wir ${GREETING} im command: stehen, und Compose ersetzte das bereits auf dem Host beim Parsen der Datei. Gemessen wurde also die Interpolation, nicht der Container. Erst mit $$GREETING (literal escaped) zeigte sich der echte Unterschied. Ein Messaufbau, der die zu untersuchende Ebene selbst auflöst, bestätigt zuverlässig die falsche Antwort.

Und die Sicherheitsregel dazu: .env enthält Passwörter und gehört in .gitignore. Was stattdessen ins Repository kommt, ist eine .env.example mit leeren Werten. Für echte Geheimnisse in Produktion gibt es secrets:, das Werte als Datei einhängt statt als Umgebungsvariable — Umgebungsvariablen tauchen in docker inspect und in Prozesslisten auf.

Fund 6: Netzwerke isolieren bis in die Namensauflösung

Standardmäßig legt Compose ein Netzwerk an, in dem sich alle Dienste unter ihrem Service-Namen erreichen. Für einen echten Stack will man mehr Trennung:

services:
  frontend:
    image: alpine:3.20
    networks: [public_net]
  backend:
    image: alpine:3.20
    networks: [public_net, private_net]
  db:
    image: alpine:3.20
    networks: [private_net]
networks:
  public_net:
  private_net:

Gemessen:

frontend -> db:   NICHT_ERREICHBAR
                  ** server can't find db: SERVFAIL
backend  -> db:   ERREICHBAR

Das Entscheidende ist die zweite Zeile: frontend bekommt keinen „Zugriff verweigert”, sondern kann den Namen db gar nicht auflösen. Die Isolation greift im DNS, nicht erst im Paketfilter. Für eine Datenbank heißt das: Sie ist für den öffentlich erreichbaren Dienst schlicht nicht existent.

Drei leuchtende glasartige geometrische Elemente in einer Reihe, ein türkisfarbenes Element flankiert von zwei warm-gelben Kugeln mit inneren Netzwerkknoten

Kombiniert mit der 127.0.0.1-Regel von oben ergibt das die Grundform eines sicheren Stacks: Die Datenbank hat gar kein ports:-Mapping, sie ist nur im internen Netz erreichbar. Nur der Reverse Proxy bindet einen Port, und auch der nur lokal.

services:
  db:
    image: postgres:16-alpine
    networks: [private_net]
    # kein ports: — absichtlich
  app:
    image: my-app
    networks: [private_net, public_net]
  proxy:
    image: nginx:1.27-alpine
    ports:
      - "127.0.0.1:8080:80"
    networks: [public_net]

restart: Was passiert, wenn etwas abstürzt

restart: on-failure:3

Gemessen mit einem Container, der sofort mit Exit-Code 1 endet:

RestartCount: 3
State: exited exit=1

Genau drei Versuche, dann gibt Docker auf. Die Optionen im Überblick:

WertVerhalten
noStandard, kein Neustart
on-failure[:n]Neustart nur bei Exit ≠ 0, optional mit Obergrenze
alwaysimmer neu starten, auch nach manuellem Stopp beim Daemon-Neustart
unless-stoppedwie always, aber respektiert ein manuelles stop

Für Server-Dienste ist unless-stopped meist die richtige Wahl: Der Dienst kommt nach einem Reboot zurück, bleibt aber unten, wenn Sie ihn bewusst gestoppt haben.

compose watch: Entwickeln ohne ständiges Neubauen

Für lokale Entwicklung gibt es seit Compose v2 einen eigenen Modus, der Dateiänderungen beobachtet:

services:
  app:
    build: .
    develop:
      watch:
        - action: sync
          path: ./src
          target: /app/src
        - action: rebuild
          path: package.json
docker compose watch

sync kopiert geänderte Dateien in den laufenden Container, rebuild baut das Image neu — je nachdem, was sich geändert hat. Quellcode wird synchronisiert, eine geänderte Abhängigkeitsliste löst einen echten Neubau aus. Das ersetzt den früheren Reflex, für jede Änderung docker compose up --build laufen zu lassen.

Das vollständige Docker Compose Beispiel

Alles Gemessene zusammengesetzt zu einem Stack, den man produktiv betreiben kann:

name: meinprojekt

services:
  db:
    image: postgres:16-alpine
    restart: unless-stopped
    networks: [private_net]
    volumes:
      - db_data:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 30s
      start_interval: 1s

  cache:
    image: redis:7-alpine
    restart: unless-stopped
    networks: [private_net]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 30s
      timeout: 3s
      retries: 5
      start_period: 10s
      start_interval: 1s

  app:
    build: .
    restart: unless-stopped
    networks: [private_net, public_net]
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy
    env_file: .env
    environment:
      DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
      REDIS_URL: redis://cache:6379

  proxy:
    image: nginx:1.27-alpine
    restart: unless-stopped
    networks: [public_net]
    depends_on:
      - app
    ports:
      - "127.0.0.1:8080:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro

volumes:
  db_data:

networks:
  private_net:
  public_net:

Was daran aus den Messungen oben folgt, Punkt für Punkt:

  • name: explizit — sonst entscheidet der Ordnername über Ihre Volume-Namen (Fund 4)
  • 127.0.0.1: vor jedem Port — sonst umgeht Docker die Firewall (Fund 1)
  • Die Datenbank hat kein ports: — sie muss von außen nicht erreichbar sein (Fund 6)
  • condition: service_healthy statt der Kurzform — sonst Connection refused (Fund 2)
  • start_period plus start_interval — sonst warten Sie 30 statt 4 Sekunden (Fund 3)
  • :ro beim Konfigurations-Mount — der Container soll seine Konfiguration nicht ändern können
  • Geheimnisse über .env, die Datei in .gitignore

Häufige Fehler und wie man sie erkennt

Der Container läuft, die Anwendung antwortet trotzdem nicht

Ein Zustand, den wir schon bei einer Shopware-Installation dokumentiert haben: Der Container meldet running, das Log meldet Erfolg — und der erste Aufruf liefert HTTP 400. Ursache war dort der Host-Header, nicht der Container. Die Lehre gilt allgemein: docker compose ps beweist, dass ein Prozess läuft, nicht dass er das Richtige tut. Prüfen Sie immer mit einem echten Aufruf gegen den Dienst, nicht gegen den Status.

YAML-Fehler an einer Stelle, die richtig aussieht

Bei der Arbeit an diesem Artikel produzierten wir selbst:

yaml: line 4: mapping values are not allowed in this context

Ursache war ein Doppelpunkt in einer nicht gequoteten command:-Zeile. YAML liest ihn als Trennzeichen eines neuen Schlüssels. Bei Befehlen mit Doppelpunkten, Klammern oder Variablen hilft die Listenform:

command: ["sh", "-c", "echo hallo: welt"]

Und vor jedem Start: docker compose config zeigt die vollständig aufgelöste Konfiguration inklusive aller interpolierten Variablen. Das ist der schnellste Weg zu sehen, was Compose wirklich verstanden hat — statt was Sie gemeint haben.

Der Stack läuft lokal, aber nicht auf dem Server

Fast immer Fund 3 in freier Wildbahn: Auf der schnellen Entwicklermaschine ist die Datenbank in 0,4 Sekunden bereit und gewinnt das Rennen gegen die App. Auf dem langsameren Server nicht mehr. Ein Stack ohne condition: service_healthy funktioniert nicht zuverlässig — er hat nur Glück.

Grenzen dieser Messungen

Was wir nicht behaupten, weil wir es nicht gemessen haben:

  • Keine Performance-Zahlen zu Container-Overhead. Unser Server ist selbst eine virtuelle Maschine ohne verschachtelte Virtualisierung; belastbare Vergleiche gegen Bare Metal wären erfunden.
  • Keine Aussagen zu Docker Swarm oder Kubernetes. Compose ist ein Werkzeug für einen Host. Der Sprung zu mehreren Knoten ist ein anderes Thema mit anderen Regeln.
  • Die Firewall-Messung gilt für iptables mit ufw. Auf Systemen mit reinem nftables oder firewalld kann die Kettenreihenfolge abweichen. Der Ratschlag bleibt identisch, die exakte Ursachenkette nicht zwingend.
  • Die Zeitmessungen stammen von einem Server. Absolute Werte hängen von Hardware und Last ab; die Verhältnisse (Faktor 60 bei running vs. bereit, Faktor 8,5 durch start_period) sind das Belastbare.

Fazit

Ein brauchbares Docker Compose Beispiel unterscheidet sich von einem hübschen an drei Stellen, und alle drei sind unsichtbar, solange nichts schiefgeht:

Erstens beim Port-Mapping. Die kurze Schreibweise "8080:80" hat in unserer Messung einen Dienst aus dem Internet erreichbar gemacht, obwohl die Firewall aktiv war und den Port nie freigegeben hat. Das ist kein Randfall — es ist der Normalfall in fast jedem Beispiel im Netz.

Zweitens bei depends_on. Die Kurzform wartet auf einen Container, nicht auf einen Dienst. Die Lücke betrug bei uns 1,19 Sekunden — genug, um Connection refused zu erzeugen, und genau die Art Fehler, die auf dem eigenen Rechner nie auftritt.

Drittens bei den Standardwerten. interval: 30s kostete uns 31,73 Sekunden Wartezeit für eine Datenbank, die nach 1,21 Sekunden bereit war. Zwei Zeilen brachten das auf 3,75 Sekunden.

Was alle drei gemeinsam haben: Sie erzeugen keine Fehlermeldung. Der Stack startet, docker compose ps sieht gesund aus, und die Konsequenz zeigt sich erst beim ersten Lastspitzen-Neustart oder beim ersten Portscan von außen. Genau deshalb haben wir für diesen Artikel gemessen statt zusammengefasst — und genau deshalb steht unsere eigene Fehlmessung bei .env mit im Text.

Wer tiefer in die Serverseite einsteigen will: Unser Leitfaden Linux Server einrichten behandelt die Härtung des Hosts, auf dem diese Container laufen, und IT-Sicherheitslücken zeigt, wie aus einem offenen Port ein echter Vorfall wird.

Häufige Fragen

Was ist ein Docker Compose Beispiel in seiner einfachsten Form?

Vier Zeilen YAML genügen: ein services:-Block, darunter ein Dienstname, ein image: und optional ein ports:-Eintrag. Gestartet wird mit docker compose up -d. Bei uns lief ein solcher Minimal-Stack in 3,99 Sekunden und antwortete mit HTTP 200. Eine version:-Zeile braucht es nicht mehr — Compose meldet sie ausdrücklich als veraltet.

Was ist der Unterschied zwischen docker compose und docker-compose?

docker-compose mit Bindestrich ist Version 1, in Python geschrieben und End of Life. docker compose mit Leerzeichen ist Version 2+, in Go geschrieben und als Plugin Teil von Docker. Auf aktuellen Installationen existiert die alte Variante meist gar nicht mehr — auf unserem Testsystem war docker-compose nicht installiert, während docker compose version v5.0.2 meldete.

Warum wartet depends_on nicht, bis meine Datenbank bereit ist?

Weil die Kurzform von depends_on nur die Startreihenfolge des Containers steuert, nicht die Bereitschaft des Dienstes darin. Wir haben beide Momente getrennt gemessen: Docker meldete den Container nach 0,02 Sekunden als laufend, Postgres nahm erst nach 1,21 Sekunden Verbindungen an. In dieser Lücke scheitert die abhängige Anwendung mit Connection refused. Abhilfe schafft ein healthcheck auf dem Zieldienst plus condition: service_healthy.

Wie schreibe ich einen healthcheck in Docker Compose richtig?

Mit einem Befehl, der den Dienst wirklich befragt, statt nur den Prozess zu prüfen — bei Postgres etwa pg_isready, bei Redis redis-cli ping. Entscheidend sind zusätzlich start_period und start_interval: Ohne sie läuft der erste Check erst nach einem vollen interval. Bei uns bedeutete das 31,73 Sekunden Wartezeit statt 3,75 Sekunden — bei identischer Datenbank.

Ist ein Port in Docker Compose durch meine Firewall geschützt?

Nein, nicht automatisch. Ein Mapping der Form "8412:80" bindet an alle Schnittstellen, und Dockers iptables-Regeln greifen im FORWARD-Pfad vor denen von ufw. In unserer Messung war der Port von einem fremden Server im Internet mit HTTP 200 erreichbar, obwohl ufw aktiv war und ihn nie freigegeben hatte. Schreiben Sie Ports deshalb als "127.0.0.1:8412:80" und stellen Sie einen Reverse Proxy davor.

Wie teste ich, ob mein Docker-Port wirklich von außen offen ist?

Nicht vom Server selbst. Ein curl auf die eigene öffentliche IP läuft über das Loopback-Interface und durchläuft die Firewall-Kette nie — das Ergebnis ist wertlos und sieht trotzdem nach einem Test aus. Prüfen Sie von einem anderen Rechner außerhalb Ihres Netzes, etwa mit nc -zv IHRE-IP PORT oder einem curl gegen die öffentliche Adresse.

Löscht docker compose down meine Daten?

docker compose down behält Named Volumes, docker compose down -v löscht sie. Wir haben das über drei Läufe nachgewiesen: Nach down waren die Daten noch da, nach down -v war der Inhalt weg. Es gibt dabei keine Rückfrage und keine Warnung — die Ausgabe unterscheidet sich nicht von einem harmlosen Stopp.

Warum nutzen zwei verschiedene Projekte dasselbe Volume?

Weil Compose den Projektnamen standardmäßig aus dem Verzeichnisnamen ableitet und ihn dem Volume voranstellt. Zwei Projekte in verschieden gelegenen Ordnern mit demselben Namen — etwa app — teilen sich damit das Volume. Wir haben das gemessen: Beide Stacks lasen dieselbe Datei, und auf der Platte existierte nur ein einziges Volume. Setzen Sie deshalb immer ein explizites name: in der compose.yaml.

Wird meine .env-Datei automatisch in den Container übernommen?

Nein. .env wird von Compose gelesen, um ${...}-Platzhalter in der YAML-Datei zu ersetzen — das passiert auf dem Host, eine Ebene vor dem Container. Damit eine Variable im Container ankommt, brauchen Sie env_file: oder environment:. In unserem Test war die Variable ohne diese Einträge im Container schlicht nicht gesetzt, obwohl die Datei direkt neben der compose.yaml lag.

Welche restart-Policy sollte ich für Serverdienste wählen?

Meist unless-stopped. Der Dienst kommt nach einem Neustart des Hosts von selbst zurück, bleibt aber unten, wenn Sie ihn bewusst gestoppt haben. on-failure:n begrenzt die Versuche — wir haben mit on-failure:3 nachgemessen: exakt drei Neustarts, danach bleibt der Container mit Exit-Code 1 liegen.

Wie verhindere ich, dass meine Datenbank von außen erreichbar ist?

Geben Sie ihr gar kein ports:-Mapping und legen Sie sie in ein eigenes internes Netzwerk. In unserer Messung konnte ein Dienst im anderen Netz den Namen db nicht einmal auflösen — die Antwort war SERVFAIL, nicht etwa eine abgelehnte Verbindung. Die Isolation greift also schon im DNS, lange vor dem Paketfilter.