TL;DR : ufw enable on a Pi does what it says — until Docker is installed. Docker inserts its own chains ahead of UFW's in FORWARD , and DNATs published ports before UFW is ever consulted. Same port, same firewall, same deny (incoming) default: a host process is blocked and a container is reachable from the entire LAN. ufw status reports the port as not allowed in both cases. Bind to 127.0.0.1 explicitly, or put a rule in DOCKER-USER — both verified below. The demonstration One Pi 4B, Raspberry Pi OS Lite (Trixie), ufw active with a single rule for SSH: $ sudo ufw status verbose 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 ) Port 8080 is not in that list, so it is denied. Start a plain listener on it: sudo docker run -d --name ufwtest -p 8080:80 nginx:alpine sudo iptables -S FORWARD -P FORWARD DROP -A FORWARD -j DOCKER-USER -A FORWARD -j DOCKER-ISOLATION-STAGE-1 -A FORWARD -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT -A FORWARD -o docker0 -j DOCKER -A FORWARD -i docker0 ! -o docker0 -j ACCEPT -A FORWARD -i docker0 -o docker0 -j ACCEPT -A FORWARD -j ufw-before-logging-forward ... DOCKER-USER and DOCKER come first. ufw-before-logging-forward — the entry point to everything UFW manages — comes after them. Traffic that Docker accepts never reaches a UFW rule. The actual redirection happens earlier still, in nat : ls /usr/sbin/iptables /sbin/iptables ls : cannot access '/usr/sbin/iptables' : No such file or directory ls : cannot access '/sbin/iptables' : No such file or directory {Status}' iptables unknown ok not-installed update-alternatives --display iptables iptables - auto mode link best version is /usr/sbin/iptables-nft link currently points to /usr/sbin/iptables-nft sudo docker run -d --name fixA -p 127.0.0.1:8080:80 nginx:alpine sudo iptables -S DOCKER-USER -N DOCKER-USER -A DOCKER-USER ! -s 127.0.0.1/32 -p tcp -m tcp --dport 80 -m conntrack --ctstate NEW -j DROP -A DOCKER-USER -j RETURN 192.168.128.154:8080 → timed out Note the port in that rule is the container's port (80), not the published one (8080) — DOCKER-USER is in FORWARD , so it sees the packet after DNAT has already rewritten the destination. Writing --dport 8080 there matches nothing and silently does not protect you, which is its own small trap. Rules added this way are not persistent across reboots on their own; iptables-persistent , a systemd unit, or a tool like ufw-docker handles that. Whichever route you take, verify from another machine rather than from the Pi — curl localhost will succeed in every configuration above and tells you nothing. The generalisable habit This is the same shape as the swap file that isn't swap and the memory limit that isn't enforced : a tool reports on its own model of the world, and something outside that model is what actually decides. ufw status is not lying. It is answering "what rules does UFW have?" precisely. The question you had was "what can reach this machine?", and no single tool on the box answers that once two things are both writing firewall rules. So the habit worth building is narrow and cheap: test exposure from a different machine. One curl from a laptop settles in three seconds what an hour of reading rule listings will not, because it exercises the whole stack instead of one layer's opinion of it. And when you install something that manages its own firewall rules — Docker, Tailscale, libvirt, k3s — assume it did, and go look at the chain order once. The ordering is the entire behaviour, and it is visible in one command. Toolkit This post's fix is available as a tested, ready-to-run script in the toolkit. See the toolkit →


