YAML: Die Auszeichnungssprache in der Docker-Compose-Welt

Aus Tuxipedia

YAML — Die Auszeichnungssprache in der Docker-Compose-Welt

YAML steht für „YAML Ain't Markup Language" — ein rekursives Akronym, typisch für die Open-Source-Welt. Es ist eine Sprache zur Darstellung strukturierter Daten, die für Menschen lesbar sein soll.


Geschichte

YAML wurde 2001 von drei Entwicklern entworfen: Clark Evans, Ingy döt Net und Oren Ben-Kiki. Das ursprüngliche Akronym lautete noch „Yet Another Markup Language" — in Anlehnung an die vielen XML-Varianten der Zeit. Es wurde bald zu „YAML Ain't Markup Language" umgedeutet, um klarzustellen: das hier ist keine Dokumentensprache wie HTML oder XML, sondern eine reine Datensprache.

Das Grundproblem das YAML lösen wollte war real: XML war für Konfigurationsdateien viel zu geschwätzig — jeder Wert brauchte ein öffnendes und schließendes Tag. JSON existierte noch gar nicht (das kam erst 2002). Man wollte etwas das Menschen wirklich lesen und schreiben können, ohne ständig spitze Klammern tippen zu müssen.

Verbreitung vor Docker

Docker hat YAML nicht erfunden — es hat YAML einer neuen Generation bekannt gemacht. Vorher war YAML in anderen Welten zuhause:

  • Ruby on Rails (2004) — der erste große Durchbruch. Rails verwendete YAML
 für Datenbankverbindungen, Übersetzungen und Konfiguration. Eine ganze Generation
 von Webentwicklern lernte YAML über Rails, lange vor Docker.
  • Ansible (2012) — das Konfigurationsmanagement-Tool wählte YAML als zentrale
 Sprache für Playbooks. Für Systemadministratoren war Ansible der eigentliche
 YAML-Einstieg.
  • Kubernetes (2014) — parallel zu Docker entstanden, ebenfalls YAML für alles.
 Kubernetes hat YAML in der Infrastrukturwelt zementiert.
  • CI/CD-Systeme — GitHub Actions, GitLab CI, Travis CI, CircleCI — alle
 verwenden YAML für Pipeline-Definitionen.
  • OpenAPI/Swagger — API-Dokumentation wird seit Jahren in YAML geschrieben.

Docker Compose hat YAML dann für Heimserver-Betreiber und Administratoren ohne Rails- oder Ansible-Hintergrund zum Standard gemacht.

Kritik in der Fachwelt

YAML hat einen schlechten Ruf in Teilen der Entwicklergemeinde — nicht wegen der Grundidee, sondern wegen der Implementierung.

Die Spezifikation ist notorisch komplex: YAML 1.2 umfasst über 80 Seiten. Verschiedene Parser verhalten sich bei Grenzfällen unterschiedlich. Und es gibt berühmte Stolperfallen:

# Diese Werte sind KEIN String — YAML interpretiert sie als Boolean:
land: no        # → false
antwort: yes    # → true
aktiv: on       # → true

# Das hier ist kein String — es wird zur Zahl:
version: 1.0    # → Float 1.0, nicht String "1.0"
port: 8080      # → Integer

Das bekannteste Beispiel ist das sogenannte Norway Problem: das Länderkürzel NO wird in älteren YAML-Parsern automatisch zu false — was dazu geführt hat dass norwegische Entwickler in Konfigurationsdateien ihren eigenen Ländercode nicht als String verwenden konnten ohne Anführungszeichen.🤣

YAML 1.2 (2009) hat viele dieser Probleme behoben, aber ältere Parser sind noch weit verbreitet.

Das Fazit vieler erfahrener Entwickler: YAML ist für Menschen gut lesbar, aber für Programme schwer korrekt zu implementieren. Für Konfigurationsdateien die selten geändert werden ist es hervorragend — für Programme die YAML generieren oder parsen müssen, ist es eine Quelle von Überraschungen.


YAML und JSON — Verwandtschaft, kein Dialekt

