Publishing ports and the packet path

From a client to a container and back: NAT, docker-proxy, IPv6, firewalls and the nftables backend.

Intermediate30 min · lesson 13 of 24
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (2 files, 1 KB): ports.tar.gz. The lab VM shares no folders with your computer, so fetch them inside the VM: cd ~/lab && curl -fsSLO https://secopslog.com/lab-files/docker-hard/ports.tar.gz && tar -xzf ports.tar.gz, which creates ~/lab/ports/. SHA-256: 5d18c8d6d8754ccb41f7747fb6c14601d6047fc3c64afc1f75afd97cca8f30ed
Watch out
The first half of this lesson runs on the main lab VM (secopslog-docker) and only reads firewall rules. The second half changes the host firewall, daemon.json and the firewall backend: run it only in the SecOpsLog disposable lab VM (secopslog-docker-sec), never on a host anyone depends on. If it does not exist yet, create it on your workstation from the lab kit folder with ./setup/create-lab.sh --profile sec, open a shell with multipass shell secopslog-docker-sec (limactl shell secopslog-docker-sec with Lima), and get back to a clean state at any point with ./setup/create-lab.sh --profile sec --recreate. Each terminal below names the VM it runs on. On the sec VM the ubuntu account is not in the docker group, so docker runs through sudo.

One -p flag, two listeners:

ubuntu@secopslog-docker:~ · Docker 29.8.2
$ docker network create --subnet 10.89.10.0/24 lab-pub docker run -d --name lab-web --network lab-pub -p 8088:80 nginx:1.30-alpine
f9f0a09835d8d7f9d0fa68a14b312c839e3778790348eb81e79166bac78cc3af a95476f8d050a195146b3fb1dbaa553f75ef0a4f354a49278b9f0558fd3c1aea
$ docker port lab-web
80/tcp -> 0.0.0.0:8088 80/tcp -> [::]:8088
$ sudo ss -tlnp 'sport = :8088'
State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess LISTEN 0 4096 0.0.0.0:8088 0.0.0.0:* users:(("docker-proxy",pid=600304,fd=8)) LISTEN 0 4096 [::]:8088 [::]:* users:(("docker-proxy",pid=600310,fd=8))

docker port reports the mapping twice, once for 0.0.0.0 and once for [::]: a published port without a host address is published on every IPv4 and every IPv6 address of the host, and ss shows a docker-proxy process listening on each. "Container networking basics" (Docker for beginners) showed how to bind 127.0.0.1 instead. This lesson follows a packet from a client to the container and back, shows which part of Docker handles which kind of client, and covers what that means for host firewalls, IPv6 and the new nftables backend. Reading the rules needs root, so the commands use sudo; they are filtered to this lab's network because every other network on a host adds rules of its own.

The packet path

