Zum Inhalt springen
SONOYA.com
Web & Code

Docker Compose verstehen: Volumes, Netzwerke und .env

Docker Compose verständlich erklärt: Named Volume oder Bind Mount, Dienstnamen als Hostnamen, .env-Variablen, Healthchecks, sichere Updates und Backups.

Von

Docker-Symbol auf farbigem Verlauf für die Grundlagen von Docker Compose mit Volumes, Netzwerken und einer .env-Datei

Fast jede Selfhosting-Anleitung endet mit einer compose.yaml, die Du kopierst, mit docker compose up -d startest und die dann einfach läuft. Bis zum ersten Update. Plötzlich sind die Daten weg, die App findet ihre Datenbank nicht mehr, oder das Datenbankpasswort steht im Klartext in einem Git-Repository. Hinter all diesen Problemen stecken dieselben vier Grundlagen: Volumes, Netzwerke, Variablen und die Reihenfolge beim Start.

Diese Anleitung geht eine typische compose.yaml mit einer App und einer PostgreSQL-Datenbank Zeile für Zeile durch und erklärt dabei genau diese Grundlagen. Danach kannst Du fremde Compose-Dateien lesen, gezielt anpassen und sicher aktualisieren. Stand ist Docker Engine 29 mit dem Compose-Plugin, also der Befehl docker compose mit Leerzeichen. Der alte Befehl docker-compose mit Bindestrich wird nicht mehr weiterentwickelt.

Das Beispiel: eine App mit Datenbank

Als Beispiel dient Miniflux, ein schlanker RSS-Reader, der eine PostgreSQL-Datenbank braucht. Das Muster aus App plus Datenbank findest Du bei fast jedem selbst gehosteten Dienst wieder, von Nextcloud bis n8n. Leg einen Projektordner an und wechsle hinein:

Code
mkdir -p ~/miniflux
cd ~/miniflux

Darin liegen am Ende genau zwei Dateien: compose.yaml mit der Beschreibung der Dienste und .env mit den Geheimnissen. Die compose.yaml sieht so aus:

Code
services:
  app:
    image: miniflux/miniflux:2.3.5
    restart: unless-stopped
    ports:
      - "127.0.0.1:8080:8080"
    environment:
      DATABASE_URL: postgres://miniflux:${POSTGRES_PASSWORD}@db/miniflux?sslmode=disable
      RUN_MIGRATIONS: "1"
      CREATE_ADMIN: "1"
      ADMIN_USERNAME: admin
      ADMIN_PASSWORD: ${ADMIN_PASSWORD}
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:18-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: miniflux
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: miniflux
    volumes:
      - db-daten:/var/lib/postgresql
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "miniflux"]
      interval: 10s
      start_period: 30s

volumes:
  db-daten:

Oben unter services stehen die beiden Container, hier app und db. Die Namen wählst Du selbst, und sie sind wichtiger, als es aussieht, denn sie werden zu Hostnamen im internen Netz. Unten unter volumes meldest Du den Speicher an, den die Datenbank nutzt. Eine Zeile version: ganz oben brauchst Du nicht mehr: Compose ignoriert sie laut Compose File Reference und gibt nur eine Warnung aus.

Images und Versionen

Die Zeile image: miniflux/miniflux:2.3.5 legt fest, welches Image Compose lädt. Der Teil nach dem Doppelpunkt heißt Tag und steht hier für eine feste Version. Damit weißt Du jederzeit genau, was läuft, und ein Update passiert nur, wenn Du die Nummer selbst änderst. Mit :latest oder ganz ohne Tag lädt dagegen jedes pull die neueste Version, auch wenn sie Umbauten mitbringt, die Deine Konfiguration nicht verträgt. Welche Tags es gibt, siehst Du auf der Seite des Images bei Docker Hub. Manche Projekte bieten zusätzlich Tags wie 2 für eine ganze Versionsreihe an, Miniflux tut das nicht.

Bei Datenbanken ist das besonders heikel. PostgreSQL kann die Daten einer älteren Hauptversion nicht einfach öffnen, ein Sprung von 17 auf 18 braucht eine Migration. Schreib bei der Datenbank deshalb immer die Hauptversion fest, hier postgres:18-alpine. Der Zusatz alpine steht für ein besonders kleines Basis-Image.