Der häufige Vergleich „YAML ist ein JSON-Dialekt" ist ungenau. Die präzise Formulierung: YAML ist eine Obermenge von JSON.

  • Jedes gültige JSON ist auch gültiges YAML.
  • Umgekehrt gilt das nicht: YAML kann Dinge ausdrücken die JSON nicht kann
 (Kommentare, mehrzeilige Strings, Anker und Referenzen).
  • Die Syntax ist völlig unterschiedlich: JSON verwendet geschweifte Klammern
 und Anführungszeichen, YAML verwendet Einrückung und Doppelpunkte.

Dasselbe Datenkonstrukt in beiden Sprachen:

JSON:

{
  "services": {
    "nextcloud": {
      "image": "nextcloud:latest",
      "restart": "unless-stopped"
    }
  }
}

YAML:

services:
  nextcloud:
    image: nextcloud:latest
    restart: unless-stopped

YAML ist lesbarer — auf Kosten einer strengen Regel: Einrückung mit Leerzeichen, niemals mit Tabs. Ein falsch gesetzter Tab ist der häufigste Fehler in compose-Dateien.

Kommentare

YAML unterstützt Kommentare mit # — JSON nicht. Das macht YAML deutlich besser geeignet für Konfigurationsdateien die Menschen pflegen:

# Dieser Container darf keinen direkten Außenzugriff haben
ports: []  # keine ports → kein direkter Zugriff von außen

Datentypen

YAML erkennt Datentypen automatisch:

Wert Typ Anmerkung
true / false Boolean Ohne Anführungszeichen
42 Integer
3.14 Float
hallo String Ohne Anführungszeichen wenn eindeutig
"true" String Mit Anführungszeichen erzwungen
null Null

Stolperfalle: restart: always ist ein String. restart: true wäre ein Boolean — und ungültig. Im Zweifel Anführungszeichen setzen.


Grundaufbau einer docker-compose.yml

Eine docker-compose.yml besteht aus maximal vier Abschnitten auf oberster Ebene:

services:      # Pflicht — definiert die Container
  ...

networks:      # Optional — definiert Netzwerke
  ...

volumes:       # Optional — definiert persistente Datenspeicher
  ...

configs:       # Optional — definiert Konfigurationsdateien
  ...

Der services-Abschnitt ist der einzig verpflichtende. Alles andere ist optional und wird nur angegeben wenn benötigt.

Einrückungsregeln

Die Hierarchie wird ausschließlich durch Einrückung ausgedrückt. Zwei Leerzeichen pro Ebene sind Konvention:

services:           # Ebene 1
  nextcloud:        # Ebene 2 — Name des Dienstes
    image: ...      # Ebene 3 — Direktive des Dienstes
    networks:       # Ebene 3 — Direktive (Liste folgt)
      - tuxi_net    # Ebene 4 — Listenelement

Direktiven im services-Abschnitt

image

Das Docker-Image das für den Container verwendet wird.

services:
  nextcloud:
    image: lscr.io/linuxserver/nextcloud:latest

Format: registry/image:tag. Ohne Registry wird Docker Hub angenommen. Ohne Tag wird latest angenommen — aber explizit angeben ist besser für Reproduzierbarkeit.

container_name

Fester Name des Containers. Ohne diese Direktive generiert Docker Compose einen Namen aus Projektname + Servicename + Nummer.

services:
  nextcloud:
    container_name: nextcloud

Wichtig: der Container-Name ist gleichzeitig der Docker-interne DNS-Name mit dem andere Container diesen Dienst ansprechen können.

restart

Neustart-Verhalten des Containers.

restart: unless-stopped
Wert Verhalten
no Nie neu starten (Standard)
always Immer neu starten, auch nach manuellem Stopp
unless-stopped Neu starten außer wenn manuell gestoppt — empfohlen für Produktionsdienste
on-failure Nur neu starten wenn der Container mit Fehlercode endet

image vs. build

Statt ein fertiges Image zu verwenden kann Docker Compose auch selbst eines bauen:

services:
  mein-dienst:
    build:
      context: ./mein-verzeichnis   # Pfad zum Dockerfile
      dockerfile: Dockerfile.prod   # optional: anderer Dateiname

ports

Portweiterleitung zwischen Host und Container: HOST:CONTAINER.

services:
  caddy:
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"   # für HTTP/3

Wichtig: Wer ports weglässt, ist nur im Docker-Netzwerk erreichbar — nicht von außen. Das ist für interne Dienste wie Datenbanken oder Collabora gewünscht.

volumes

Einbinden von Verzeichnissen oder benannten Volumes in den Container.

services:
  nextcloud:
    volumes:
      - ./nextcloud/app:/config       # Bind Mount: Host-Pfad → Container-Pfad
      - /data/nextcloud-data:/data    # absoluter Host-Pfad
      - caddy_data:/data              # benanntes Volume (unten definiert)

volumes:
  caddy_data:                         # Docker verwaltet den Speicherort
Typ Syntax Wann verwenden
Bind Mount ./host/pfad:/container/pfad Wenn du direkten Zugriff auf die Dateien brauchst
Benanntes Volume volume_name:/container/pfad Für Datenbankdaten, Caches — Docker verwaltet den Speicherort
tmpfs Siehe tmpfs-Direktive Flüchtige Daten die nur im RAM leben sollen

environment

Umgebungsvariablen die in den Container übergeben werden.

services:
  nextcloud:
    environment:
      - PUID=1000
      - PGID=1001
      - TZ=Europe/Berlin
      - MYSQL_PASSWORD=${MYSQL_PASSWORD}   # Wert aus .env-Datei

Alternativ als Map-Schreibweise:

    environment:
      PUID: "1000"
      TZ: Europe/Berlin

env_file

Umgebungsvariablen aus einer externen Datei laden — sinnvoll für Passwörter:

services:
  nc-db:
    env_file:
      - .env

Die .env-Datei enthält dann:

MYSQL_ROOT_PASSWORD=sehrgeheim
MYSQL_DATABASE=nextcloud
MYSQL_USER=ncuser
MYSQL_PASSWORD=auchgeheim

Die .env-Datei gehört in .gitignore — niemals in ein öffentliches Repository einchecken.

networks

Netzwerkzugehörigkeit des Containers.

services:
  nextcloud:
    networks:
      mein_netzwerk:
        ipv4_address: 172.18.0.20   # feste IP (optional)

  collabora:
    networks: [mein_netzwerk]       # Kurzschreibweise ohne feste IP

networks:
  mein_netzwerk:
    external: true                  # Netzwerk existiert bereits

Ohne networks-Angabe landet der Container im automatisch erstellten Default-Netzwerk des Projekts — Container in verschiedenen Projekten können sich dann nicht erreichen.

extra_hosts

Einträge die direkt in die /etc/hosts des Containers geschrieben werden. Überschreibt DNS — wird vor jeder DNS-Anfrage ausgewertet.

services:
  nextcloud:
    extra_hosts:
      - "collabora.beispiel.de:172.18.0.6"

Typischer Anwendungsfall: Hairpinning vermeiden — ein Container soll eine Domain intern auflösen statt den Umweg über die externe IP zu nehmen. Siehe Hairpin-NAT – Collabora und Nextcloud hinter Caddy.

depends_on

Startreihenfolge und Abhängigkeiten zwischen Diensten.

services:
  nextcloud:
    depends_on:
      db:
        condition: service_healthy   # wartet bis Healthcheck grün ist
      redis:
        condition: service_healthy
condition Bedeutung
service_started Container wurde gestartet (Standard — prüft nicht ob er wirklich läuft)
service_healthy Healthcheck des abhängigen Dienstes ist grün
service_completed_successfully Abhängiger Dienst hat sich mit Exit-Code 0 beendet

healthcheck

Prüft ob der Container wirklich funktioniert — nicht nur ob er läuft.

services:
  nc-db:
    healthcheck:
      test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u root -p$$MYSQL_ROOT_PASSWORD || exit 1"]
      interval: 10s      # wie oft prüfen
      timeout: 5s        # wie lange auf Antwort warten
      retries: 30        # wie oft wiederholen bevor unhealthy
      start_period: 30s  # Anlaufzeit bevor Healthcheck zählt