A client on the LAN reaches lab-web on host port 8088 (iptables backend)
1Client to host-ip:8088
arrives on the host NIC, addressed to the host
2nat PREROUTING, then nat DOCKER
DNAT: destination rewritten to 10.89.10.2:80; conntrack records the translation
3Routing decision
10.89.10.2 is behind br-..., so the packet is forwarded, not delivered to a host socket: INPUT (where ufw's rules sit) is never consulted
4filter FORWARD: DOCKER-USER
your rules, empty by default; they see the rewritten address and port
5DOCKER-FORWARD: DOCKER-CT, DOCKER-BRIDGE, DOCKER
replies of known connections pass; new ones need the published-port ACCEPT, everything else into the bridge is dropped
6br-... to veth to eth0
nginx sees the client's real address
7Reply
conntrack reverses the DNAT, so the client sees an answer from host-ip:8088
Outbound traffic takes the mirror path: POSTROUTING MASQUERADE rewrites the container's source address to the host's. Connections from the host itself to 127.0.0.1 or ::1 do not use this path; docker-proxy handles them.

Here is the nat table behind that diagram:

ubuntu@secopslog-docker:~ · Docker 29.8.2
$ sudo iptables -t nat -S | grep -e '-j DOCKER' -e '10\.89\.10\.'
-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER -A OUTPUT ! -d 127.0.0.0/8 -m addrtype --dst-type LOCAL -j DOCKER -A POSTROUTING -s 10.89.10.0/24 ! -o br-f9f0a09835d8 -j MASQUERADE -A DOCKER ! -i br-f9f0a09835d8 -p tcp -m tcp --dport 8088 -j DNAT --to-destination 10.89.10.2:80

PREROUTING sends every packet addressed to one of the host's own addresses into Docker's DOCKER chain, and OUTPUT does the same for connections the host itself opens, except to 127.0.0.0/8. In DOCKER, the DNAT rule rewrites TCP port 8088 to 10.89.10.2:80 for packets that did not come from the container's own bridge. POSTROUTING masquerades anything leaving 10.89.10.0/24 through another interface: that is how containers reach the internet with the host's address. Now the filter table:

ubuntu@secopslog-docker:~ · Docker 29.8.2
$ BR=br-$(docker network inspect -f '{{.Id}}' lab-pub | cut -c1-12) sudo iptables -S | grep -e '^-P FORWARD' -e '^-A FORWARD' -e '^-N DOCKER-USER' -e '^-A DOCKER-USER' -e "$BR"
-P FORWARD DROP -N DOCKER-USER -A FORWARD -j DOCKER-USER -A FORWARD -j DOCKER-FORWARD -A DOCKER -d 10.89.10.2/32 ! -i br-f9f0a09835d8 -o br-f9f0a09835d8 -p tcp -m tcp --dport 80 -j ACCEPT -A DOCKER ! -i br-f9f0a09835d8 -o br-f9f0a09835d8 -j DROP -A DOCKER-BRIDGE -o br-f9f0a09835d8 -j DOCKER -A DOCKER-CT -o br-f9f0a09835d8 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT -A DOCKER-FORWARD -i br-f9f0a09835d8 -j ACCEPT

FORWARD jumps first to DOCKER-USER, the chain Docker creates for your rules and never fills (since Docker 28.2.2 it has no built-in RETURN rule, so you can append as well as insert), then to DOCKER-FORWARD. For this bridge, DOCKER-CT lets replies of established connections back in, DOCKER-FORWARD lets anything the containers send out, and DOCKER accepts new connections from outside to exactly 10.89.10.2 port 80 and drops every other new connection into the bridge. The FORWARD policy is DROP on this host, so a forwarded packet nothing accepts is dropped. Docker sets that policy when it turns IP forwarding on itself, so that enabling forwarding does not turn the host into a router for its other interfaces. Two more tables complete the picture:

ubuntu@secopslog-docker:~ · Docker 29.8.2
$ sudo iptables -t raw -S PREROUTING | grep '10\.89\.10\.'
-A PREROUTING -d 10.89.10.2/32 ! -i br-f9f0a09835d8 -j DROP
$ sudo ip6tables -t nat -S DOCKER
-N DOCKER

The raw-table rule drops packets addressed to the container's own IP unless they come from its bridge. It runs before NAT, so it only catches packets sent to 10.89.10.2 directly, which is what a neighbour that routes to the container subnet would do. The IPv6 DOCKER chain is empty because lab-pub is IPv4-only. The [::]:8088 listener still works, as the next section shows, but no IPv6 NAT rule is involved.

Three ways in

ubuntu@secopslog-docker:~ · Docker 29.8.2
$ hostname -I | awk '{print $1}'
192.168.2.4
$ IP=$(hostname -I | awk '{print $1}') curl -s -o /dev/null -w 'host address %{http_code}\n' http://$IP:8088/ curl -s -o /dev/null -w 'loopback v4 %{http_code}\n' http://127.0.0.1:8088/ curl -s -o /dev/null -w 'loopback v6 %{http_code}\n' 'http://[::1]:8088/'
host address 200 loopback v4 200 loopback v6 200
$ docker logs lab-web 2>/dev/null | awk '{print $1, $7, $9}' | tail -n 3
192.168.2.4 / 200 10.89.10.1 / 200 10.89.10.1 / 200

All three requests worked, and nginx's log shows two different client addresses. The request to the host's own address, 192.168.2.4 here (yours will differ), went through the OUTPUT DNAT rule and kept its source address. The requests to 127.0.0.1 and [::1] came from 10.89.10.1, the bridge address: they were accepted by docker-proxy, which opened a new connection to the container. Loopback is excluded from the DNAT rules, and there is no IPv6 rule at all on an IPv4-only network, so for those clients the userland proxy is the path. conntrack shows the difference:

ubuntu@secopslog-docker:~ · Docker 29.8.2
$ sudo conntrack -L -p tcp --orig-port-dst 8088 2>/dev/null | sed 's/ \[ASSURED\]//; s/ mark=0 use=1//'
tcp 6 119 TIME_WAIT src=127.0.0.1 dst=127.0.0.1 sport=54142 dport=8088 src=127.0.0.1 dst=127.0.0.1 sport=8088 dport=54142 tcp 6 119 TIME_WAIT src=192.168.2.4 dst=192.168.2.4 sport=37692 dport=8088 src=10.89.10.2 dst=192.168.2.4 sport=80 dport=37692
$ sudo conntrack -L -p tcp -d 10.89.10.2 2>/dev/null | sed 's/ \[ASSURED\]//; s/ mark=0 use=1//'
tcp 6 119 TIME_WAIT src=10.89.10.1 dst=10.89.10.2 sport=36438 dport=80 src=10.89.10.2 dst=10.89.10.1 sport=80 dport=36438 tcp 6 119 TIME_WAIT src=10.89.10.1 dst=10.89.10.2 sport=36442 dport=80 src=10.89.10.2 dst=10.89.10.1 sport=80 dport=36442

Each conntrack entry shows the original direction first and the expected reply second. The connection to 192.168.2.4:8088 expects its reply from 10.89.10.2 port 80: that is the DNAT. The 127.0.0.1 connection stops at 127.0.0.1:8088, where docker-proxy accepted it, and the second command shows the proxy's own connections from 10.89.10.1 to the container. conntrack -L lists IPv4 unless you add -f ipv6, which is why the [::1] client does not appear in the first list. TIME_WAIT counters and ports differ on every run. Traffic in the other direction is masqueraded:

ubuntu@secopslog-docker:~ · Docker 29.8.2
$ docker exec lab-web wget -q -T 5 -O /dev/null http://example.com && echo fetched
fetched
$ sudo conntrack -L -p tcp -s 10.89.10.2 --orig-port-dst 80 2>/dev/null | sed 's/ \[ASSURED\]//; s/ mark=0 use=1//'
tcp 6 29 LAST_ACK src=10.89.10.2 dst=104.20.23.154 sport=50776 dport=80 src=104.20.23.154 dst=192.168.2.4 sport=80 dport=50776

The container connected from 10.89.10.2; the reply is expected at 192.168.2.4, the host's address. The remote server never learns the container's address.

Choosing the address

Still on the main VM, publish a second container on 127.0.0.1 only, and a third with -P:

ubuntu@secopslog-docker:~ · Docker 29.8.2
$ docker run -d --name lab-local --network lab-pub -p 127.0.0.1:8089:80 nginx:1.30-alpine docker port lab-local
9a4e2cec934d4455d04ad9bba039a36a15b48ae5f7d943c3585b75f6bded98db 80/tcp -> 127.0.0.1:8089
$ curl -s -o /dev/null -w '%{http_code}\n' --max-time 3 http://$(hostname -I | awk '{print $1}'):8089/ || echo "curl exit $?"
000 curl exit 7
$ sudo iptables -t raw -S PREROUTING | grep -e '--dport 8089'
-A PREROUTING -d 127.0.0.1/32 ! -i lo -p tcp -m tcp --dport 8089 -j DROP
$ docker run -d --name lab-rnd --network lab-pub -P nginx:1.30-alpine docker port lab-rnd
37c8cd9ebe531730bb5e2a5a599342db68b8cb7406a0604becaabc464467c53f 80/tcp -> 0.0.0.0:32771 80/tcp -> [::]:32771

A port published on 127.0.0.1 gets one mapping, refuses connections to the host's LAN address (curl exit 7), and a raw rule drops packets for 127.0.0.1:8089 that arrive on any interface other than lo. Before Docker 28.0, neighbours on the same layer-2 segment could reach ports published on 127.0.0.1 by sending packets for 127.0.0.1 to the host's MAC address; the release notes list that rule as a security fix. -P publishes every EXPOSEd port on a free port from the ephemeral range, which starts at 32768, again on both address families. To change the default for every container on a host, set "ip": "127.0.0.1" in daemon.json ("Configuring the daemon safely" covers the procedure); explicit -p addresses still win.

That is all for the main VM. Remove its containers and network:

ubuntu@secopslog-docker:~ · Docker 29.8.2
$ docker rm -f lab-web lab-local lab-rnd docker network rm lab-pub
lab-web lab-local lab-rnd lab-pub

What a neighbour can reach

Everything from here on runs on the sec VM, secopslog-docker-sec, as ubuntu with sudo for docker. Get the lesson files there too; they unpack to ~/lab/ports, where these steps run. On a single VM there is no second machine to test from, so the lesson builds one: a network namespace joined to the host by a veth pair, with an address on a separate subnet and the host as its default gateway. To the host it is a LAN neighbour on interface lab-lan0.

lan-neighbour.sh
#!/bin/sh
# Simulates a second machine on the Docker host's LAN: a network namespace "lab-lan" joined to the host
# by a veth pair. Host side: lab-lan0, 192.0.2.1/24. Neighbour: 192.0.2.10/24, default route via the host.
# Usage: sudo ./lan-neighbour.sh up|down Then: sudo ip netns exec lab-lan <command>
[ -f /etc/secopslog-lab ] || { echo "refusing: run this only in the SecOpsLog lab VM" >&2; exit 2; }
set -eu
case "${1:-}" in
up)
ip netns add lab-lan
ip link add lab-lan0 type veth peer name eth0 netns lab-lan
ip addr add 192.0.2.1/24 dev lab-lan0
ip link set lab-lan0 up
ip -n lab-lan link set lo up
ip -n lab-lan addr add 192.0.2.10/24 dev eth0
ip -n lab-lan link set eth0 up
ip -n lab-lan route add default via 192.0.2.1
;;
down)
ip netns del lab-lan 2>/dev/null || true
ip link del lab-lan0 2>/dev/null || true
;;
*)
echo "usage: $0 up|down" >&2
exit 2
;;
esac
ubuntu@secopslog-docker-sec:~/lab/ports · Docker 29.8.2
$ sudo ./lan-neighbour.sh up sudo ip netns exec lab-lan ip -br addr show eth0
eth0@if4 UP 192.0.2.10/24 fe80::44e6:54ff:fe37:7be4/64
$ sudo docker network create --subnet 10.89.10.0/24 lab-pub sudo docker run -d --name lab-web --network lab-pub -p 8080:80 nginx:1.30-alpine
2fb301a07cb2ef38043faf77e1f3ea56468228c1957207fb85a07a4561f311bf 627afae2841bed7c94f05dd8dd3d102633ae6f382664561952c9114e1f4ef068
$ sudo ip netns exec lab-lan curl -s -o /dev/null -w '%{http_code}\n' http://192.0.2.1:8080/ sudo docker logs lab-web 2>/dev/null | awk '{print $1, $7, $9}' | tail -n 1
200 192.0.2.10 / 200
$ sudo ip netns exec lab-lan curl -s -o /dev/null -w '%{http_code}\n' --max-time 3 http://10.89.10.2:80/
000