Volumes: wo Deine Daten wirklich liegen

Ein Container ist zum Wegwerfen gedacht. Alles, was die Datenbank in den Container selbst schreibt, ist beim nächsten Update weg, denn dabei entsteht ein neuer Container aus dem neuen Image. Damit die Daten überleben, legst Du sie in ein Volume, also einen Speicherort außerhalb des Containers, den Compose bei jedem neuen Container wieder einhängt.

Es gibt zwei Arten, und beide haben ihren Platz:

Named VolumeBind Mount
Schreibweisedb-daten:/var/lib/postgresql./daten:/var/lib/postgresql
Speicherortvon Docker verwaltet unter /var/lib/docker/volumes/ein Ordner Deiner Wahl auf dem Host
Rechterichtet Docker passend einDu kümmerst Dich selbst um Besitzer und Rechte
Gut fürDatenbanken und interne App-DatenKonfigurationsdateien, Medien, Ordner, die Du selbst bearbeitest

Der Unterschied steckt nur im linken Teil vor dem Doppelpunkt: Ein Name ohne Schrägstrich ist ein Named Volume, ein Pfad mit ./ oder / ist ein Bind Mount. Rechts steht immer der Pfad im Container, und genau dieser muss stimmen. Steht dort ein Pfad, in den die Software gar nicht schreibt, sind die Daten trotz Volume beim nächsten Update verloren.

Ein aktuelles Beispiel für diese Falle ist PostgreSQL 18. Bis Version 17 lag das Datenverzeichnis unter /var/lib/postgresql/data, und fast alle Anleitungen hängen das Volume genau dort ein. Ab Version 18 legt das offizielle Image die Daten in einem Unterordner mit der Versionsnummer ab und erwartet das Volume eine Ebene höher unter /var/lib/postgresql. Deshalb steht es im Beispiel oben so. Prüf bei jeder Software in ihrer Doku, welchen Pfad sie für ihre Daten nutzt.

Named Volumes bekommen von Compose den Projektnamen als Vorsilbe. Der Projektname ist standardmäßig der Ordnername, aus db-daten wird also miniflux_db-daten. So siehst Du alle Volumes auf dem Rechner:

Code
docker volume ls

Netzwerke: der Dienstname ist der Hostname

In der DATABASE_URL steht @db als Adresse der Datenbank. Das ist kein Zufall: Compose legt für jedes Projekt automatisch ein eigenes Netzwerk an, hier miniflux_default, und hängt alle Dienste daran. In diesem Netz löst ein eingebauter DNS-Server jeden Dienstnamen in die aktuelle IP-Adresse des Containers auf. Die App erreicht die Datenbank also unter dem Namen db und dem normalen Port 5432, wie die Doku zu Networking in Compose beschreibt.

Daraus folgen zwei Regeln, die viele Fehler verhindern:

  • Nie localhost für einen anderen Container. Im App-Container zeigt localhost auf den App-Container selbst, nicht auf die Datenbank. Nimm immer den Dienstnamen.
  • Nie feste IP-Adressen. Container bekommen bei jedem Neustart unter Umständen eine andere Adresse. Der Dienstname bleibt gleich.

Welche Container in einem Netz hängen und welche Adressen sie gerade haben, zeigt Dir:

Code
docker network inspect miniflux_default

Container aus verschiedenen Compose-Projekten sehen sich standardmäßig nicht, weil jedes Projekt sein eigenes Netz bekommt. Soll etwa ein Reverse Proxy aus einem anderen Projekt die App erreichen, legst Du ein gemeinsames Netz an und trägst es in beiden Projekten als external ein.

Ports nur veröffentlichen, wenn nötig

Die Datenbank im Beispiel hat keinen Abschnitt ports. Das ist Absicht: Die App erreicht sie über das interne Netz, und von außen muss niemand direkt an die Datenbank. Jeder Port, den Du nicht veröffentlichst, ist ein Angriffspunkt weniger.

