
Die docker-compose.yaml — was du ueber Ports und Netzwerke wissen solltest
Ich arbeite fast taeglich damit und trotzdem passiert es mir immer mal wieder: Ich schaue auf eine docker-compose.yaml und denke „warte, warum geht der Port jetzt an localhost und nicht an 0.0.0.0?“ Oder schlimmer: Ein Service findet einen anderen nicht, obwohl beide im gleichen Compose-File stehen.
Meistens liegt es an zwei Dingen: dem ports-Mapping und den Netzwerk-Definitionen. Beides ist eigentlich simpel, aber es gibt ein paar Ecken, in denen Docker einem auf die Fuesse tritt, wenn man nicht aufpasst.
ports: HOST:CONTAINER, nicht andersrum
Das hier ist die Kurzform, die in 90% aller compose-Files steht:
services:
web:
image: nginx
ports:
- "8080:80"
Links steht der Host-Port, rechts der Container-Port. 8080 ist das, was du im Browser aufrufst. 80 ist das, worauf nginx im Container lauscht. Docker nimmt den Traffic von Host:8080 und reicht ihn an Container:80 durch. Fertig.
Du kannst den Host-Port auch weglassen:
ports:
- "80" # Docker sucht sich selbst einen Host-Port
Docker wuerfelt dann einen freien High-Port auf dem Host. Praktisch, wenn du drei Instanzen vom gleichen Dienst startest und dir egal ist, auf welchem Port die landen — Hauptsache, nichts kollidiert.
Langform: Wenn du es wirklich genau wissen willst
Es gibt noch eine ausfuehrliche Syntax mit allen Optionen. Die meisten brauchen sie nicht, aber sie steht halt in der Doku und irgendwann stolpert man doch drueber:
ports:
- target: 80
published: 8080
protocol: tcp
mode: host
protocol ist standardmaessig tcp, du kannst aber auch udp angeben. mode: host umgeht den Docker-Proxy und bindet den Port direkt ans Host-Interface, das bringt ein bisschen Performance in Szenarien, wo es darauf ankommt. Default ist ingress, also ueber Dockers internen Load Balancer.
Fuer den Alltag reicht die Kurzform. Ich hab die Langform in freier Wildbahn noch nie gesehen, ausser vielleicht in irgendwelchen Enterprise-YAMLs, die 400 Zeilen lang sind und bei denen keiner mehr weiss, warum das so ist.
expose vs. ports – das Missverstaendnis
Das hier verwirrt jeden am Anfang:
services:
api:
image: meine-api
expose:
- "3000" # Sichtbar im compose-Netzwerk, nicht am Host
web:
image: nginx
ports:
- "8080:80" # Am Host erreichbar
expose öffnet den Port nur innerhalb des Docker-Netzwerks. Andere Container im selben Netzwerk sehen Port 3000, aber von ausserhalb (Host, LAN, Browser) kommt keiner dran. ports macht den Port am Host verfuegbar.
Kleiner Twist: Container im selben compose-Netzwerk koennen sich auch ganz ohne expose ueber alle Ports erreichen. expose ist im Grunde nur Doku – „dieser Port wird intern genutzt“. Es schränkt nichts ein. Aber es liest sich netter, wenn man explizit hinschreibt, was der Container nach innen anbietet.
Externe Netzwerke: Container ueber Compose-Grenzen hinweg verbinden
Normalerweise kriegt jeder compose-Stack sein eigenes Netzwerk. Container im Stack duerfen miteinander reden, alles andere ist abgeschottet. Das funktioniert prima, bis zu dem Moment, wo du zwei verschiedene Stacks verbinden musst.
Klassisches Beispiel: Du hast einen Traefik-Container in Stack A und eine Webapp in Stack B. Traefik soll die Webapp routen, aber die beiden kennen sich nicht, weil sie in unterschiedlichen compose-Netzwerken stecken.
Loesung: ein Netzwerk ausserhalb von Compose anlegen und beide Stacks da reinhaengen. Das Stichwort heisst external: true:
networks:
traefik-public:
external: true
services:
whoami:
image: traefik/whoami
networks:
- traefik-public
Vorher musst du das Netzwerk einmal anlegen:
docker network create traefik-public
Ohne diesen Schritt motzt Docker: network traefik-public declared as external, but could not be found. Der Fehler ist selbsterklaerend, aber ich hab ihn trotzdem schon oefter gesehen, als ich zugeben will.
Praxisbeispiel: App, Datenbank und Reverse Proxy
Hier ist ein Setup, wie ich es in vielen Projekten verwende. Drei Services, zwei Netzwerke, sauber getrennt zwischen dem, was nach aussen sichtbar ist und dem, was intern bleibt:
networks:
backend:
# Nur fuer app und db -- kein externer Zugriff
traefik-public:
external: true
services:
app:
image: meine-webapp:latest
expose:
- "3000"
networks:
- backend
- traefik-public
environment:
- DB_HOST=db
- DB_PORT=5432
db:
image: postgres:16
environment:
- POSTGRES_PASSWORD=geheim
networks:
- backend
# Keine ports -- die DB erreicht keiner von aussen
reverse-proxy:
image: traefik:v3
ports:
- "80:80"
- "443:443"
networks:
- traefik-public
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
Die app haengt in beiden Netzwerken: ueber backend spricht sie mit der Datenbank, ueber traefik-public bekommt Traefik sie zu fassen. Die Datenbank steckt ausschliesslich in backend — kein Port nach draussen, kein externes Netzwerk. Traefik sitzt nur im oeffentlichen Netzwerk und braucht keinen Zugriff auf die DB.
Das Schoene an diesem Aufbau: Wenn jemand die Datenbank kompromittieren will, muesste er erst in die App eindringen, die als einziges im internen Netzwerk haengt. Kein offener DB-Port, den man von aussen scannen koennte.
Dinge, die mir schon auf die Fuesse gefallen sind
Kein theoretischer Kram, das ist alles live passiert:
Port schon belegt. Passiert staendig. Irgendein Prozess hoert noch auf 8080, Docker startet nicht, und du fluchst zehn Minuten lang, bis dir einfaellt, dass da noch der Dev-Server von letzter Woche laeuft. ss -tlnp | grep 8080 zeigt dir, wer Schuld ist.
0.0.0.0 vs. 127.0.0.1. Docker bindet Ports standardmaessig an alle Interfaces. Dein Container ist dann im gesamten Netzwerk sichtbar — auch aus dem Internet, wenn der Rechner eine oeffentliche IP hat und keine zusaetzliche Firewall dazwischen sitzt. Mit "127.0.0.1:8080:80" ist der Port nur an localhost gebunden. Das ist der richtige Default fuer Dev-Sachen, die keiner von aussen sehen soll.
Container-IPs aendern sich. Greif nie ueber IP-Adressen auf andere Container zu. Docker wuerfelt die bei jedem Neustart neu. Nutz den Service-Namen — der eingebaute DNS loest den fuer dich auf. DB_HOST=db reicht.
Netzwerke nicht getrennt. Ein compose-File heisst nicht automatisch „alle duerfen mit allen reden“. Wenn dein Reverse Proxy keinen Zugriff auf die DB braucht, dann gib ihm auch keinen. Separate Netzwerke fuer interne und externe Kommunikation kosten zwei Extra-Zeilen und ersparen dir Kopfschmerzen, sobald das Setup groesser wird.
Docker und Firewall. Docker umgeht iptables/nftables komplett und schreibt seine eigenen Regeln. Wenn du ufw oder firewalld verwendest, ist dein Container-Port offen, auch wenn deine Firewall-Konfiguration das nicht hergibt. Das ist kein Bug, sondern so designed, aber ich bin da auch schon reingelaufen. Bei oeffentlichen Servern immer doppelt checken, was wirklich offen ist — nmap oder docker ps --format '{{.Ports}}' hilft.
Fazit
Ich mag Docker Compose, weil es einem den ganzen docker run --net=irgendwas --publish=irgendwas-Krampf abnimmt und sauber in eine Datei packt. Aber so eine YAML ist kein magisches „mach mal“-Dokument, man sollte schon wissen, was mit den Ports und Netzwerken tatsaechlich passiert.
Wenn du zwei Sachen aus diesem Post mitnimmst: Ports nicht mit expose verwechseln, und die Netzwerke so bauen, dass nicht jeder Container mit jedem reden kann. Der Rest ist Syntax, die kannst du im Zweifel googeln.