Why Docker's published ports bypass your firewall
Why does a port published by Docker bypass my host firewall?
The server from part 1 is also the router of the house. One interface sits on the public internet, the host firewall is nftables, and its input chain drops everything that is not on a short list. About a hundred containers run next to it, and some of them publish ports. That combination taught me three things about Docker’s firewall rules, the last one while writing this post.
Where a published port really goes
A published port is not a socket on the host. Docker implements -p 2222:22 with a NAT rule: a packet that arrives for port 2222 has its destination rewritten to the container’s address and port 22, before the kernel decides where it goes. After that rewrite the packet is not for the host anymore, so it is forwarded, and the input chain, where host firewalls put their rules, never sees it:
WAN, tcp to :2222 nat PREROUTING Docker's DNAT: destination becomes 172.19.0.13:22 filter FORWARD DOCKER-USER, then Docker's own chains, then the container filter INPUT never reached: this is where the host firewall waitsDocker’s documentation says it plainly about ufw: published ports bypass its rules entirely. The same goes for a hand-written nftables input chain. A stray ports: ["8080:8080"] in any compose file is open to the internet, and nothing in the firewall rules mentions it.
Docker owns the FORWARD chain
The first lesson came after a power outage: the server came back up, and we couldn’t browse the internet from the LAN. It took some investigation to find out why.
When Docker enables IP forwarding, it also sets the policy of the iptables FORWARD chain to DROP, and then only accepts what goes to and from its own bridges. On a normal host that is harmless. On a router, forwarding LAN traffic to the internet is the whole job. My nftables forward chain accepted it, but Docker’s FORWARD chain hangs from the same point in the kernel, and one chain dropping a packet is enough. The rules I had added by hand to fix it once were gone after the reboot.
Docker leaves one chain alone, DOCKER-USER, which it runs before its own rules. A small script fills it with the router’s rules, and a systemd unit runs it after Docker and again every time Docker restarts:
[Unit]Description=Seed DOCKER-USER with LAN/VPN → WAN forwarding rulesAfter=docker.serviceRequires=docker.servicePartOf=docker.service
[Service]Type=oneshotRemainAfterExit=trueExecStart=/opt/router/docker-forward.shPartOf= runs the script again whenever Docker restarts, and here that happens on purpose. The nftables configuration starts with flush ruleset, which also wipes the tables Docker built, so after a firewall reload the router role restarts Docker to rebuild them, and DOCKER-USER comes back empty. The script checks each rule with iptables -C before inserting it, so running it twice changes nothing.
Published means public
The second lesson came from a periodic port scan of my own public address: a port that a container had published was reachable from the internet, with nothing in the firewall that said so.
The fix lives in the same chain. Everything new that comes from the WAN is dropped before it reaches a bridge, except an explicit list of ports that are public on purpose: Traefik on 80 and 443, the torrent client, and Forgejo’s SSH. Replies to connections that started inside still pass. Since -I inserts at the top, the block is written back to front: the DROP goes in first and ends up last.
ensure_rule -i "$WAN_IF" -j DROP{% for p in docker_wan_published_tcp | default([80, 443, forgejo_ssh_port | default(2222), 51413]) %}ensure_rule -i "$WAN_IF" -p tcp -m conntrack --ctorigdstport {{ p }} -j ACCEPT{% endfor %}ensure_rule -i "$WAN_IF" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPTThe ports that are only for the LAN get a second layer in their compose files: they are published on the server’s LAN address, like "{{ server_ip }}:53:53/udp" for the DNS server, so Docker does not rewrite anything that arrives on the public interface in the first place.
The rule that never matched
That --ctorigdstport is new. Until today the rule said --dport, and preparing this post I looked at the counters of the chain:
iptables -L DOCKER-USER -n -v --line-numbersThese are the rules for the public interface:
num pkts bytes target prot opt in out source destination3 2348M 4290G ACCEPT all -- eno1.835 * 0.0.0.0/0 0.0.0.0/0 ctstate RELATED,ESTABLISHED4 22M 1311M ACCEPT tcp -- eno1.835 * 0.0.0.0/0 0.0.0.0/0 tcp dpt:514135 0 0 ACCEPT tcp -- eno1.835 * 0.0.0.0/0 0.0.0.0/0 tcp dpt:22226 972K 46M ACCEPT tcp -- eno1.835 * 0.0.0.0/0 0.0.0.0/0 tcp dpt:4437 876K 40M ACCEPT tcp -- eno1.835 * 0.0.0.0/0 0.0.0.0/0 tcp dpt:808 16M 1542M ACCEPT udp -- eno1.835 * 0.0.0.0/0 0.0.0.0/0 udp dpt:514139 10427 610K DROP all -- eno1.835 * 0.0.0.0/0 0.0.0.0/0Zero packets on 2222, in almost a month. The other public ports had no problem because Traefik and the torrent client use the same number outside and inside: 443 is 443 on both sides of the NAT. Forgejo is published as 2222:22, and by the time a packet reaches DOCKER-USER the DNAT has already happened, so it carries port 22. The rule waited for a 2222 that never came, the packet fell through to the DROP, and git over SSH was closed from outside since the day the rule went in.
Docker’s documentation describes exactly this: to match what the client asked for, use the conntrack extension, which remembers the original destination. With -m conntrack --ctorigdstport, the list in the script means what it says, the ports on the host, whatever the container listens on. The docs warn that conntrack matching may cost some performance. Here it is one rule per public port.
How to check yours
Three commands tell you where you stand:
iptables -t nat -S DOCKERiptables -L DOCKER-USER -n -vnft list rulesetThe first lists every port Docker forwards and where it goes, which is your real list of open ports. The second shows your own rules with their counters: a rule that should match and stays at zero is lying to you. The third shows the rest of the firewall, but no rule in your input chain will ever see a published port. And from outside, a port scan of your own address is still the only test that sees what the internet sees.