edu@dorner-it
WerkzeugeEinsteiger20 min

Docker Compose Basics: Mehrere Container orchestrieren

Wie du mehrere Container zu einem Stack zusammensteckst — Services, Netzwerke, Volumes und Profile. Mit einem voll lauffähigen Beispiel aus App + Datenbank + Cache.

Zuletzt aktualisiert: 11. Mai 2026

Mit docker run bekommst du einen Container ans Laufen. Sobald deine Anwendung aus App + Datenbank + Cache + Worker besteht, wird das schnell unhandlich. Docker Compose beschreibt den gesamten Stack in einer YAML-Datei und startet ihn mit einem einzigen Befehl.

Voraussetzung: Du kennst die Konzepte aus dem Kurs Docker Basics (Image, Container, Volume, Netzwerk).

Lernziele

  • Du verstehst Services, Netzwerke und Volumes in Compose
  • Du schreibst eine compose.yaml für einen realistischen Stack
  • Du kennst die wichtigsten Subkommandos
  • Du nutzt profiles, depends_on und Healthchecks sinnvoll

Das Bild im Kopf

flowchart LR
    subgraph stack["docker compose up"]
        W[web<br/><small>nginx</small>]
        A[api<br/><small>node</small>]
        D[db<br/><small>postgres</small>]
        C[cache<br/><small>redis</small>]
        W -- HTTP --> A
        A -- SQL --> D
        A -- cache --> C
    end
    User[Browser] -- :8080 --> W

Ein Service ist ein Container (oder mehrere Replikate desselben Images). Alle Services in derselben Compose-Datei landen automatisch im gleichen Netzwerk und erreichen sich über den Service-Namen.

Dein erstes Compose-File

Lege im Projekt eine Datei compose.yaml an:

services:
  web:
    image: nginx:alpine
    ports:
      - "8080:80"
    depends_on:
      - api
 
  api:
    build: ./api
    environment:
      DATABASE_URL: postgres://app:secret@db:5432/app
      REDIS_URL: redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
 
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: app
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      timeout: 3s
      retries: 5
 
  cache:
    image: redis:7-alpine
 
volumes:
  db-data:

Starten:

docker compose up -d        # alles im Hintergrund starten
docker compose ps           # was läuft?
docker compose logs -f api  # Logs eines Services
docker compose down         # alles stoppen + entfernen
docker compose down -v      # zusätzlich Volumes löschen

Modern ist docker compose (mit Leerzeichen, Plugin). Das alte docker-compose (Bindestrich, separates Binary) ist deprecated.

Was Compose automatisch tut

Konzept Automatik
Netzwerk Ein Default-Netzwerk pro Compose-Projekt
DNS Services sind über ihren Namen erreichbar (db, cache, ...)
Projektname Vom Ordnernamen abgeleitet; mit -p überschreibbar
Restart Mit restart: unless-stopped automatisch wieder hoch

Im Beispiel oben spricht api zu db:5432 und cache:6379 — als wären es lokale Hosts. Compose macht das DNS-Mapping für dich.

build: vs image:

Ein Service kann entweder ein bestehendes Image nutzen oder lokal gebaut werden:

services:
  api:
    build:
      context: ./api          # Verzeichnis mit Dockerfile
      dockerfile: Dockerfile  # optional, default ist Dockerfile
      args:
        NODE_VERSION: "20"
    image: myapp/api:dev      # optional: Name fürs gebaute Image
docker compose build         # nur bauen
docker compose up --build    # bauen + starten

Volumes & Bind Mounts

services:
  api:
    build: ./api
    volumes:
      - ./api/src:/app/src        # Bind Mount (Dev: Hot-Reload)
      - api-node-modules:/app/node_modules  # Named Volume (keep host's modules out)
 
  db:
    image: postgres:16-alpine
    volumes:
      - db-data:/var/lib/postgresql/data  # persistente DB-Daten
 
volumes:
  db-data:
  api-node-modules:

Der zweite Mount im api-Service ist ein häufiger Trick: damit überschreibt das Host-node_modules (oft inkompatibel, falsche Plattform) nicht das Container-node_modules.

Umgebungsvariablen — drei Wege

services:
  api:
    image: myapp
    # 1. Inline
    environment:
      NODE_ENV: production
      PORT: "3000"
 
    # 2. Aus .env-Datei laden
    env_file:
      - .env
      - .env.local
 
    # 3. Vom Host übernehmen (Substitution mit ${VAR})
    environment:
      API_KEY: ${API_KEY}