The neighbour reached the published port, and nginx logged its real address 192.0.2.10: inbound DNAT does not hide the client, so access logs and allow-lists in the application keep working. Going straight to the container's address timed out, although the neighbour routes 10.89.10.0/24 through the host. That is the raw-table rule. Before Docker 28.0, a neighbour that routed to the container subnet could reach published ports directly on the container IP; the 28.0 release notes list that change among the security fixes. lan-neighbour.sh refuses to run outside the lab VM, because it adds interfaces and routes to the host.

Gateway modes

NAT is the default way a bridge network meets the outside, not the only one. The bridge driver option com.docker.network.bridge.gateway_mode_ipv4 (and _ipv6) selects nat (the default), nat-unprotected (NAT, but any container port is reachable by direct routing), routed (no NAT: the container addresses are meant to be routed to) or isolated (no gateway address on the host at all). routed is the mode to use when your network routes a container subnet to the host:

ubuntu@secopslog-docker-sec:~/lab/ports · Docker 29.8.2
$ sudo docker network create --subnet 10.89.11.0/24 -o com.docker.network.bridge.gateway_mode_ipv4=routed lab-routed sudo docker run -d --name lab-r --network lab-routed -p 8081:80 python:3.14-slim sh -c 'python3 -m http.server 81 & exec python3 -m http.server 80'
3bc4ff6e261356247190e874443436a35dc02ad527504efc98851981d30a69c8 bc4a4bd7c7b7236ebcedcf6d9f92521a14ee2b77b307359f4fefed9596c5baa2
$ sudo docker port lab-r for url in http://10.89.11.2:80/ http://10.89.11.2:81/ http://192.0.2.1:8081/; do sudo ip netns exec lab-lan curl -s -o /dev/null -w "$url %{http_code}\n" --max-time 3 $url || echo "$url curl exit $?" done
80/tcp -> 0.0.0.0: 80/tcp -> [::]:8081 http://10.89.11.2:80/ 200 http://10.89.11.2:81/ 000 http://10.89.11.2:81/ curl exit 28 http://192.0.2.1:8081/ 000 http://192.0.2.1:8081/ curl exit 7
$ sudo iptables -t nat -S | grep '10\.89\.11\.' || echo 'no nat rules for 10.89.11.0/24' sudo iptables -S DOCKER | grep '10\.89\.11\.' sudo iptables -t raw -S PREROUTING | grep '10\.89\.11\.' || echo 'no raw drop for 10.89.11.2'
no nat rules for 10.89.11.0/24 -A DOCKER -d 10.89.11.2/32 ! -i br-3bc4ff6e2613 -o br-3bc4ff6e2613 -p tcp -m tcp --dport 80 -j ACCEPT no raw drop for 10.89.11.2

