Docker Compose / Podman Compose

Das vorherige Kapitel Container am Beispiel Podman hat gezeigt, wie einzelne Container gestartet und verwaltet werden. In der Praxis besteht eine Anwendung jedoch selten aus nur einem einzigen Container – etwa eine Datenbank, eine Webanwendung und ein Webserver, die koordiniert zusammenarbeiten müssen.

Compose ist das Werkzeug dafür: Die gesamte Infrastruktur wird in einer einzigen YAML-Datei (compose.yaml) deklarativ beschrieben. Ein einziger Befehl startet alle Services, legt Netzwerke an und verknüpft Volumes. In diesem Kapitel wird podman-compose verwendet, das nahtlos mit Podman zusammenarbeitet.

Lernziele. Nach dem Durcharbeiten dieses Kapitels sollte Folgendes gelingen:

  • den Zweck von Compose erklären und podman-compose einordnen;
  • eine compose.yaml mit Services, Volumes und Netzwerken schreiben;
  • die wichtigsten Service-Schlüssel (image, ports, environment, depends_on, restart) anwenden;
  • Compose-Befehle (up, down, ps, logs, exec) nutzen;
  • .env-Dateien für Konfiguration und Geheimnisse einsetzen;
  • YAML-Stolperfallen in compose-Dateien erkennen und vermeiden.

Was ist Compose?

Das Problem: Multi-Container-Anwendungen manuell starten

Ohne Compose müsste man mehrere podman run-Befehle einzeln eingeben, dabei Netzwerknamen übergeben, die richtige Startreihenfolge einhalten und Volumes explizit anlegen. Das ist fehleranfällig und schlecht reproduzierbar.

Die Lösung: Deklarative Konfiguration

Compose löst dieses Problem durch Infrastructure as Code:

  • Die Datei compose.yaml beschreibt den Soll-Zustand – welche Services mit welchen Images, Ports, Abhängigkeiten und Volumes existieren sollen.
  • Das Compose-Tool liest die Datei und bringt das System in diesen Soll-Zustand. Es startet fehlende Container, erstellt benötigte Netzwerke und legt Volumes an.
  • Ein einziger Befehl (podman-compose up) startet die gesamte Anwendungslandschaft; podman-compose down fährt sie wieder sauber herunter.

Vorteile

Vorteil Erläuterung
Reproduzierbarkeit Dieselbe compose.yaml ergibt auf jedem Rechner exakt dieselbe Umgebung.
Einfaches Onboarding Neue Teammitglieder klonen das Repository und führen up aus – fertig.
Isolierte Umgebungen Jedes Projekt hat sein eigenes Netzwerk; mehrere Projekte laufen unabhängig nebeneinander.
Versionierbarkeit Die Infrastruktur liegt als Textdatei in Git und kann wie Code reviewed werden.

podman-compose und docker compose

Gemeinsames Dateiformat

Das Compose-Dateiformat ist ein offener Standard (Compose Specification). Alle gängigen Tools lesen dieselbe compose.yaml-Datei – ein Image, das mit docker compose gebaut wurde, läuft genauso mit podman-compose.

podman-compose

podman-compose ist ein Python-basiertes Werkzeug, das Compose-Dateien in entsprechende Podman-Aufrufe übersetzt. Es ist daemonlos – das bedeutet, es läuft ohne einen dauerhaft im Hintergrund laufenden Systemdienst (Daemon). Docker hingegen benötigt einen solchen Daemon (dockerd), der als privilegierter Prozess stets aktiv sein muss. Podman und podman-compose starten Container direkt als Kindprozesse des aufrufenden Nutzers, was den Betrieb auch ohne Administratorrechte (rootless) ermöglicht.

# Installation via Paketmanager (Debian/Ubuntu/Fedora):
apt install podman-compose      # Debian/Ubuntu
dnf install podman-compose      # Fedora

# oder via pip:
pip install podman-compose

# Version prüfen:
podman-compose version

Verhältnis zu docker compose

docker compose (seit Docker v2 als Plugin in die Docker CLI integriert) ist die Referenzimplementierung der Compose Specification und deckt den Standard vollständig ab. podman-compose ist weitgehend kompatibel; gelegentlich fehlen sehr neue Compose-Spec-Features.

Merkmal podman-compose docker compose
Daemon Daemonlos (kein Hintergrunddienst) Benötigt Docker-Daemon (dockerd)
Rootless Nativ Möglich, aber nicht Standard
Integration Podman-Ökosystem Docker CLI (docker compose)
Spec-Abdeckung Sehr gut, nicht 100 % Referenzimplementierung
Befehlssyntax podman-compose up docker compose up