Compose lädt automatisch eine .env im selben Ordner für die ${...}-Substitution in der YAML-Datei. Das ist nicht dasselbe wie env_file: — das gibt Variablen in den Container, ${...} ersetzt sie vor dem Parsen des YAML.

depends_on & Healthchecks

services:
  api:
    depends_on:
      db:
        condition: service_healthy     # wartet auf Healthcheck
      cache:
        condition: service_started     # nur warten, dass es startet
 
  db:
    image: postgres:16-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      timeout: 3s
      retries: 5
      start_period: 10s
Condition Bedeutung
service_started Container läuft (sagt nichts über Bereitschaft)
service_healthy Healthcheck ist healthy
service_completed_successfully Service ist mit Exit 0 fertig (Init-Jobs)

Wichtig: depends_on ohne condition: service_healthy wartet nicht darauf, dass eine App wirklich bereit ist — nur darauf, dass sie startet. Ohne Healthcheck musst du in deiner App selbst retry-fähig sein.

Profiles — optionale Services

Nicht alle Services sollen immer hochgehen. Beispiel: ein optionaler Mailserver für lokale Tests.

services:
  api:
    build: ./api
 
  mailhog:
    image: mailhog/mailhog
    profiles: ["dev"]
    ports:
      - "8025:8025"
 
  loadgen:
    image: myorg/loadgen
    profiles: ["bench"]
docker compose up                          # nur api
docker compose --profile dev up            # api + mailhog
docker compose --profile bench up loadgen  # nur loadgen

Overrides — Dev vs Prod

Compose liest standardmäßig zwei Dateien zusammen:

  1. compose.yaml (Basis)
  2. compose.override.yaml (Dev-Overrides, optional)

Du kannst aber auch explizit kombinieren:

docker compose \
  -f compose.yaml \
  -f compose.prod.yaml \
  up -d

Spätere Dateien überschreiben Felder aus früheren. Klassisches Muster:

# compose.yaml — gemeinsame Basis
services:
  api:
    image: myapp/api
    environment:
      NODE_ENV: production
# compose.override.yaml — automatisch für Dev geladen
services:
  api:
    build: ./api
    volumes:
      - ./api/src:/app/src
    environment:
      NODE_ENV: development

Subkommandos im Alltag

docker compose up -d              # Stack starten
docker compose ps                 # Status
docker compose logs -f api        # Logs eines Service
docker compose exec api sh        # Shell im laufenden Container
docker compose run --rm api npm test   # einmalig was ausführen
docker compose restart api        # einzelnen Service neu starten
docker compose pull               # neueste Images holen
docker compose down               # stoppen + Netzwerk weg
docker compose down -v            # zusätzlich Volumes weg

run vs exec: exec läuft im bestehenden Container, run startet einen neuen. Für Migrationen und Einmal-Tasks ist run --rm die richtige Wahl.

Häufige Stolperfallen

„Mein Service erreicht den anderen nicht"

  • Verwendest du Service-Namen als Host, nicht localhost? localhost im Container ist der Container selbst, nicht der Host.
  • Sind beide Services im gleichen Compose-Projekt? Compose erkennt das am Ordnernamen — docker compose -p setzt ihn explizit.

„Datenbank ist leer nach down"

Hast du down -v benutzt? Das löscht Volumes. Für reines Stoppen reicht down (ohne -v).

„Port ist schon belegt"

Ein anderer Compose-Stack oder ein lokaler Prozess hört auf dem Port. docker compose ps und lsof -i :8080 zeigen, wer.

„Änderungen am Dockerfile greifen nicht"

docker compose up --build       # explizit neu bauen
docker compose build --no-cache # ohne Cache neu bauen

Praxis-Übung

Im Ordner compose-demo/:

compose.yaml:

services:
  web:
    image: nginx:alpine
    ports:
      - "8080:80"
    volumes:
      - ./html:/usr/share/nginx/html:ro
    depends_on:
      - api
 
  api:
    image: hashicorp/http-echo
    command: ["-text", "Hallo von der API"]
    expose:
      - "5678"

html/index.html:

<!doctype html>
<title>Compose-Demo</title>
<h1>Hallo aus nginx</h1>
<p>Die API ist im Compose-Netz unter <code>http://api:5678</code>.</p>
docker compose up -d
curl localhost:8080
docker compose exec web wget -qO- http://api:5678
docker compose down

Wenn der zweite curl/wget funktioniert, hast du gerade Compose-DNS in Aktion gesehen — web erreicht api per Name, ohne dass du irgendetwas explizit eingerichtet hast.

Weiterführendes


Compose ist Docker, aber deklarativ. Wer den Stack beschreiben kann, muss ihn nicht mehr von Hand zusammenklicken. 🧩