The container listens on ports 80 and 81 and publishes only 80. The neighbour reached 10.89.11.2:80 directly, port 81 timed out, and the host port 8081 refused the connection. There are no NAT rules for the subnet and no raw drop; the filter rule that accepts port 80 is still there, so "published" now means "allowed through", not "mapped to a host port". docker port shows the IPv4 mapping with an empty host port for the same reason. The [::]:8081 line is docker-proxy listening on the host's IPv6 addresses for this IPv4-only network; the routed mode applies to IPv4 only. The daemon option allow-direct-routing removes the direct-routing protection for every network; prefer per-network modes.

Host firewalls: ufw and firewalld

Docker's install documentation warns that if you use ufw or firewalld, ports you publish bypass your firewall rules. Watch it happen:

ubuntu@secopslog-docker-sec:~/lab/ports · Docker 29.8.2
$ sudo ufw allow 22/tcp sudo ufw default deny incoming sudo ufw --force enable sudo ufw status verbose
Rules updated Rules updated (v6) Default incoming policy changed to 'deny' (be sure to update your rules accordingly) Firewall is active and enabled on system startup 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)
$ sudo ip netns exec lab-lan curl -s -o /dev/null -w '%{http_code}\n' --max-time 3 http://192.0.2.1:8080/
200
$ sudo iptables -S FORWARD
-P FORWARD DROP -A FORWARD -j DOCKER-USER -A FORWARD -j DOCKER-FORWARD -A FORWARD -j ufw-before-logging-forward -A FORWARD -j ufw-before-forward -A FORWARD -j ufw-after-forward -A FORWARD -j ufw-after-logging-forward -A FORWARD -j ufw-reject-forward -A FORWARD -j ufw-track-forward
$ sudo ufw --force disable sudo iptables -S FORWARD | head -n 1
Firewall stopped and disabled on system startup -P FORWARD ACCEPT