In diesem Kapitel werden alle Beispiele mit podman-compose gezeigt. Wer docker compose verwendet, ersetzt den Befehlsnamen entsprechend – Flags und Unterbefehle sind weitgehend identisch.

Aufbau der compose-Datei

Dateinamen

Compose sucht standardmäßig nach (in dieser Reihenfolge):

  1. compose.yaml (empfohlen, neuerer Standard)
  2. compose.yml
  3. docker-compose.yaml
  4. docker-compose.yml

Mit dem Flag -f kann eine beliebige Datei angegeben werden: podman-compose -f meine-datei.yaml up.

Die obersten Schlüssel

Eine compose-Datei ist ein YAML-Mapping mit wenigen Top-Level-Schlüsseln:

services:        # Pflicht – ein Eintrag je Container-Service
  ...
networks:        # optional – eigene Netzwerke
  ...
volumes:         # optional – benannte Datenspeicher
  ...

services ist ein Mapping (Service-Name → Konfiguration), keine Liste. Der häufigste Anfängerfehler ist, - web: statt web: zu schreiben.

Daneben gibt es weitere Top-Level-Schlüssel: configs und secrets (siehe Weitere Themen) sowie x-…-Extension-Fields (siehe Abschnitt Wiederverwendung: Anchors und Extension-Fields).

Service-Konfiguration: die wichtigsten Schlüssel

Unter services wird für jeden Container ein selbst vergebener Service-Name als Schlüssel notiert (erlaubt sind Buchstaben, Ziffern sowie ., _ und -; üblich ist Kleinschreibung). Dieser Name dient zugleich als Hostname, unter dem der Service im Compose-Netzwerk von anderen Services erreichbar ist. Der Wert ist ein Mapping mit der Konfiguration dieses Containers:

services:
  db:          # selbst vergebener Name – hier "db"
    image: docker.io/library/postgres:15-alpine
    ...
  website:     # selbst vergebener Name – hier "website"
    image: docker.io/library/nginx:alpine
    ...

Die gebräuchlichsten Schlüssel innerhalb einer Service-Konfiguration:

image

Das Container-Image, das gestartet wird. Vollständige Registry-URLs sind empfohlen, damit keine Mehrdeutigkeit entsteht:

image: docker.io/library/postgres:15-alpine     # explizit: Docker Hub, offizielle Library
image: ghcr.io/umami-software/umami:postgresql-latest  # GitHub Container Registry
image: docker.io/library/nginx:alpine

Ohne Registry-Präfix sucht Podman standardmäßig in konfigurierten Registries (oft Docker Hub).

container_name

Setzt einen festen Namen für den Container (statt des automatisch generierten):

container_name: iac_db

Nützlich für Skripte oder Logs, die einen stabilen Namen erwarten. Ohne container_name erzeugt Compose einen Namen aus Projektname und Service.

ports

Port-Zuordnung im Format "HOST:CONTAINER". Anführungszeichen sind Pflicht (YAML-Stolperfalle, siehe unten). Ein Kommentar erklärt die Zuordnung:

ports:
  - "3000:3000"  # host:container
  - "8080:80"    # Die Webseite selbst

environment

Umgebungsvariablen, die dem Programm im Container bereitgestellt werden – in zwei gleichwertigen Schreibweisen, die sich sogar in einer Datei mischen lassen.

Was bedeutet ${VAR}? Die Schreibweise ${VAR} ist ein Platzhalter. Compose ersetzt ihn beim Einlesen der compose.yaml durch einen konkreten Wert – meist aus einer .env-Datei. So müssen z. B. Passwörter nicht fest in der compose-Datei stehen. Details folgen im Abschnitt Konfiguration via .env-Dateien; hier genügt: ${DB_NAME} wird später durch einen echten Wert ersetzt.

# Variante A – Liste: jeder Eintrag ist eine Zeichenkette "KEY=WERT"
environment:
  - POSTGRES_DB=${DB_NAME}
  - POSTGRES_USER=${DB_USER}
  - POSTGRES_PASSWORD=${DB_PASSWORD}

# Variante B – Mapping: KEY und WERT als Schlüssel-Wert-Paar
environment:
  DATABASE_URL: ${DATABASE_URL}
  APP_SECRET: ${APP_SECRET}
  DATABASE_TYPE: postgresql

volumes

Ein Volume bindet Speicher in den Container ein, der außerhalb des Containers liegt und dessen Löschen überdauert – wichtig z. B. für Datenbankdaten. Es gibt zwei Arten: benannte Volumes (von Podman verwaltet) und Bind-Mounts (ein Verzeichnis vom Host). Die Unterschiede werden im Abschnitt Volumes in Compose ausführlich erklärt; hier nur die Schreibweise:

volumes:
  - iac_db_data:/var/lib/postgresql/data    # benanntes Volume
  - ./html:/usr/share/nginx/html:ro         # Bind-Mount; :ro = read-only

depends_on

Startreihenfolge: der Service wird erst gestartet, nachdem der genannte Service gestartet wurde.

depends_on:
  - db

Achtung: „gestartet" heißt nur, dass der Container läuft – nicht, dass der Dienst darin schon bereit ist (eine Datenbank braucht nach dem Start noch Zeit, bis sie Verbindungen annimmt). Wer wirklich auf die Bereitschaft warten will, kombiniert die Langform von depends_on mit einem healthcheck – siehe nächster Abschnitt.

healthcheck

Ein healthcheck ist ein Befehl, den Compose regelmäßig im Container ausführt, um zu prüfen, ob der Dienst wirklich bereit ist. Liefert der Befehl den Exit-Code 0, gilt der Container als „healthy", sonst als „unhealthy".

db:
  image: docker.io/library/postgres:15-alpine
  healthcheck:
    test: ["CMD-SHELL", "pg_isready"]   # im Container ausgeführter Prüfbefehl
    interval: 10s     # alle 10 s prüfen
    timeout: 5s       # eine Prüfung darf höchstens 5 s dauern
    retries: 5        # nach 5 Fehlversuchen gilt der Container als "unhealthy"
  • test – der Prüfbefehl. CMD-SHELL führt ihn über die Shell des Containers aus (so funktionieren z. B. auch Pipes). pg_isready ist ein PostgreSQL-Werkzeug, das mit Exit-Code 0 antwortet, sobald die Datenbank Verbindungen annimmt.
  • interval / timeout / retries – steuern Häufigkeit, Zeitlimit pro Prüfung und Anzahl der Fehlversuche bis zum Status „unhealthy".

Der Nutzen entsteht im Zusammenspiel mit depends_on: Mit condition: service_healthy wartet ein abhängiger Service, bis der healthcheck des anderen „healthy" meldet – nicht nur, bis dessen Container gestartet wurde:

umami:
  depends_on:
    db:
      condition: service_healthy   # wartet, bis der DB-healthcheck "healthy" ist

$-Stolperfalle: Braucht der Prüfbefehl eine Umgebungsvariable des Containers, muss das Dollarzeichen verdoppelt werden, z. B. test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER"]. Mit nur einem $ würde Compose $POSTGRES_USER schon beim Einlesen der compose.yaml ersetzen (durch einen Wert aus .env/Shell – hier meist leer), statt es an die Shell im Container weiterzureichen. Im einfachen Beispiel oben (pg_isready ohne -U) tritt das nicht auf, weil gar keine Variable vorkommt.

restart

Neustart-Politik bei Absturz:

Wert Verhalten
no nie neu starten (Standard)
always immer neu starten
on-failure nur bei Fehler (Exit-Code ≠ 0)
unless-stopped immer, außer manuell gestoppt

networks

Weist den Service einem oder mehreren Netzwerken zu:

networks:
  - analytics_net

Vollständiges Beispiel

Das folgende Beispiel ist eine reale compose.yaml für eine Web-Anwendung mit drei Services:

  • db – PostgreSQL-Datenbank
  • umami – Web-Analytics-Software, die die Datenbank nutzt
  • website – nginx-Webserver, der statische Dateien aus einem lokalen Verzeichnis ausliefert
services:
  db:
    image: docker.io/library/postgres:15-alpine
    container_name: iac_db
    restart: always
    environment:
      - POSTGRES_DB=${DB_NAME}
      - POSTGRES_USER=${DB_USER}
      - POSTGRES_PASSWORD=${DB_PASSWORD}
    volumes:
      - iac_db_data:/var/lib/postgresql/data
    networks:
      - analytics_net

  umami:
    image: ghcr.io/umami-software/umami:postgresql-latest
    container_name: iac_analytics
    restart: always
    ports:
      - "3000:3000"  # host:container
    environment:
      DATABASE_URL: ${DATABASE_URL}
      APP_SECRET: ${APP_SECRET}
      # Verhindert, dass Umami versucht die DB zu konfigurieren, bevor sie bereit ist
      DATABASE_TYPE: postgresql
    depends_on:
      - db
    networks:
      - analytics_net

  website:
    image: docker.io/library/nginx:alpine
    container_name: iac_web_site
    restart: always
    ports:
      - "8080:80"    # Die Webseite selbst
    volumes:
      - ./html:/usr/share/nginx/html:ro
    networks:
      - analytics_net

