
Was steht bei dir in ufw status, und was sagt dein Laptop dazu? Ich stelle die Frage, weil die beiden Antworten auf Docker-Hosts oft nicht zusammenpassen: UFW meldet für Port 8080 ein sauberes DENY, und der Laptop im Wohnzimmer bekommt vom Container auf genau diesem Port trotzdem eine Antwort. Das ist kein Tippfehler in deiner Regel. Es ist die Bauweise.
Docker schreibt das selbst in seine Doku, in einem Satz, der für eine Hersteller-Doku ungewöhnlich deutlich ist: Docker und ufw benutzen Firewall-Regeln auf eine Weise, die sie miteinander inkompatibel macht. Der Rest der Seite erklärt, warum. Dieser Beitrag übersetzt das und zeigt, was du dagegen tust.
Warum UFW deine Container nicht sieht
UFW ist eine Bedienoberfläche für die Linux-Firewall. Darunter arbeitet iptables, und dort laufen Pakete durch mehrere Listen, die Ketten heißen. UFW schreibt seine Regeln in die Kette INPUT: alles, was für den Rechner selbst bestimmt ist. Ein Paket für einen Container ist aber nicht für den Rechner bestimmt. Docker fängt es früher ab, in der NAT-Tabelle, schreibt die Zieladresse auf den Container um und schickt es durch die Kette FORWARD weiter. INPUT sieht es nie. Die Docker-Doku formuliert es so: Die Pakete werden umgeleitet, bevor sie die Ketten INPUT und OUTPUT erreichen, die ufw benutzt.
Der Kasten INPUT, in dem UFW wartet, kommt in dieser Kette gar nicht vor. Neu ist das nicht. Im Issue-Tracker von Docker liegt der Fall seit dem 18-03-2014 unter der Nummer 4737 mit dem Titel „docker and ufw serious problems“: 157 Kommentare, 2018 geschlossen, nicht behoben. Maintainer Brian Goff (cpuguy83) schrieb dort 2016, alles, was Docker tue, sei auf zwei eigene Ketten beschränkt, die Docker selbst anlege; UFW dagegen halte sich für die einzige Quelle der Wahrheit und lese vorhandene iptables-Regeln gar nicht. Beide Seiten haben recht, und genau deshalb ist es seit zwölf Jahren so.
Wen es trifft, hängt davon ab, wo der Docker-Host steht. Auf einem Mietserver mit öffentlicher Adresse steht jeder veröffentlichte Port im Internet, egal was UFW sagt; für diesen Fall wurde die Warnung geschrieben. Zu Hause hinter einer FritzBox nimmt dir der Router den größten Teil ab: Von außen kommt nur herein, was du im Router freigibst. Die Lücke ist dann kleiner, aber sie ist da. Jedes Gerät im Heimnetz, der Fernseher, das Handy des Besuchs, der Smart-Stecker mit der Firmware von 2021, erreicht jeden veröffentlichten Port, und UFW kann es nicht verhindern. Ich würde das nicht dramatisieren, aber ich würde es auch nicht so lassen.
Eine Sache hat Docker inzwischen selbst repariert, und die gehört hierher: Bis Version 28 konnten Rechner im selben Netz laut Doku auch Ports erreichen, die nur an localhost veröffentlicht waren, und sogar Container-Adressen, die gar nicht veröffentlicht waren, wenn sie die Route kannten. Docker Engine 28 vom 28-02-2025 sperrt beides standardmäßig. Wer noch mit einer älteren Version fährt, hat ein Problem mehr, als dieser Beitrag behandelt; die Version prüfst du deshalb als Erstes.
Was du brauchst
- Einen Docker-Host mit Debian oder Ubuntu und UFW als Firewall. Docker muss mindestens Version 28 haben, Prüfbefehl gleich unten.
- Einen zweiten Rechner im selben Netz, Laptop oder Handy, für die Gegenprobe. Ohne den Blick von außen weißt du nie, ob die Tür wirklich zu ist.
- sudo-Rechte und einen SSH-Zugang zum Host. Falls UFW bei dir noch aus ist: erst SSH erlauben, dann einschalten, sonst sperrst du dich aus. Wie das geht, steht in SSH absichern auf Debian und Ubuntu.
- Docker Compose, weil Beispieldienst und Fix beide in einer Compose-Datei stehen.
Die beiden Prüfbefehle laufen auf dem Docker-Host, per SSH oder direkt am Gerät:
docker --version
sudo ufw status verboseDocker version 29.8.1, build 4a63305
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), deny (routed)
New profiles: skip
To Action From
-- ------ ----
22/tcp ALLOW IN Anywhere
22/tcp (v6) ALLOW IN Anywhere (v6)Die erste Zeile muss eine 28 oder höher zeigen. In der vierten Zeile steckt schon ein Hinweis auf das ganze Problem: Das dritte Wort betrifft weitergeleitete Pakete, also genau die Sorte, die zu Containern geht. Dort steht deny, UFW kümmert sich um diesen Weg von Haus aus nicht. Diese Werte benutze ich im ganzen Beitrag; ersetze sie durch deine:
- Docker-Host (Debian oder Ubuntu mit UFW):
192.168.178.20 - Zweiter Rechner für die Gegenprobe:
192.168.178.30 - Heimnetz:
192.168.178.0/24 - Beispieldienst:
whoami, im Container Port80, auf dem Host Port8080 - Ordner der Compose-Datei:
~/whoami
Schritt 1: Die Lücke mit eigenen Augen sehen
Bevor du etwas reparierst, sieh dir den Schaden an. Der Beispieldienst whoami vom Traefik-Projekt ist ein winziger Webserver, der nur zurückgibt, wer ihn gefragt hat. Er hat keine Daten und kein Passwort und eignet sich deshalb als Versuchskaninchen. Zuerst der Ordner und die Compose-Datei, so wie sie fast jede Anleitung zeigt, mit der Portzeile "8080:80".
mkdir -p ~/whoami
cd ~/whoami
nano compose.yamlIn den leeren Editor kommt dieser Inhalt. Speichern mit Strg+O und Enter, schließen mit Strg+X.
services:
whoami:
image: traefik/whoami:v1.12.0
container_name: whoami
ports:
- "8080:80"
restart: unless-stoppedJetzt startest du den Container und sagst UFW gleichzeitig, dass Port 8080 tabu ist. Wenn UFW Container sehen könnte, wäre danach Schluss.
docker compose up -d
sudo ufw deny 8080/tcp
sudo ufw statusStatus: active
To Action From
-- ------ ----
22/tcp ALLOW Anywhere
8080/tcp DENY AnywhereDENY steht da. Jetzt die Gegenprobe, und zwar nicht auf dem Host, sondern auf dem zweiten Rechner, denn nur der zählt. Auf dem Laptop im Terminal (unter Windows in der PowerShell, dort heißt der Befehl genauso):
curl http://192.168.178.20:8080Hostname: 3f2a9c1d7e8b
IP: 127.0.0.1
IP: 172.18.0.2
RemoteAddr: 172.18.0.1:53412
GET / HTTP/1.1
Host: 192.168.178.20:8080
User-Agent: curl/8.5.0Der Container antwortet, obwohl UFW für diesen Port DENY zeigt. Das ist die Lücke. Der Ausdruck verrät nebenbei noch etwas: Als Absender steht dort 172.18.0.1, die Adresse der Docker-Bridge, nicht dein Laptop. Docker hat die Adresse unterwegs umgeschrieben, das ist der NAT-Schritt aus dem Kasten oben. Wer später im Container-Log nach dem echten Absender sucht, findet ihn hier nicht. Zweite Kontrolle, wieder auf dem Host:
sudo ss -tlnp | grep 8080LISTEN 0 4096 0.0.0.0:8080 0.0.0.0:* users:(("docker-proxy",pid=1842,fd=7))
LISTEN 0 4096 [::]:8080 [::]:* users:(("docker-proxy",pid=1848,fd=7))0.0.0.0 heißt: auf allen Adressen des Hosts, auch der im Heimnetz. Das ist Dockers Voreinstellung für jede Portzeile ohne Adresse davor, und die Doku nennt das Kind beim Namen: Das Veröffentlichen von Ports sei standardmäßig unsicher, der Port stehe damit nicht nur dem Docker-Host offen, sondern auch der Außenwelt. Den Befehl ss kennst du vielleicht aus Port belegt? So findest du den Schuldigen; hier zeigt er nicht, wer den Port hat, sondern für wen er offen ist.
Schritt 2: Ports an 127.0.0.1 binden
Der einfachste Fix ist eine Adresse vor dem Port. Aus "8080:80" wird "127.0.0.1:8080:80", und der Port hört nur noch auf der Loopback-Adresse, also auf dem Host selbst. Die Docker-Doku sagt dazu: Mit der localhost-Adresse im publish-Flag kann nur der Docker-Host auf den veröffentlichten Port zugreifen. Kein iptables, kein UFW, eine Zeile, die du sowieso schreibst. Die Schreibweise mit IP davor steht so in der Compose-Referenz.
nano ~/whoami/compose.yamlDie Datei sieht danach so aus; nur die Portzeile ist anders:
services:
whoami:
image: traefik/whoami:v1.12.0
container_name: whoami
ports:
- "127.0.0.1:8080:80"
restart: unless-stoppedcd ~/whoami
docker compose up -d
sudo ss -tlnp | grep 8080LISTEN 0 4096 127.0.0.1:8080 0.0.0.0:* users:(("docker-proxy",pid=2210,fd=7))Compose merkt, dass sich die Datei geändert hat, und baut den Container neu; du musst nichts stoppen. Statt 0.0.0.0 steht jetzt 127.0.0.1, und die IPv6-Zeile ist weg. Die Gegenprobe vom Laptop, derselbe Befehl wie vorhin:
curl http://192.168.178.20:8080curl: (7) Failed to connect to 192.168.178.20 port 8080 after 3 ms: Connection refusedDer Laptop bekommt „Connection refused“, und auf dem Host liefert curl http://127.0.0.1:8080 weiter den Hostname-Ausdruck. Das ist der Moment, in dem die Tür zum ersten Mal wirklich zu ist, und er hat eine Zeile gekostet.
Für welche Dienste passt das? Für alles, was nur der Host selbst braucht: die Datenbank neben der Anwendung, Redis, alles, was hinter einem Reverse Proxy steht. Und hier eine Einschränkung, die ich lieber selbst ausspreche, bevor sie in den Kommentaren landet: Läuft dein Reverse Proxy selbst als Container, etwa Nginx Proxy Manager, dann ist 127.0.0.1 aus seiner Sicht der Proxy-Container, nicht der Host. Er erreicht den whoami-Port danach nicht mehr. Der saubere Weg ist, Proxy und Dienst in dasselbe Docker-Netz zu stecken und die ports-Zeile beim Dienst ganz zu streichen: Der Proxy spricht den Container dann über seinen Namen an, und nach außen gibt es nur noch den Proxy. Das verdient einen eigenen Beitrag. Bis dahin gilt für alles, was das Heimnetz direkt erreichen soll, Schritt 3.
Für Container, die du ohne Compose per docker run startest, kennt die Doku noch den Schlüssel "ip": "127.0.0.1" in /etc/docker/daemon.json. Er ändert die Voreinstellung, gilt laut Doku aber nur für das Standard-Bridge-Netz; die Netze, die Compose für jedes Projekt anlegt, bekommen ihn nicht. Deshalb steht die Adresse hier in der Compose-Datei, wo du sie siehst.
Schritt 3: UFW die Docker-Kette beibringen
Manche Dienste sollen aus dem Heimnetz erreichbar sein, Jellyfin für den Fernseher, Pi-hole für alle, aber nicht aus dem Internet. Dafür hat Docker seit Jahren eine eigene Stelle vorgesehen: die Kette DOCKER-USER. Die Doku beschreibt sie als Platzhalter für benutzerdefinierte Regeln, die vor Dockers eigenen Regeln in DOCKER-FORWARD verarbeitet werden. Was dort steht, sieht jedes Paket auf dem Weg zu einem Container, und Docker räumt diese Kette bei einem Neustart nicht ab. Das Problem: UFW weiß nichts von ihr.
Genau diese Brücke baut das Projekt ufw-docker von Feng Chai, seit 2018 auf GitHub, rund 6.800 Sterne, letzte Änderung im Mai 2026. Es ist kein Programm, das laufen muss, sondern ein Block Regeln, den du in eine Datei von UFW hängst. UFW liest diese Datei bei jedem Start, und die Regeln landen in DOCKER-USER. Ich übernehme den Block wörtlich aus der README, und ich finde es gut, dass er dort seit Jahren offen liegt und jeder nachlesen kann, was er tut.
Die Datei heißt /etc/ufw/after.rules; sie gehört root, deshalb sudo. Mit den Pfeiltasten ganz nach unten, hinter die letzte Zeile COMMIT, und dort den Block anhängen.
sudo nano /etc/ufw/after.rules# BEGIN UFW AND DOCKER
*filter
:ufw-user-forward - [0:0]
:ufw-docker-logging-deny - [0:0]
:DOCKER-USER - [0:0]
-A DOCKER-USER -j ufw-user-forward
-A DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN
-A DOCKER-USER -m conntrack --ctstate INVALID -j DROP
-A DOCKER-USER -i docker0 -o docker0 -j ACCEPT
-A DOCKER-USER -j RETURN -s 10.0.0.0/8
-A DOCKER-USER -j RETURN -s 172.16.0.0/12
-A DOCKER-USER -j RETURN -s 192.168.0.0/16
-A DOCKER-USER -j ufw-docker-logging-deny -m conntrack --ctstate NEW -d 10.0.0.0/8
-A DOCKER-USER -j ufw-docker-logging-deny -m conntrack --ctstate NEW -d 172.16.0.0/12
-A DOCKER-USER -j ufw-docker-logging-deny -m conntrack --ctstate NEW -d 192.168.0.0/16
-A DOCKER-USER -j RETURN
-A ufw-docker-logging-deny -m limit --limit 3/min --limit-burst 10 -j LOG --log-prefix "[UFW DOCKER BLOCK] "
-A ufw-docker-logging-deny -j DROP
COMMIT
# END UFW AND DOCKERSpeichern, schließen, dann UFW neu laden. Die README warnt an dieser Stelle, dass die Regeln nach dem Neuladen aus unbekannten Gründen manchmal nicht greifen; dann hilft nur ein Neustart des Servers. Ich würde den Neustart gleich einplanen, dann weißt du auch, dass der Block einen Reboot überlebt.
sudo ufw reload
sudo iptables -L DOCKER-USER -n --line-numbersFirewall reloaded
Chain DOCKER-USER (1 references)
num target prot opt source destination
1 ufw-user-forward all -- 0.0.0.0/0 0.0.0.0/0
2 RETURN all -- 0.0.0.0/0 0.0.0.0/0 ctstate RELATED,ESTABLISHED
3 DROP all -- 0.0.0.0/0 0.0.0.0/0 ctstate INVALID
4 ACCEPT all -- 0.0.0.0/0 0.0.0.0/0
5 RETURN all -- 10.0.0.0/8 0.0.0.0/0
6 RETURN all -- 172.16.0.0/12 0.0.0.0/0
7 RETURN all -- 192.168.0.0/16 0.0.0.0/0
8 ufw-docker-logging-deny all -- 0.0.0.0/0 10.0.0.0/8 ctstate NEW
9 ufw-docker-logging-deny all -- 0.0.0.0/0 172.16.0.0/12 ctstate NEW
10 ufw-docker-logging-deny all -- 0.0.0.0/0 192.168.0.0/16 ctstate NEW
11 RETURN all -- 0.0.0.0/0 0.0.0.0/0Stehen die Zeilen da, ist der Block aktiv. Steht dort nur die Kopfzeile ohne Regeln, ist das der Fall aus der README: Neustart, dann noch einmal nachsehen. Für die Probe aus dem Heimnetz stellst du whoami vorübergehend wieder auf "8080:80" (dieselbe Datei, dieselbe Zeile, dann docker compose up -d): Der Laptop bekommt jetzt wieder den Hostname-Ausdruck, denn 192.168.178.30 liegt in einem privaten Netz, und das lässt der Block durch. Was von außerhalb käme, würde mit dem Präfix „UFW DOCKER BLOCK“ im Kernel-Log landen, sichtbar mit sudo dmesg | grep "UFW DOCKER"; zu Hause hinter dem Router wirst du diese Zeile selten sehen, auf einem Mietserver dagegen schnell.
Soll ein Container-Port doch für jeden offen sein, etwa Port 443 auf einem Mietserver, gibt es dafür ein eigenes Kommando, und es ist nicht das gewohnte. ufw allow schreibt in INPUT und ändert für Container nichts. Für weitergeleitete Pakete hat UFW das Schlüsselwort route, und der Block ist so gebaut, dass diese Regeln greifen:
sudo ufw route allow proto tcp from any to any port 443Zwei Grenzen, damit du sie kennst. Erstens gilt der Block nur für IPv4; hat dein Docker IPv6 eingeschaltet, was standardmäßig nicht der Fall ist, liefert die README einen zweiten Block für after6.rules. Zweitens setzt das alles das iptables-Backend von Docker voraus, und das ist die Voreinstellung. Docker 29 bringt ein experimentelles nftables-Backend mit, und dort gibt es laut Doku keine Entsprechung zu DOCKER-USER. Solange in deiner daemon.json kein firewall-backend steht, betrifft dich das nicht.
Was am Ende eingestellt ist
1. Die Adresse vor dem Port ist die Regel, nicht die Ausnahme. Jeder Dienst, den nur der Host oder ein Proxy auf dem Host braucht, bekommt 127.0.0.1: vor den Port. Ich würde beim nächsten Blick in jede bestehende Compose-Datei genau diese Zeile suchen. Es ist die billigste Sicherheitsmaßnahme im ganzen Homelab.
2. Der Block in after.rules bleibt. UFW lädt ihn bei jedem Start, Docker lässt DOCKER-USER in Ruhe. Nach einem größeren Docker-Update lohnt trotzdem ein Blick mit sudo iptables -L DOCKER-USER -n; Docker hat mit Version 29 mehrere seiner eigenen Ketten umgebaut, DOCKER-USER war nicht dabei.
3. Die DENY-Regel aus Schritt 1 fliegt raus. Sie hat nie etwas getan und verleitet nur beim nächsten ufw status zu falscher Sicherheit:
sudo ufw delete deny 8080/tcp4. Freigaben nach außen nur mit ufw route allow, und zu Hause am besten gar nicht. Der Router gibt ohnehin nichts weiter, was du dort nicht einträgst, und für den Zugriff von unterwegs ist ein VPN der bessere Weg als eine Portfreigabe. Das ist der nächste Beitrag auf meiner Liste.
5. Was du nicht tust: "iptables": false. Der Schalter taucht in vielen Forenthreads als Lösung auf. Die Docker-Doku sagt dazu, er werde das Container-Netzwerk wahrscheinlich kaputt machen; Container hätten dann keinen Internetzugang mehr. Er löst das Problem, indem er den Patienten abschaltet.
Wo es erfahrungsgemäß klemmt
Nach ufw reload steht DOCKER-USER leer. Der Klassiker, die README nennt ihn selbst und hat keine Erklärung dafür. Neustart des Servers, danach sitzt der Block.
Nginx Proxy Manager findet den Dienst nicht mehr. Passiert nach Schritt 2, wenn der Proxy als Container läuft und im Proxy Host die Adresse des Docker-Hosts mit Port 8080 steht. Der Port hört jetzt nur noch auf 127.0.0.1, und das ist aus dem Proxy-Container heraus eine andere Maschine. Kurzfristig hilft, für diesen Dienst zu Schritt 3 zu greifen und die Adresse vor dem Port wieder wegzulassen; langfristig gehören beide ins selbe Docker-Netz, siehe oben.
ufw allow 8080 hat nichts geändert. Richtig, weil es die falsche Kette ist. Regeln für Container heißen ufw route allow, und sie wirken erst mit dem Block aus Schritt 3.
Docker älter als 28. Dann erreichen Geräte im selben Netz laut Doku auch Ports, die an localhost gebunden sind, und Schritt 2 hilft nur halb. Erst Docker aktualisieren, dann weitermachen.
Das Container-Log zeigt als Absender immer 172.18.0.1. Kein Fehler, das ist NAT. Wer die echte Absenderadresse braucht, etwa für Fail2ban, hat ein Thema für sich; ein Reverse Proxy, der die Adresse im Header weiterreicht, ist der übliche Ausweg.
Wie sieht es bei dir aus: Läuft UFW auf deinem Docker-Host überhaupt, und hat dich der Blick vom Laptop überrascht? Falls du eine andere Lösung fährst, etwa ein eigenes Docker-Netz für den Proxy ohne einen einzigen veröffentlichten Port, schreib deinen Aufbau in die Kommentare. Ich sammle die Varianten für den Folgebeitrag.
Wenn der Host jetzt dicht ist, bleibt noch der Zugang, über den du ihn verwaltest: SSH absichern auf Debian und Ubuntu ist die Anleitung, die eigentlich vor dieser hier kommt, und sie erklärt auch, warum UFW den Port 22 kennen muss, bevor du die Firewall einschaltest.
Quellen
- Docker Docs: Packet filtering and firewalls (Abschnitte „Docker and ufw“, „Prevent Docker from manipulating firewall rules“, „Firewall backend“)
- Docker Docs: Docker and iptables (DOCKER-USER-Kette, Beispiele)
- Docker Docs: Port publishing (127.0.0.1-Bindung, „insecure by default“, Verhalten vor 28.0.0, daemon.json „ip“)
- Docker Docs: Compose-Referenz, ports (Schreibweise IP:HOSTPORT:CONTAINERPORT)
- Docker Blog: Docker Engine 28, Hardening container networking by default (28-02-2025)
- Docker Docs: Docker with nftables (experimentell seit 29.0.0, keine DOCKER-USER-Entsprechung)
- Docker Engine 29 Release Notes (29.8.1 vom 15-09-2026, umgebaute iptables-Ketten)
- chaifeng/ufw-docker: README mit dem after.rules-Block, ufw route allow, Neustart-Hinweis, after6.rules
- moby/moby Issue #4737: „docker and ufw serious problems“ (2014, 157 Kommentare, Kommentar von cpuguy83 2016)
- traefik/whoami Releases (v1.12.0)
- Ubuntu Manpage ufw (route-Regeln, Version 0.36.2)