ufw is active with incoming traffic denied and only SSH allowed, and the neighbour still gets a 200 from port 8080. The diagram explains it: ufw's "incoming" rules live in the INPUT chain, and a DNATed packet is forwarded, never input. In FORWARD, Docker's jumps come before ufw's chains, and DOCKER-FORWARD accepts the packet before ufw's routed policy is consulted. firewalld behaves similarly from the other side: Docker puts its bridges in a firewalld zone called docker with target ACCEPT. Disabling ufw at the end also reset the built-in chain policies: FORWARD now reads ACCEPT, where it was DROP above, which matters again in the nftables section. The fixes are to not publish what should not be public (bind 127.0.0.1, or put the service behind a reverse proxy), to filter in front of the host (cloud security groups), or to put your rules where forwarded packets go: DOCKER-USER.

ubuntu@secopslog-docker-sec:~/lab/ports · Docker 29.8.2
$ sudo iptables -I DOCKER-USER -i lab-lan0 -p tcp --dport 8080 -j DROP sudo ip netns exec lab-lan curl -s -o /dev/null -w '%{http_code}\n' --max-time 3 http://192.0.2.1:8080/ sudo iptables -L DOCKER-USER -v -n
200 Chain DOCKER-USER (1 references) pkts bytes target prot opt in out source destination 0 0 DROP tcp -- lab-lan0 * 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080

A rule for --dport 8080 matched nothing: its packet counter is 0. By the time a packet reaches DOCKER-USER, DNAT has already rewritten it to the container address and port 80. Match the original destination through conntrack instead:

ubuntu@secopslog-docker-sec:~/lab/ports · Docker 29.8.2
$ sudo iptables -D DOCKER-USER -i lab-lan0 -p tcp --dport 8080 -j DROP sudo iptables -I DOCKER-USER -i lab-lan0 -p tcp -m conntrack --ctorigdstport 8080 -j DROP sudo ip netns exec lab-lan curl -s -o /dev/null -w '%{http_code}\n' --max-time 3 http://192.0.2.1:8080/ || echo "neighbour: curl exit $?" curl -s -o /dev/null -w 'from the host: %{http_code}\n' http://127.0.0.1:8080/ sudo iptables -L DOCKER-USER -v -n
000 neighbour: curl exit 28 from the host: 200 Chain DOCKER-USER (1 references) pkts bytes target prot opt in out source destination 3 180 DROP tcp -- lab-lan0 * 0.0.0.0/0 0.0.0.0/0 ctorigdstport 8080
$ sudo iptables -D DOCKER-USER -i lab-lan0 -p tcp -m conntrack --ctorigdstport 8080 -j DROP sudo iptables -S DOCKER-USER
-N DOCKER-USER

With -m conntrack --ctorigdstport 8080 the rule matches (3 packets: the SYN and its retransmissions), the neighbour times out, and the host itself still gets a 200 because its loopback connection goes through docker-proxy and never crosses FORWARD. --ctorigdst matches the original destination address the same way. Scope rules with -i (the external interface) so they do not catch traffic between containers, and put an ACCEPT for RELATED,ESTABLISHED first when you build an allow-list. Rules added with iptables are lost on reboot; persist them with your configuration management or a unit that runs after Docker starts. Persist only your DOCKER-USER rules: do not iptables-save the whole ruleset into iptables-persistent, because restoring Docker's own chains at boot conflicts with the rules dockerd writes when it starts. The policy side of this, which ports to allow from where and how to control egress, belongs to "Container network hardening" (Advanced container security).