volumes:
  iac_db_data:

networks:
  analytics_net:
    driver: bridge

Direkt ablesbare Besonderheiten:

  • Vollständige Registry-URLs (docker.io/library/…, ghcr.io/…) statt Kurznamen – vermeiden Mehrdeutigkeit beim Image-Pull.
  • container_name vergibt jedem Container einen festen Namen (iac_db, …).
  • Gemischte environment-Stile: db nutzt die Listen-Schreibweise (- KEY=${VAR}), umami das Mapping (KEY: ${VAR}). Beide Stile sind in derselben Datei erlaubt.
  • ${...}-Platzhalter (${DB_NAME}, ${DATABASE_URL}, …) werden beim Start aus einer .env-Datei ersetzt – die echten Werte stehen also nicht in der compose-Datei.

Außerdem nutzt das Beispiel Volumes (das benannte iac_db_data für die DB-Daten und einen Bind-Mount ./html für die Webinhalte) sowie ein eigenes Netzwerk analytics_net. Diese drei Themen – Netzwerke, Volumes und .env-Dateien – werden in den folgenden Abschnitten ausführlich erklärt.

YAML-Sicht auf environment: Der Eintrag - POSTGRES_DB=${DB_NAME} ist für YAML eine einfache Zeichenkette; das = hat für YAML keine besondere Bedeutung. Erst podman-compose zerlegt sie in Schlüssel und Wert und ersetzt ${DB_NAME} durch den Wert aus der .env-Datei.

Netzwerke in Compose

Automatisches Standard-Netzwerk

Ohne explizite Netzwerk-Konfiguration legt Compose automatisch ein Standard-Netzwerk für das Projekt an. Alle Services sind darin erreichbar – über den Service-Namen als Hostnamen.

Eigene Netzwerke mit driver: bridge

Im Beispiel wird ein eigenes Netzwerk analytics_net definiert:

networks:
  analytics_net:
    driver: bridge

Der Treiber bridge ist der Standard für einzelne Hosts: Er erzeugt ein virtuelles Netzwerk, in dem alle angeschlossenen Container miteinander kommunizieren können, aber vom restlichen Host-Netzwerk isoliert sind.

Alle drei Services sind diesem Netzwerk zugewiesen:

services:
  db:
    networks:
      - analytics_net
  umami:
    networks:
      - analytics_net
  website:
    networks:
      - analytics_net

Weitere Treiber und Netzwerk-Modi

Neben bridge gibt es weitere Treiber bzw. Modi. Auf einem einzelnen Rechner ist bridge aber fast immer die richtige Wahl:

Treiber / Modus Bedeutung
bridge Standard: eigenes virtuelles Netz, Container untereinander verbunden, vom Host isoliert (hier verwendet)
host (via network_mode: host) Container nutzt direkt den Netzwerk-Stack des Hosts – keine Isolation, kein eigenes Container-IP
none (via network_mode: none) gar kein Netzwerk
macvlan / ipvlan Container erscheint mit eigener Adresse direkt im physischen LAN (fortgeschritten)

host und none werden üblicherweise pro Service über network_mode: gesetzt, nicht als Netzwerk-Treiber. Der Treiber overlay (Netzwerk über mehrere Hosts hinweg) ist nur für Cluster-Orchestrierung (Swarm/Kubernetes) relevant, nicht für podman-compose auf einem einzelnen Host.

Service-Namen als Hostnamen

Innerhalb des Netzwerks ist jeder Service unter seinem Service-Namen erreichbar. Der umami-Service muss z. B. seine Datenbank finden. Dafür bekommt er eine Verbindungs-URL (DATABASE_URL), die etwa so aussehen könnte:

postgresql://admin:ein_sicheres_passwort@db:5432/umamiDB

Diese URL setzt sich aus mehreren Teilen zusammen – die konkreten Werte stammen aus der .env-Datei, die weiter unten erklärt wird:

Teil Bedeutung
postgresql:// Protokoll (hier: PostgreSQL)
admin Benutzername (im Beispiel DB_USER)
ein_sicheres_passwort Passwort (im Beispiel DB_PASSWORD)
db Hostname = Service-Name der Datenbank
5432 Port (PostgreSQL-Standard)
umamiDB Name der Datenbank (im Beispiel DB_NAME)

Entscheidend ist der Hostname db: Compose löst ihn automatisch zum Container des db-Service auf – ganz ohne IP-Adressen. Ebenso könnte umami den Webserver unter http://website:80 erreichen.

