Der Ablauf ist immer derselbe: ufw status meldet Status: active, Port 5432 taucht nirgends auf, und trotzdem lässt sich die Datenbank von außen erreichen. Kein Konfigurationsfehler, so ist Docker gebaut.
Die Lücke ist besonders unangenehm, weil sie mit einem falschen Sicherheitsgefühl einhergeht. Wer ufw status prüft und beruhigt weitergeht, hat eine offene Datenbank und einen Beleg dafür, dass alles zu ist.
Warum das passiert#
Docker richtet beim Veröffentlichen eines Ports eine DNAT-Regel in der nat-Tabelle ein. Diese greift, bevor die filter-Regeln von ufw drankommen, der Verkehr ist also schon umgeleitet, wenn ufw ihn sehen würde.
Sichtbar wird es so:
# Was ufw meint
sudo ufw status verbose
# Was tatsächlich gilt
sudo iptables -t nat -L DOCKER -n --line-numbers
sudo iptables -L DOCKER -n | head -20
Und die Gegenprobe von außen, von einer anderen Maschine:
nmap -Pn -p 5432,6379,9090 203.0.113.10
Die gute Lösung: gar nicht erst veröffentlichen#
Der Reflex, gegen Docker anzukämpfen, führt in die Irre. Der eigentliche Fehler steckt meist in der Compose-Datei.
services:
db:
image: postgres:17.4-alpine
# falsch: bindet auf allen Adressen
# ports: ["5432:5432"]
#
# richtig, wenn Zugriff von außerhalb des Compose-Netzes nötig ist:
ports: ["127.0.0.1:5432:5432"]
networks: [intern]
app:
image: ghcr.io/beispiel/app:1.8.2
# erreicht die Datenbank über den Dienstnamen, ohne veröffentlichten Port
environment:
DATABASE_URL: postgres://app:${DB_PASSWORT}@db:5432/app
ports: ["127.0.0.1:3000:3000"]
networks: [intern, web]
networks:
intern:
internal: true # kein Weg ins Internet
web:
Drei Regeln, die das Problem an der Wurzel lösen:
- Was nur intern gebraucht wird, bekommt gar kein
ports. Container im selben Netz erreichen sich über den Dienstnamen. - Was lokal erreichbar sein muss, bindet an
127.0.0.1. - Was von außen erreichbar sein muss, steht hinter einem Reverse-Proxy, dann ist genau ein Port offen und TLS liegt an einer Stelle.
# Kontrolle: keine Zeile darf 0.0.0.0 enthalten
docker compose ps --format 'table {{.Service}}\t{{.Ports}}'
Wenn es doch eine Firewallregel braucht#
Für Fälle, in denen ein Container auf allen Adressen hören muss, aber nur bestimmte Quellen erlaubt sind, gibt es die Kette DOCKER-USER. Sie wird vor den Docker-eigenen Regeln ausgewertet und von Docker nicht überschrieben.
# Nur aus dem VPN-Netz auf den veröffentlichten Port 5432
sudo iptables -I DOCKER-USER -i eth0 -p tcp --dport 5432 -s 10.99.0.0/24 -j ACCEPT
sudo iptables -I DOCKER-USER -i eth0 -p tcp --dport 5432 -j DROP
Mit nftables entsprechend:
table ip filter {
chain DOCKER-USER {
iifname "eth0" tcp dport 5432 ip saddr 10.99.0.0/24 accept
iifname "eth0" tcp dport 5432 drop
}
}
Von Hand eingefügte DOCKER-USER-Regeln sind nach einem Neustart weg. Sie gehören in ein Regelwerk, das beim Booten geladen wird (iptables-persistent oder die nftables-Konfiguration), sonst ist der Schutz genau so lange aktiv, bis jemand die Maschine neu startet, und danach fällt es niemandem auf.
IPv6 nicht vergessen#
Docker aktiviert IPv6 nicht standardmäßig. Wo es aktiviert ist, gilt dasselbe Problem noch einmal, mit eigenen Tabellen:
// /etc/docker/daemon.json
{
"ipv6": true,
"fixed-cidr-v6": "2001:db8:1::/64",
"ip6tables": true
}
Ohne "ip6tables": true gibt es für IPv6 überhaupt keine Docker-Regeln, veröffentlichte Ports sind dann ungefiltert erreichbar. Nach jeder Änderung von außen gegenprüfen, nicht nur die Konfiguration lesen.
Der Docker-Socket#
Ein verwandtes Thema, das oft im selben Atemzug auftaucht: Jeder Container mit Zugriff auf /var/run/docker.sock kann einen privilegierten Container starten und damit den Host übernehmen. :ro ändert daran nichts, der Socket ist eine API, keine Datei mit Rechten.
# Welche Container haben den Socket eingehängt?
docker ps -q | xargs docker inspect --format \
'{{.Name}}: {{range .Mounts}}{{.Source}} {{end}}' | grep docker.sock
Wo es wirklich nötig ist (Monitoring, Update-Melder), gehört ein Socket-Proxy davor, der nur Leseoperationen durchlässt.
Zusammengefasst#
| Symptom | Ursache | Lösung |
|---|---|---|
| Port offen trotz ufw | DNAT vor filter | nicht veröffentlichen oder 127.0.0.1 binden |
| Regel wirkt nicht | falsche Kette | DOCKER-USER statt INPUT |
| Nach Neustart wieder offen | Regel nicht persistent | ins Boot-Regelwerk |
| Nur IPv6 erreichbar | ip6tables aus | in daemon.json aktivieren |
Die Grundlagen der eingehenden Filterung stehen in Eingehend dichtmachen; wie ein sauberer Compose-Aufbau insgesamt aussieht, steht auf der Schwesterseite unter Docker im echten Betrieb.
Geschrieben aus dem laufenden Betrieb: MRMedia betreibt eigene Hosts, Kundendienste und deren Härtung in der EU. Eine veraltete Anleitung ist hier ein Sicherheitsproblem, kein Schönheitsfehler. Korrekturen bitte über Discord.