Bei der App steht "127.0.0.1:8080:8080". Das Format ist Host-Adresse:Host-Port:Container-Port. Der vordere Teil 127.0.0.1 sorgt dafür, dass die App nur auf dem Rechner selbst erreichbar ist, nicht aus dem Netz. Das ist die richtige Einstellung, wenn ein Reverse Proxy wie Caddy oder Nginx auf demselben Rechner davorsitzt und HTTPS übernimmt. Lässt Du die Adresse weg und schreibst nur "8080:8080", lauscht die App auf allen Netzwerkschnittstellen.

Das ist auf einem Server im Internet heikel, denn Docker umgeht dabei die Firewall ufw. Docker trägt seine eigenen Regeln direkt in iptables ein, und die greifen, bevor ufw den Datenverkehr überhaupt sieht. Die Docker-Doku zu Paketfiltern und Firewalls erklärt das ausführlich. Ein veröffentlichter Port ist also aus dem Internet erreichbar, auch wenn ufw ihn blockieren soll. Binde Ports deshalb an 127.0.0.1, wann immer ein Proxy davor sitzt.

Variablen und die .env-Datei

In der compose.yaml stehen statt der Passwörter Platzhalter wie ${POSTGRES_PASSWORD}. Compose ersetzt sie beim Start durch Werte aus einer Datei namens .env im selben Ordner. Leg sie an:

Code
nano .env

Trag dort die beiden Passwörter ein, ohne Anführungszeichen und ohne Leerzeichen um das Gleichheitszeichen:

Code
POSTGRES_PASSWORD=ein-langes-zufaelliges-passwort
ADMIN_PASSWORD=dein-passwort-fuer-die-weboberflaeche

Ersetze die Werte durch eigene, lange Passwörter. Ein zufälliges Passwort erzeugst Du zum Beispiel mit openssl rand -hex 24. Speichere mit Strg + O, bestätige mit Enter und schließe den Editor mit Strg + X. Danach schränkst Du die Rechte ein, damit nur Dein Benutzer die Datei lesen kann:

Code
chmod 600 .env

Der Vorteil: Die compose.yaml enthält keine Geheimnisse mehr und darf in ein Git-Repository. Die .env gehört dagegen in die .gitignore. Wenn Du sie trotzdem versionieren willst, zum Beispiel als Backup Deiner Konfiguration, zeigt Dir der Artikel sops und age: .env-Dateien verschlüsselt in Git, wie das sicher geht.

Ob Compose alle Variablen richtig einsetzt, prüfst Du vor dem Start mit:

Code
docker compose config

Der Befehl gibt die fertige Konfiguration aus, so wie Compose sie verwenden würde, mit eingesetzten Werten. Fehlt eine Variable in der .env, erscheint eine Warnung, und an ihrer Stelle steht ein leerer Wert. Achtung: Die Ausgabe enthält Deine Passwörter im Klartext, kopier sie also nicht in ein Forum.

Neustart und Startreihenfolge

Die Zeile restart: unless-stopped sorgt dafür, dass ein Container nach einem Absturz und nach einem Neustart des Servers automatisch wieder startet. Nur wenn Du ihn selbst mit docker compose stop anhältst, bleibt er aus.

depends_on regelt die Reihenfolge. Ohne Zusatz wartet Compose nur, bis der Datenbank-Container gestartet ist. PostgreSQL braucht danach aber noch ein paar Sekunden, bis es Verbindungen annimmt, und in dieser Zeit stürzt die App mit einem Verbindungsfehler ab. Deshalb steht im Beispiel condition: service_healthy: Compose wartet, bis der Healthcheck der Datenbank erfolgreich ist. Der Healthcheck ruft alle zehn Sekunden pg_isready auf, ein kleines Werkzeug aus dem PostgreSQL-Image, das genau diese Frage beantwortet.

Jetzt starten wir den Stack:

Code
docker compose up -d

Der Schalter -d startet die Container im Hintergrund. Ob beide laufen, zeigt:

Code
docker compose ps

In der Spalte STATUS steht bei der Datenbank nach etwa einer halben Minute healthy, bei der App Up. Öffne danach im Browser auf dem Server http://127.0.0.1:8080 oder, über Deinen Reverse Proxy, Deine Domain, und melde Dich mit admin und dem Passwort aus der .env an. Mit docker compose logs -f app verfolgst Du die Ausgaben der App live, Strg + C beendet die Anzeige.