Nicht verwechseln: db (Hostname) ist der Service-Name der Datenbank, umamiDB am Ende ist der Datenbankname (DB_NAME). Der Service umami wiederum ist die Analytics-Anwendung, die auf diese Datenbank zugreift – drei verschiedene Namen mit drei verschiedenen Rollen.

Wie sich Services über mehrere Netzwerke gezielt voneinander trennen lassen, zeigt der Abschnitt Weitere Themen am Ende.

Volumes in Compose

Container sind flüchtig: Wird ein Container gelöscht, geht seine beschreibbare Schicht – und damit alle zur Laufzeit erzeugten Daten – verloren. Damit z. B. eine Datenbank ihre Daten behält, müssen diese außerhalb des Containers gespeichert werden. Dafür gibt es zwei Mechanismen: benannte Volumes und Bind-Mounts. Beide werden im Service unter volumes: als Liste eingetragen.

Benannte Volumes

Ein benanntes Volume wird von Podman/Compose verwaltet und an einem internen Ort auf dem Host gespeichert (der genaue Pfad ist normalerweise nicht relevant). Es muss unter dem Top-Level-Schlüssel volumes: deklariert werden:

volumes:
  iac_db_data:       # leerer Wert = Standard-Treiber (local)

Im Service wird es im Format VOLUME_NAME:PFAD_IM_CONTAINER eingebunden:

services:
  db:
    volumes:
      - iac_db_data:/var/lib/postgresql/data

Eigenschaften:

  • Persistenz: Das Volume überlebt podman-compose down; die Daten bleiben erhalten. Erst podman-compose down -v (oder podman volume rm) löscht es.
  • Verwaltung: Mit podman volume ls und podman volume inspect <name> lassen sich Volumes ansehen.
  • Teilen: Mehrere Services können dasselbe benannte Volume einbinden und so Daten gemeinsam nutzen.

Bind-Mounts

Ein Bind-Mount verknüpft ein konkretes Verzeichnis (oder eine Datei) auf dem Host mit einem Pfad im Container. Format: HOST_PFAD:CONTAINER_PFAD[:optionen].

services:
  website:
    volumes:
      - ./html:/usr/share/nginx/html:ro
  • ./html – Pfad auf dem Host, relativ zur compose.yaml (absolute Pfade sind ebenso möglich).
  • /usr/share/nginx/html – Pfad im Container; ein dort bereits vorhandener Inhalt wird vom Host-Verzeichnis überdeckt.
  • :roread-only; der Container darf nur lesen. Ohne Angabe (bzw. mit :rw) darf er auch schreiben.

Änderungen am Host sind sofort im Container sichtbar – ideal für statische Webinhalte, Konfigurationsdateien oder Quellcode in der Entwicklung.

Podman + SELinux: Auf Systemen mit SELinux (z. B. Fedora/RHEL) verweigert der Container oft den Zugriff auf Bind-Mounts. Die Option :Z (privat für genau diesen Container) bzw. :z (geteilt zwischen mehreren Containern) setzt das passende SELinux-Label: - ./html:/usr/share/nginx/html:ro,Z.

Anonyme Volumes

Wird nur ein Container-Pfad ohne Namen und ohne Host-Pfad angegeben, erzeugt Compose ein anonymes Volume mit zufälligem Namen. Es speichert Daten zwar dauerhaft, ist aber schwer wiederzufinden und wird leicht zur „Datenmüllhalde" – in der Praxis meist unerwünscht:

volumes:
  - /var/lib/postgresql/data    # anonymes Volume (kein Name!)

Vergleich

Benanntes Volume (iac_db_data) Bind-Mount (./html)
Verwaltung Durch Compose/Podman Durch das Host-Dateisystem
Speicherort intern, von Podman verwaltet frei gewählter Host-Pfad
Portabilität Hoch Pfad muss auf dem Host existieren
Einsatz Datenbankdaten, persistente Speicher Statische Dateien, Konfiguration, Code

Konfiguration via .env-Dateien

Zwei Orte für Variablen – nicht verwechseln!

Umgebungsvariablen wirken bei Compose an zwei völlig unterschiedlichen Stellen. Diese zu verwechseln, ist eine der häufigsten Fehlerquellen:

Ort 1 – in der compose.yaml selbst (Platzhalter-Ersetzung). Steht irgendwo in der compose-Datei ein Platzhalter ${VAR}, ersetzt Compose ihn beim Einlesen der Datei durch einen konkreten Wert – noch bevor ein Container existiert. Es ist also reine Textersetzung in der Datei. Die Werte holt sich Compose standardmäßig aus der Datei .env im selben Verzeichnis (oder aus der Shell-Umgebung).