userland-proxy false, and IPv6 networks

docker-proxy costs a process per published port and address family and hides the client address for loopback and IPv6 clients. Setting "userland-proxy": false replaces it with kernel rules where it can. It is a daemon setting, so this uses the procedure from "Configuring the daemon safely": back up the current file (on this VM there is none, so the backup is an empty {}), merge the new key into the backup with jq, validate, restart, and at the end restore the backup. If your VM was created with --mtu, the backup holds the kit's MTU settings and the merge keeps them. This part restarts dockerd three times; systemd allows only a few restarts of docker.service in a short window, so if a restart fails with start request repeated too quickly, wait a minute or run sudo systemctl reset-failed docker docker.socket (the recorded run resets the counter between restarts).

ubuntu@secopslog-docker-sec:~/lab/ports · Docker 29.8.2
$ sudo cp -a /etc/docker/daemon.json /etc/docker/daemon.json.bak 2>/dev/null || echo '{}' | sudo tee /etc/docker/daemon.json.bak >/dev/null sudo cat /etc/docker/daemon.json.bak
{}
$ jq '. + {"userland-proxy": false}' /etc/docker/daemon.json.bak | sudo tee /etc/docker/daemon.json sudo dockerd --validate --config-file /etc/docker/daemon.json sudo docker rm -f lab-web lab-r >/dev/null sudo systemctl restart docker sudo docker info --format '{{json .FirewallBackend}}'
{ "userland-proxy": false } configuration OK {"Driver":"iptables"}
$ sudo docker network create --ipv6 --subnet 10.89.12.0/24 lab-v6 sudo docker run -d --name lab-web --network lab-pub -p 8080:80 nginx:1.30-alpine sudo docker run -d --name lab-web6 --network lab-v6 -p 8082:80 nginx:1.30-alpine
aede8be43e07ee891dbda79acfa502e97565c1ffce6b2d81e640c0223aaf032e ebb7873b93be918ddb850eabefc5740aaeeb96158aeff925dd546804180aaa39 2dbe8a255b714e96a1c8658e771a31893ea1939f6391b447abbaab83eb9526d4
$ sudo docker port lab-web sudo docker port lab-web6 sudo docker network inspect lab-v6 --format '{{json .IPAM.Config}}' sudo ss -tlnp | grep -c docker-proxy || true
80/tcp -> 0.0.0.0:8080 80/tcp -> 0.0.0.0:8082 80/tcp -> [::]:8082 [{"Subnet":"10.89.12.0/24","Gateway":"10.89.12.1"},{"Subnet":"fd8d:df:9637::/64","Gateway":"fd8d:df:9637::1"}] 0
$ V6=$(ip -6 -br addr show scope global | awk 'NR==1{sub("/.*", "", $3); print $3}') echo "host IPv6 address: $V6" curl -s -o /dev/null -w 'v4 loopback, IPv4-only net %{http_code}\n' --max-time 3 http://127.0.0.1:8080/ curl -s -o /dev/null -w 'v6 address, IPv4-only net %{http_code}\n' --max-time 3 "http://[$V6]:8080/" curl -s -o /dev/null -w 'v6 address, dual-stack net %{http_code}\n' --max-time 3 "http://[$V6]:8082/" true
host IPv6 address: fda8:2347:c04c:e555:5054:ff:fec3:33d5 v4 loopback, IPv4-only net 200 v6 address, IPv4-only net 000 v6 address, dual-stack net 200
$ sudo ip6tables -t nat -S DOCKER
-N DOCKER -A DOCKER ! -s fe80::/10 -p tcp -m tcp --dport 8082 -j DNAT --to-destination [fd8d:df:9637::2]:80

With the proxy off, the IPv4-only network got only an IPv4 mapping: no process exists to carry IPv6 clients to an IPv4 container, so the host's IPv6 address no longer reaches it (000). Loopback IPv4 still works, now without a proxy process. The --ipv6 network got a ULA subnet (an fd00::/8 prefix Docker picks when you give none), mappings on both families, and an ip6tables DNAT rule, so IPv6 clients reach lab-web6 without a proxy. The rule's ! -s fe80::/10 keeps link-local traffic out of it. If IPv6 clients must reach a service, give its network IPv6; publishing alone only covers them through docker-proxy.

The nftables firewall backend

Docker 29.0 added "firewall-backend": "nftables", experimental in 29.x: Docker writes native nftables rules instead of iptables rules. It changes how you add your own rules, so try it before you plan on it:

ubuntu@secopslog-docker-sec:~/lab/ports · Docker 29.8.2
$ sudo docker rm -f lab-web lab-web6 >/dev/null jq '. + {"firewall-backend": "nftables"}' /etc/docker/daemon.json.bak | sudo tee /etc/docker/daemon.json sudo dockerd --validate --config-file /etc/docker/daemon.json sudo systemctl restart docker sudo docker info --format '{{json .FirewallBackend}}'
{ "firewall-backend": "nftables" } configuration OK {"Driver":"nftables","Info":[["EnableUserlandProxy","true"],["UserlandProxyPath","/usr/bin/docker-proxy"]]}
$ sudo nft list tables | grep docker sudo iptables -S FORWARD | grep -v ufw cat /proc/sys/net/ipv4/ip_forward
table ip docker-bridges table ip6 docker-bridges -P FORWARD ACCEPT -A FORWARD -j DOCKER-USER 1

Docker now owns two nftables tables, ip docker-bridges and ip6 docker-bridges, and its iptables chains are gone except the old jump from FORWARD to DOCKER-USER, which stays until it is removed or the host reboots. Two things are now yours. Docker does not enable IP forwarding with this backend; it reports an error when a network needs forwarding and it is off. It is 1 here only because the earlier iptables-backend start enabled it, so on a migrated host set net.ipv4.ip_forward=1 and net.ipv6.conf.all.forwarding=1 in a sysctl.d file. And an iptables FORWARD chain with a DROP policy still drops packets that Docker's nftables rules accepted. The iptables backend sets that policy when it enables forwarding itself, as the main VM showed; here the policy already reads ACCEPT because disabling ufw earlier reset it. On a host that switched backends without a reboot, run sudo iptables -P FORWARD ACCEPT and sudo ip6tables -P FORWARD ACCEPT.

Forwarding on and nothing in iptables dropping it means the host now forwards between all of its interfaces, not only Docker's bridges. On a host with more than one network, a neighbour that uses this host as its gateway can reach the other networks through it. Docker's documentation asks you to add firewall rules that block unwanted forwarding between non-Docker interfaces before you enable forwarding, and to keep such blocking on any multi-homed host that is not meant to be a router. A table of your own does it; for a host with interfaces eth0 and eth1:

no-ext-forwarding.nft
table inet no-ext-forwarding {
chain forward {
type filter hook forward priority filter; policy accept;
iifname "eth0" oifname "eth1" drop
iifname "eth1" oifname "eth0" drop
}
}

Docker's own chains still accept what goes to and from its bridges, and your drops between the external interfaces stop everything else. Publish a port and look at the rules:

ubuntu@secopslog-docker-sec:~/lab/ports · Docker 29.8.2
$ sudo docker run -d --name lab-web --network lab-pub -p 8080:80 nginx:1.30-alpine
7c6877871ee77436180f662e75e9d256e22d1cefcee53887e4698088a8210f2f
$ BR=br-$(sudo docker network inspect -f '{{.Id}}' lab-pub | cut -c1-12) sudo nft list chain ip docker-bridges filter-forward-in__$BR
table ip docker-bridges { chain filter-forward-in__br-2fb301a07cb2 { ct state established,related counter packets 0 bytes 0 accept iifname "br-2fb301a07cb2" counter packets 0 bytes 0 accept comment "ICC" ip daddr 10.89.10.2 tcp dport 80 counter packets 0 bytes 0 accept counter packets 0 bytes 0 drop comment "UNPUBLISHED PORT DROP" } }
$ sudo nft list table ip docker-bridges | grep -e 'hook' -e '8080' -e '10.89.10.2'
type filter hook forward priority filter; policy accept; type nat hook output priority dstnat; policy accept; type nat hook postrouting priority srcnat; policy accept; type nat hook prerouting priority dstnat; policy accept; iifname != "br-2fb301a07cb2" tcp dport 8080 counter packets 0 bytes 0 dnat to 10.89.10.2:80 comment "DNAT" type filter hook prerouting priority raw; policy accept; ip daddr 10.89.10.2 iifname != "br-2fb301a07cb2" counter packets 0 bytes 0 drop comment "DROP DIRECT ACCESS" ip daddr 10.89.10.2 tcp dport 80 counter packets 0 bytes 0 accept

The same logic in a different shape: a DNAT rule for port 8080, a raw-priority drop for direct access, and a per-bridge chain that accepts established traffic, traffic between containers on the bridge (ICC), the published port, and drops the rest (UNPUBLISHED PORT DROP). There is no DOCKER-USER. Your rules go in a table of your own, with a base chain on the hook you need:

lab-filter.nft
# Your own nftables rules live in your own table. Docker never touches it.
table inet lab-filter {
chain forward {
type filter hook forward priority filter - 10; policy accept;
iifname "lab-lan0" ct original proto-dst 8080 counter drop
}
}
ubuntu@secopslog-docker-sec:~/lab/ports · Docker 29.8.2
$ sudo ip netns exec lab-lan curl -s -o /dev/null -w '%{http_code}\n' --max-time 3 http://192.0.2.1:8080/
200
$ sudo nft -f lab-filter.nft sudo ip netns exec lab-lan curl -s -o /dev/null -w '%{http_code}\n' --max-time 3 http://192.0.2.1:8080/ || echo "neighbour: curl exit $?" curl -s -o /dev/null -w 'from the host: %{http_code}\n' http://127.0.0.1:8080/ sudo nft list table inet lab-filter
000 neighbour: curl exit 28 from the host: 200 table inet lab-filter { chain forward { type filter hook forward priority filter - 10; policy accept; iifname "lab-lan0" ct original proto-dst 8080 counter packets 3 bytes 180 drop } }

ct original proto-dst 8080 plays the role of --ctorigdstport. In nftables a drop in any base chain is final, whatever the priority, so your drop wins over Docker's accept; the priority (filter - 10, before Docker's chain) only decides the order. The reverse is not true: an accept in your table does not override a drop in Docker's. For that, the daemon option bridge-accept-fwmark lets packets carrying a firewall mark you set be accepted by Docker's chains. One limit decides the backend for some hosts:

ubuntu@secopslog-docker-sec:~/lab/ports · Docker 29.8.2
$ sudo docker swarm init --advertise-addr 192.0.2.1
Error response from daemon: --firewall-backend=nftables is incompatible with swarm mode

Swarm mode is incompatible with the nftables backend, because the overlay network rules have not been migrated. Roll back and confirm the iptables backend is active again:

ubuntu@secopslog-docker-sec:~/lab/ports · Docker 29.8.2
$ sudo nft delete table inet lab-filter sudo docker rm -f lab-web >/dev/null sudo docker network rm lab-pub lab-routed lab-v6 sudo cp /etc/docker/daemon.json.bak /etc/docker/daemon.json sudo systemctl restart docker sudo docker info --format '{{json .FirewallBackend}}' sudo nft list tables | grep docker || echo 'no docker tables in nftables' sudo ./lan-neighbour.sh down
lab-pub lab-routed lab-v6 {"Driver":"iptables","Info":[["EnableUserlandProxy","true"],["UserlandProxyPath","/usr/bin/docker-proxy"]]} no docker tables in nftables

Rootless Docker publishes ports through RootlessKit's port driver rather than through the host rules shown here; "Rootless Docker" (Advanced container security) covers how its ports and client addresses behave.

Quick check
01A Postgres container is published with -p 5432:5432 on a server where ufw denies all incoming traffic except SSH. A scanner on the LAN still connects to 5432. Where would a blocking rule actually take effect?
Incorrect — ufw's incoming rules are in INPUT; the DNATed packet is forwarded and never passes through INPUT.
Incorrect — EXPOSE is metadata and publishes or blocks nothing.
Correct — DOCKER-USER is the first stop for forwarded packets, and --ctorigdstport matches the port before DNAT.
Incorrect — The nat table is for address translation; filtering belongs in filter chains such as DOCKER-USER.
02A rule iptables -I DOCKER-USER -p tcp --dport 8080 -j DROP has a packet counter of 0, and the published port 8080 (container port 80) is still reachable. Why?
Correct — DOCKER-USER sees the rewritten packet; match the original with -m conntrack --ctorigdstport 8080.
Incorrect — DOCKER-USER exists for IPv4 (iptables) and IPv6 (ip6tables); the lab blocked IPv4 traffic there.
Incorrect — Docker creates the chain and leaves its content to you.
Incorrect — Host-local connections to 127.0.0.1 go through docker-proxy and skip FORWARD; DOCKER-USER is for forwarded traffic.
03You set "userland-proxy": false. A service on an IPv4-only network published with -p 8080:80 stops answering on the host's IPv6 address but still answers on IPv4. What change brings IPv6 clients back without the proxy?
Incorrect — There is still nothing to translate IPv6 into an IPv4-only container; without the proxy the mapping has no path.
Incorrect — The backend changes how rules are written, not what can be translated.
Incorrect — Accepting traffic does not give it an IPv6 destination to reach.
Correct — With IPv6 on the network, Docker adds an ip6tables DNAT rule to the container's IPv6 address.

Try this

Work through “The nftables firewall backend” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

If you keep one thing from publishing ports and the packet path, keep “The nftables firewall backend”. Decide now which check you will run when this shows up on a live system, and write it somewhere your team will find it.

Related