Updates sicher einspielen

Ein Update läuft immer in diesen Schritten:

Code
docker compose pull
docker compose up -d
docker image prune

Bei festen Versionen wie im Beispiel trägst Du vorher die neue Nummer in der compose.yaml ein, nachdem Du die Release Notes gelesen hast. pull lädt die neuen Images, up -d ersetzt nur die Container, deren Image sich geändert hat, und image prune räumt die alten Images weg. Bei der Datenbank bleibst Du innerhalb der Hauptversion: postgres:18-alpine bekommt so automatisch die Fehlerkorrekturen der Reihe 18. Deine Daten bleiben erhalten, weil sie im Volume liegen und nicht im Container. Wie Du automatisch erfährst, wann ein neues Image bereitsteht, ohne Container blind aktualisieren zu lassen, zeigt der Artikel Docker-Container aktualisieren: Diun statt Watchtower.

Ein Befehl verdient besondere Vorsicht: docker compose down hält die Container an und löscht sie samt Netzwerk, die Volumes aber bleiben. Erst mit dem Zusatz -v löscht Compose auch die Named Volumes, und damit Deine Datenbank. Diesen Zusatz brauchst Du nur, wenn Du ein Projekt wirklich komplett entfernen willst.

Backup der Daten

Ein Volume ist kein Backup. Für Datenbanken ist ein Dump die sicherste Form, weil er auch bei laufender Datenbank in sich stimmig ist:

Code
docker compose exec db pg_dump -U miniflux miniflux > miniflux-backup.sql

Der Befehl führt pg_dump im Datenbank-Container aus und schreibt das Ergebnis in die Datei miniflux-backup.sql im aktuellen Ordner auf dem Host. Für andere Named Volumes ohne Datenbank, etwa hochgeladene Dateien, packst Du den Inhalt mit einem kurzlebigen Hilfscontainer in ein Archiv:

Code
docker run --rm -v miniflux_db-daten:/quelle:ro -v "$PWD":/ziel alpine tar czf /ziel/volume-backup.tar.gz -C /quelle .

Ersetze miniflux_db-daten durch den Namen aus docker volume ls. Halt die Container dafür vorher mit docker compose stop an, sonst kann das Archiv bei einer Datenbank unvollständige Daten enthalten. Die Sicherungen gehören anschließend auf einen anderen Rechner oder in die Cloud, am besten automatisch und verschlüsselt, wie im Artikel Backups mit restic: verschlüsselt auf S3 und Spaces beschrieben.

Die fünf häufigsten Fehler

  1. Daten nach dem Update weg: Das Volume hängt am falschen Pfad im Container, oder es fehlt ganz. Vergleiche den rechten Teil der Volume-Zeile mit der Doku der Software, wie oben bei PostgreSQL 18.
  2. „Connection refused” zur Datenbank: Die App versucht es mit localhost statt mit dem Dienstnamen, oder sie startet, bevor die Datenbank bereit ist. Nutze db als Host und depends_on mit condition: service_healthy.
  3. Variablen bleiben leer: Die .env liegt nicht im selben Ordner wie die compose.yaml, oder Du startest Compose aus einem anderen Ordner. docker compose config zeigt sofort, was ankommt.
  4. Passwortänderung wirkt nicht: PostgreSQL liest POSTGRES_PASSWORD nur beim allerersten Start, wenn das Volume noch leer ist. Später änderst Du das Passwort in der Datenbank selbst, eine geänderte .env allein reicht nicht.
  5. Port trotz Firewall offen: Ein veröffentlichter Port ohne 127.0.0.1 ist aus dem Internet erreichbar, auch wenn ufw ihn sperrt. Binde ihn an 127.0.0.1 und lass den Reverse Proxy den Zugriff von außen übernehmen.

Wenn Du für Deinen ersten Stack noch einen Server suchst: Ein kleiner Droplet bei DigitalOcean (Affiliate Link) mit 1 GB RAM reicht für Miniflux und vergleichbare Dienste, für mehrere Stacks mit Datenbanken nimm 2 GB oder mehr.

Weiterlesen.