Ort 2 – im laufenden Container (Programm-Umgebung). Die Schlüssel environment: und env_file: legen fest, welche Umgebungsvariablen das Programm im Container zu sehen bekommt – z. B. liest PostgreSQL sein Passwort aus der Variablen POSTGRES_PASSWORD.

.env-Datei environment: / env_file:
Wo definiert Datei .env im Projektordner im Service in der compose.yaml
Wirkt auf die compose.yaml (Textersetzung) den laufenden Container
Wann beim Einlesen, vor dem Start zur Laufzeit des Containers
Wozu ${VAR} in der Datei ersetzen Variablen für das Programm setzen

Häufiger Fehler: Die .env-Datei wird nicht automatisch in die Container gereicht – sie steuert nur die ${VAR}-Ersetzung in der compose.yaml. Umgekehrt speist eine unter env_file: angegebene Datei nicht die Platzhalter-Ersetzung, sondern landet direkt in der Container-Umgebung.

Ort 1 im Detail: .env ersetzt ${VAR} in der compose.yaml

podman-compose liest automatisch eine Datei namens .env im Projektverzeichnis. Jedes ${VARIABLENNAME} in der compose.yaml wird beim Einlesen durch den passenden Wert ersetzt.

Eine .env passend zum Beispiel weiter oben:

# .env  (im selben Verzeichnis wie die compose.yaml)
DB_USER=admin
DB_PASSWORD=ein_sicheres_passwort
DB_NAME=umamiDB
APP_SECRET=ein_langer_zufaelliger_string
DATABASE_URL=postgresql://admin:ein_sicheres_passwort@db:5432/umamiDB

In der compose.yaml greifen dann beide Orte ineinander:

services:
  db:
    environment:                       # Ort 2: an den Container
      - POSTGRES_DB=${DB_NAME}         # ${DB_NAME} kommt aus .env (Ort 1)
      - POSTGRES_USER=${DB_USER}
      - POSTGRES_PASSWORD=${DB_PASSWORD}

Ablauf: Compose ersetzt zuerst ${DB_NAME} durch umamiDB (Ort 1, Textersetzung). Erst das Ergebnis – POSTGRES_DB=umamiDB – wird über environment: als Variable an den Container übergeben (Ort 2).

Default-Werte und fehlende Variablen

Bei der Platzhalter-Ersetzung lassen sich Standardwerte und Fehlermeldungen angeben:

Syntax Bedeutung
${VAR} leer, falls VAR nicht gesetzt ist
${VAR:-standard} standard, falls VAR leer oder nicht gesetzt
${VAR-standard} standard, nur falls VAR gar nicht gesetzt (leerer Wert bleibt leer)
${VAR:?Fehlertext} bricht mit Fehlertext ab, falls VAR leer oder nicht gesetzt
services:
  web:
    ports:
      - "${APP_PORT:-8080}:80"   # nutzt 8080, falls APP_PORT nicht gesetzt ist
    environment:
      APP_SECRET: ${APP_SECRET:?APP_SECRET muss gesetzt sein}

Ort 2 im Detail: env_file lädt eine ganze Datei in den Container

Statt jede Variable einzeln unter environment: aufzuzählen, kann eine komplette Datei direkt in die Container-Umgebung geladen werden:

services:
  app:
    env_file:
      - app.env          # jede Zeile KEY=VALUE wird zur Variablen im Container

app.env wirkt also nur im Container und hat nichts mit der ${VAR}-Ersetzung in der compose.yaml zu tun – auch wenn beide Dateien das Format KEY=VALUE nutzen.

Sicherheit: .env niemals in Git einchecken

.env-Dateien enthalten Passwörter und API-Keys und dürfen nicht eingecheckt werden:

# .gitignore
.env

Best Practice – stattdessen eine Vorlage ohne echte Werte einchecken, üblicherweise .env.example:

# .env.example (wird eingecheckt, enthält nur Platzhalter)
DB_USER=
DB_PASSWORD=
DB_NAME=
APP_SECRET=

Neue Teammitglieder kopieren die Vorlage und tragen ihre eigenen Werte ein:

cp .env.example .env

Hinweis: Der Eintrag .env in .gitignore erfasst nur die Datei .env selbst – die Vorlage .env.example wird also weiterhin versioniert. Wird in .gitignore dagegen das Muster .env* verwendet, würde auch .env.example ignoriert; dann benennt man die Vorlage z. B. env.example (ohne führenden Punkt).

Die wichtigsten Compose-Befehle