Das $$ ist kein Tippfehler — doppeltes Dollar-Zeichen verhindert dass Compose die Variable selbst auflöst; sie wird an die Shell im Container übergeben.

tmpfs

Flüchtige Dateisysteme die nur im RAM leben und beim Container-Stopp verschwinden.

services:
  nc-redis:
    tmpfs:
      - /data
      - /tmp
      - /run

  collabora:
    tmpfs:
      - /tmp
      - /var/tmp

Redis im RAM-only-Modus: schneller, kein Disk-I/O, Daten gehen beim Neustart verloren — für Session-Caches gewünscht.

dns

DNS-Server die der Container verwenden soll statt des Docker-Defaults.

services:
  nextcloud:
    dns:
      - 1.1.1.1    # Cloudflare
      - 9.9.9.9    # Quad9
      - 8.8.8.8    # Google

command

Überschreibt den Standard-Startbefehl des Images.

services:
  nc-db:
    command: --transaction-isolation=READ-COMMITTED --binlog-format=ROW

security_opt und cap_drop / cap_add

Container-Härtung: Linux-Capabilities entziehen oder hinzufügen.

services:
  nc-redis:
    security_opt:
      - no-new-privileges:true   # verhindert Privilege Escalation
    cap_drop:
      - ALL                      # alle Capabilities entziehen
    cap_add:
      - NET_BIND_SERVICE         # nur diese eine wieder erlauben

Faustregel:

  • no-new-privileges: true — immer möglich, immer gut
  • cap_drop: ALL — wo immer es geht
  • read_only: true — nur bei schlanken Services ohne viele Schreibpfade

read_only

Container-Dateisystem schreibgeschützt — kombiniert mit tmpfs für Verzeichnisse die Schreibzugriff benötigen:

services:
  nc-redis:
    read_only: true
    tmpfs:
      - /data
      - /tmp
      - /run

Der networks-Abschnitt

Externes Netzwerk verwenden

networks:
  mein_netzwerk:
    external: true   # Netzwerk wurde bereits mit docker network create angelegt

Neues Netzwerk anlegen

networks:
  mein_netzwerk:
    driver: bridge
    ipam:
      config:
        - subnet: 172.20.0.0/16
          gateway: 172.20.0.1

Der volumes-Abschnitt

Benanntes Volume

volumes:
  caddy_data:      # Docker verwaltet den Speicherort unter /var/lib/docker/volumes/
  caddy_config:

Volume mit tmpfs-Treiber (RAM-Disk)

volumes:
  cool-child-roots:
    driver_opts:
      type: tmpfs
      device: tmpfs
      o: "size=4g,mode=1777"

Nützliche CLI-Befehle

Befehl Was er tut
docker compose up -d Alle Dienste im Hintergrund starten
docker compose down Alle Dienste stoppen und Container entfernen
docker compose restart Alle Dienste neu starten
docker compose restart caddy Nur einen Dienst neu starten
docker compose up -d --force-recreate nextcloud Einen Container neu erstellen
docker compose logs -f nextcloud Log eines Dienstes live verfolgen
docker compose ps Status aller Dienste anzeigen
docker compose exec nextcloud bash Shell in laufendem Container öffnen
docker compose pull Alle Images aktualisieren

Häufige Fehler

Fehler Ursache Fix
yaml: line X: found character that cannot start any token Tab statt Leerzeichen Alle Tabs durch Leerzeichen ersetzen
Address already in use Feste IP bereits von anderem Container belegt docker network inspect → Schuldigen finden
Container startet, Dienst funktioniert nicht depends_on ohne condition: service_healthy Healthcheck ergänzen
Umgebungsvariable leer .env liegt nicht im selben Verzeichnis Pfad prüfen oder env_file explizit angeben
$$VARIABLE wird nicht aufgelöst Gewolltes Verhalten Für Shell-Variablen im Container: $$. Für Compose-Variablen: $

Siehe auch