A firewall rule can do nothing on one protocol and everything on the other
I came to Linux firewalls from Windows, where the host firewall sees everything. If a port is listening on a Windows server, Windows Firewall has an opinion about it, and a block rule blocks it. I carried that assumption onto my home server, and it turned out to be wrong in a way that only shows up once you test from another machine.
The assumption
The server runs UFW with a default deny for incoming traffic, and most of my services run in Docker containers. So the mental model was simple: UFW denies everything, I allow what I want, and a container port with no allow rule is closed.
It is not, and I only found that out by testing from another machine on the network instead of reading the rule list.
Why IPv4 skips the firewall
When a compose file publishes a port, Docker does it with a NAT rule. Traffic arriving for that port gets rewritten (DNAT) and forwarded to the container. That traffic is not destined for the host itself, it is being routed through it, so it travels the FORWARD path in iptables.
UFW's rules live in the INPUT chain, which only sees traffic addressed to the host. Published container traffic never goes there. The firewall is simply never consulted.
The practical result on IPv4:
- Deleting the allow rule for a published port does not hide it
- Adding a deny rule for a published port does not block it
- A published port with no rule at all is reachable anyway
If you genuinely need to close one, you bind it to loopback in the compose file or put a rule in the DOCKER-USER chain, which is evaluated before Docker's own rules.
Host-networked containers are different. They bind directly on the host with no NAT in between, so their traffic does hit INPUT and UFW governs them normally. Same Docker, two completely different firewall behaviors, depending on one line in the compose file.
Why IPv6 does not
Here is the part I did not expect. On this host the Docker bridge networks are IPv4 only. Docker creates the ip6tables NAT chain for Docker but never puts a rule in it. You can see that for yourself:
ip6tables -t nat -S DOCKER
On my box that prints one line, the chain declaration, and nothing else.
With no IPv6 NAT, something still has to answer connections to the published port over IPv6.
That job falls to docker-proxy, a userland process that listens on the host like any other
program. Its traffic is ordinary traffic addressed to the host, so it does go through INPUT, and
UFW governs it.
So the same published port behaves like this:
- IPv4: bypasses UFW, rules are decorative
- IPv6: goes through docker-proxy, UFW applies, rules are a real control
One rule, two protocols, opposite effects.
What that means in practice
A rule that looks like it protects a port may do nothing on one protocol and everything on the other. If you only test over IPv4, you will conclude the rule is pointless and delete it. If you only read the rule list, you will conclude the port is protected. Both conclusions are wrong for half the traffic.
For me this decided what to keep. During a firewall cleanup I scoped every service to the local network instead of leaving broad allow rules. For the published ports, the new rules do nothing over IPv4. I kept them anyway, because over IPv6 they are the only thing at the host layer, and before the cleanup the broad versions of them left those services open on a globally routable address at that layer. My notes say so in as many words: the rule is cosmetic on one side and load-bearing on the other.
The lesson for a Windows admin
Windows Firewall sits on the host's network stack and filters at the point where traffic is delivered to a process. It does not matter how the packet got there.
On Linux the firewall is a set of chains, and which chain a packet traverses depends on its destination and how it was handled on the way in. Traffic for the host goes through INPUT. Traffic being routed through the host goes through FORWARD. Docker deliberately puts published ports on the second path. UFW is a front end for the first.
So "is there a firewall rule for this port" is the wrong question. The right ones are:
- Which path does this traffic take, per protocol?
- Did I test it from another machine, over each protocol, instead of reading the rule list?
Two more habits came out of the same exercise. Re-read ufw status after every change, because
the command output lied to me once: a delete reported failure while having succeeded. And treat
the rule list as a claim to be checked, not evidence.
One caveat I will state plainly: the IPv6 behavior here follows from my networks being IPv4 only. That is what my notes record, and I have not tested how it changes with a different Docker network or daemon configuration. Check your own setup before trusting mine.