Alle Befehle werden im Verzeichnis ausgeführt, das die compose.yaml enthält. Die Beispiele beziehen sich auf die Services db und website aus dem Beispiel oben.

Starten und Stoppen

podman-compose up            # alle Services starten (im Vordergrund)
podman-compose up -d         # im Hintergrund (detached)
podman-compose up --build    # Images vorher neu bauen
podman-compose down          # alle Services stoppen und Netzwerke entfernen
podman-compose down -v       # zusätzlich alle Volumes löschen
podman-compose restart       # alle Services neu starten

Status und Logs

podman-compose ps                # laufende Services anzeigen
podman-compose logs              # Logs aller Services
podman-compose logs -f website   # Logs von 'website' live verfolgen (follow)

Interaktion mit einzelnen Services

podman-compose exec website sh        # Shell im laufenden 'website'-Container öffnen
                                      # (Alpine-Images haben kein bash, daher sh)
podman-compose run --rm website sh    # neuen Container starten, dann entfernen
podman-compose pull                   # alle Images aktualisieren
podman-compose build                  # alle Images neu bauen

Einzelne Services steuern

podman-compose stop db        # nur 'db' stoppen
podman-compose start db       # 'db' wieder starten
podman-compose rm db          # gestoppten 'db'-Container entfernen

Projektname

Ohne Angabe leitet Compose den Projektnamen vom Verzeichnisnamen ab. Alle Ressourcen (Container, Netzwerke, Volumes) tragen diesen Präfix. Mit -p kann ein expliziter Name gesetzt werden:

podman-compose -p meinprojekt up -d

Hinweis: podman-compose unterstützt nicht alle Flags, die docker compose kennt (z. B. scale und top fehlen je nach Version). Im Zweifelsfall hilft podman-compose --help oder podman-compose <befehl> --help.

Typische YAML-Stolperfallen in compose-Dateien

1. Port-Zuordnungen in Anführungszeichen setzen. YAML (Version 1.1) kennt Sexagesimalzahlen (Basis 60), die mit Doppelpunkten notiert werden. Eine Zuordnung wie 22:22 (SSH) wird ohne Anführungszeichen zur Zahl 1342 statt zur Zeichenkette "22:22"! Daher gilt: Port-Zuordnungen stets in Anführungszeichen setzen"8080:80", "22:22".

2. services ist ein Mapping, keine Liste. Falsch wäre - web: unterhalb von services.

3. environment: Liste oder Mapping – beide Schreibweisen sind erlaubt, verhalten sich aus YAML-Sicht aber unterschiedlich:

environment:           # Variante A: Liste – jedes Element ist EINE Zeichenkette
  - DEBUG=true         #   → YAML liest "DEBUG=true" als str (kein bool!)
environment:           # Variante B: Mapping – echte Schlüssel-Wert-Paare
  DEBUG: true          #   → YAML liest den Wert als bool True

In Variante B sollten bool-ähnliche Werte daher in Anführungszeichen gesetzt werden: DEBUG: "true".

4. Einrückung. Alle Schlüssel eines Service müssen gleich tief stehen.

5. Keine Tabulatoren. Den Editor auf „Leerzeichen statt Tabulatoren" einstellen.

Wiederverwendung: Anchors und Extension-Fields

Im vollständigen Beispiel weiter oben wiederholen sich bei allen drei Services dieselben zwei Einstellungen: restart: always und die Zuweisung zum Netzwerk analytics_net. Solche Wiederholungen lassen sich mit YAML-Anchors und Merge Keys zusammenfassen. Compose erlaubt zusätzlich Extension-Fields: Top-Level-Schlüssel mit dem Präfix x-, die Compose selbst ignoriert und die sich gut als Ablage für Anchors eignen.

Dasselbe Beispiel mit ausgelagerter gemeinsamer Konfiguration:

x-common: &common          # x-Extension-Field + Anchor: gemeinsame Einstellungen
  restart: always
  networks:
    - analytics_net

services:
  db:
    <<: *common            # erbt restart und networks
    image: docker.io/library/postgres:15-alpine
    container_name: iac_db
    environment:
      - POSTGRES_DB=${DB_NAME}
      - POSTGRES_USER=${DB_USER}
      - POSTGRES_PASSWORD=${DB_PASSWORD}
    volumes:
      - iac_db_data:/var/lib/postgresql/data

  umami:
    <<: *common
    image: ghcr.io/umami-software/umami:postgresql-latest
    container_name: iac_analytics
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: ${DATABASE_URL}
      APP_SECRET: ${APP_SECRET}
      DATABASE_TYPE: postgresql
    depends_on:
      - db

  website:
    <<: *common
    image: docker.io/library/nginx:alpine
    container_name: iac_web_site
    ports:
      - "8080:80"
    volumes:
      - ./html:/usr/share/nginx/html:ro

volumes:
  iac_db_data:

networks:
  analytics_net:
    driver: bridge

So funktioniert es:

  • &common definiert den Anchor common auf dem Mapping mit den gemeinsamen Einstellungen.
  • <<: *common fügt diese Schlüssel in den jeweiligen Service ein (Merge Key); *common ist der Alias, der auf den Anchor verweist.
  • Lokal definierte Schlüssel überschreiben geerbte. Würde website zusätzlich restart: unless-stopped setzen, gälte für diesen Service unless-stopped statt des geerbten always.

Der Vorteil: Ändert sich die gemeinsame Einstellung (etwa ein anderes Netzwerk), genügt eine Anpassung an einer Stelle statt in jedem Service.

Weitere Themen

Die folgenden Schlüssel und Möglichkeiten werden im Alltag seltener gebraucht. Bei configs und secrets ist außerdem zu beachten, dass podman-compose sie je nach Version nur eingeschränkt unterstützt – im Zweifel die Dokumentation prüfen.

Mehrere Netzwerke zur Segmentierung

Das Beispiel weiter oben nutzt ein einziges Netzwerk, in dem alle Services miteinander reden können. Manchmal soll der Zugriff gezielt eingeschränkt werden – etwa damit ein öffentlich erreichbares Frontend nicht direkt auf die Datenbank zugreifen kann. Dann definiert man mehrere Netzwerke und weist jedem Service nur die passenden zu:

services:
  frontend:
    networks: [frontend-net]
  backend:
    networks: [frontend-net, backend-net]
  db:
    networks: [backend-net]

networks:
  frontend-net:
    driver: bridge
  backend-net:
    driver: bridge

frontend kann mit backend kommunizieren (gemeinsames frontend-net), backend mit db (gemeinsames backend-net). frontend und db teilen kein Netzwerk und sind daher voneinander isoliert.

secrets – Geheimnisse als Dateien bereitstellen

Passwörter über environment: zu setzen hat einen Nachteil: Sie sind in der Prozess-Umgebung und über podman inspect sichtbar. Secrets umgehen das, indem das Geheimnis als Datei in den Container eingehängt wird (üblicherweise unter /run/secrets/<name>):

secrets:
  db_password:
    file: ./db_password.txt      # Datei auf dem Host mit dem Geheimnis

services:
  db:
    secrets:
      - db_password              # im Container: /run/secrets/db_password
    environment:
      # Postgres liest das Passwort aus einer Datei statt aus einer Variablen:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password

Die Datei db_password.txt enthält nur das Geheimnis und gehört (wie .env) nicht ins Repository.

configs – Konfigurationsdateien einbinden

configs funktionieren wie secrets, sind aber für nicht-sensible Konfigurationsdaten gedacht. Eine Host-Datei wird an einen Zielpfad im Container gelegt:

configs:
  nginx_conf:
    file: ./nginx.conf

services:
  website:
    configs:
      - source: nginx_conf
        target: /etc/nginx/nginx.conf   # Zielpfad im Container

Für einfache Fälle tut es auch ein read-only Bind-Mount (- ./nginx.conf:/etc/nginx/nginx.conf:ro); configs sind vor allem in größeren bzw. Swarm-Setups nützlich.

Zusammenfassung

Compose in einem Satz: Eine compose.yaml beschreibt den Soll-Zustand einer Multi-Container-Anwendung; podman-compose up bringt das System in diesen Zustand.

Wichtige Punkte

  • services ist ein Mapping, keine Liste.
  • Services sind im gemeinsamen Compose-Netzwerk über ihren Namen erreichbar (db, website, …).
  • Port-Zuordnungen immer in Anführungszeichen setzen: "8080:80".
  • Zwei Orte für Variablen unterscheiden: .env ersetzt ${VAR} in der compose.yaml; environment:/env_file: setzen Variablen im Container.
  • .env-Dateien in .gitignore eintragen; eine Vorlage (.env.example) mit leeren Werten einchecken.
  • depends_on steuert die Startreihenfolge, garantiert aber nicht, dass ein Service auch bereit ist (dafür: condition: service_healthy mit healthcheck).
  • YAML-Anchors und x--Extension-Fields vermeiden Duplikate in größeren compose-Dateien.
  • podman-compose ist weitgehend kompatibel mit der Compose Specification; bei Problemen hilft podman-compose --help.

Weiterführende Verweise