Firewall Docker-published ports with the DOCKER-USER chain
Limit who can reach the ports your containers publish on Debian 13, with rules that survive reboots and don't break the containers' own outbound traffic.
You lock a host down with iptables, then run a container with -p 9100:9100, and that port is open to every machine that can reach the host. The INPUT rules you wrote never see the traffic, because Docker rewrites the packet and forwards it before INPUT gets a look. UFW users hit exactly this: UFW’s rules live in INPUT too, which is why Docker seems to bypass UFW. Metrics exporters, admin UIs and databases end up reachable from the whole network, and the firewall says otherwise.
The usual fixes make it worse. Turning off Docker’s iptables handling breaks container networking. Writing rules that match on port number alone works until a container on the same host needs to talk to that port on another machine; then those connections are dropped too, silently, with nothing in any log. I did that twice before understanding why.
At the end of this you have a small ruleset in DOCKER-USER, the one chain Docker reserves for you, that allows published ports only from the sources you choose, leaves your containers’ own outbound traffic alone, and comes back on its own after a reboot or a Docker restart.
Before you start
Time
25 minutes
Level
Intermediate
You’ll need
A Debian 13 host with Docker installed from Docker's own apt repository, and root
A second machine on the same network to test from
The name of the host's network interface (ip -br link shows it)
Publish a port and watch INPUT ignore it
Everything below runs as root, so start with sudo -i. Then start a throwaway web server that publishes port 8080:
bash
docker run -d--name web -p8080:80 nginx:1.29-alpine
Now add an INPUT rule that should, by the usual logic, block it:
bash
iptables -I INPUT -p tcp --dport8080-j DROP
From your second machine, fetch the page anyway:
bash
curl-sI http://docker-host:8080
It answers 200 OK. The INPUT rule did nothing, because the packet never went through INPUT. Docker rewrote its destination in the nat table’s PREROUTING chain to the container’s address, and from there the kernel forwards it. Forwarded packets go through FORWARD, where Docker has installed its own chains:
bash
iptables -S FORWARD
The first jump is to DOCKER-USER. Docker creates that chain empty (older releases put a lone RETURN in it) and never touches it again. Anything you put there runs before every Docker rule, which makes it the right place for a policy.
Remove the useless INPUT rule before moving on:
bash
iptables -D INPUT -p tcp --dport8080-j DROP
Find the interface the outside world arrives on
The whole technique rests on one match: -i, the interface a packet came in on. Traffic from other machines arrives on the host’s physical interface. Traffic from your containers arrives on docker0 or a br-… bridge. Matching on the physical interface is what keeps the two apart.
bash
ip-brlinkip route show default
The interface named on the default route is the one you want. The examples below call it eth0; use yours.
Write the rules as a script
Put the ruleset in one script so it can be re-run at any time and give the same result. It flushes DOCKER-USER and rebuilds it from scratch, which is what makes it safe to run twice.
/usr/local/sbin/docker-user-rules.sh
#!/bin/shset-euEXT_IF="eth0"ALLOW_FROM="192.0.2.0/24"
iptables -N DOCKER-USER2>/dev/null ||true
iptables -F DOCKER-USER
iptables -A DOCKER-USER-m conntrack --ctstate RELATED,ESTABLISHED -j RETURN
iptables -A DOCKER-USER-i"$EXT_IF"-s"$ALLOW_FROM"-j RETURN
iptables -A DOCKER-USER-i"$EXT_IF"-j DROP
iptables -A DOCKER-USER-j RETURN
Set ALLOW_FROM to the network or address that should reach your published ports. Then make it executable and run it once:
Packets of connections already allowed pass, including replies to connections your containers opened. Without this, your containers can start a connection outward but never get the answer back.
New connections arriving on the external interface from the allowed range pass.
Everything else arriving on the external interface is dropped.
Whatever is left (which is only traffic that didn’t come in on eth0, so container-to-container and container-outbound) returns to Docker’s own chains unchanged.
If this host also routes traffic: rule 3 drops every new forwarded connection that arrives on eth0 from outside ALLOW_FROM, not only Docker’s. If the same host forwards for VMs on a bridge, a WireGuard tunnel or a Tailscale subnet route, add a RETURN for that traffic above the DROP, matched on where it’s going (-o), or it stops too.
Why RETURN and not ACCEPT?ACCEPT would end processing right there, skipping Docker’s own rules, which include network isolation between bridges. RETURN hands the packet back so Docker still gets its say. Allowed traffic is allowed either way; RETURN just doesn’t take anything away from Docker.
The mistake to avoid: the first version of my rules matched on port alone: -p tcp --dport 8081 -j DROP, with a RETURN for one trusted source above it. That dropped inbound traffic as intended, and it also dropped every connection a container on that host made to port 8081 on any other machine, because those are forwarded too, and DOCKER-USER sees the container’s private address as the source, not the host’s. A reverse proxy in a container on that host hung for days against a backend on another VM, with nothing in any log, because DROP is silent. The -i eth0 match on the allow and drop rules is what rules out that whole class of problem: container-originated packets never arrive on eth0.
Scope a rule to one published port
The script above treats all published ports the same. If you want, say, port 8080 open to one network and port 9100 open to one host, there’s a catch: by the time a packet reaches DOCKER-USER it has already been rewritten, so --dport matches the port inside the container, not the port you published. To match the published port, ask connection tracking for the original destination. In the script, replace the four iptables -A lines with these six. The two new ones sit after the established-connections rule and before the general allow, so the narrower policy for 9100 wins:
bash
iptables -A DOCKER-USER-m conntrack --ctstate RELATED,ESTABLISHED -j RETURN
iptables -A DOCKER-USER-i"$EXT_IF"-p tcp -m conntrack --ctorigdstport9100-s192.0.2.10 -j RETURN
iptables -A DOCKER-USER-i"$EXT_IF"-p tcp -m conntrack --ctorigdstport9100-j DROP
iptables -A DOCKER-USER-i"$EXT_IF"-s"$ALLOW_FROM"-j RETURN
iptables -A DOCKER-USER-i"$EXT_IF"-j DROP
iptables -A DOCKER-USER-j RETURN
Then run the script again. It rebuilds the chain in the new order:
bash
/usr/local/sbin/docker-user-rules.sh
Note: if a container publishes the same port number inside and out (-p 9100:9100), plain --dport 9100 happens to work. The moment someone publishes -p 9100:8080, it stops matching and nothing tells you. --ctorigdstport is the match that means what it says.
Keep published ports off IPv6
Everything so far is IPv4. A published port is open on IPv6 too: Docker’s docker-proxy listens on [::] for every published port, and on the default bridge it relays IPv6 connections to the container itself. Those arrive through INPUT, not FORWARD, so DOCKER-USER never sees them. If the host has a routable IPv6 address, the port is open to anything that can reach that address.
Check what is listening:
bash
ss -ltnp|grep docker-proxy
A line with [::]:8080 is the IPv6 side door. Close it by publishing on IPv4 only: give each port its host address.
bash
dockerrm-f web
docker run -d--name web -p0.0.0.0:8080:80 nginx:1.29-alpine
In a Compose file, it’s the same address in the port string:
Run ss -ltnp | grep docker-proxy again: only 0.0.0.0:8080 is left.
Why not set it once for the whole daemon?"ip": "0.0.0.0" in /etc/docker/daemon.json looks like it should do this, but Docker reads 0.0.0.0 there as “every address” and still opens [::]. Binding each port is what holds. If you do want a port reachable over IPv6, it needs its own ip6tables rules on INPUT; that’s beyond this guide.
Make it survive reboots and Docker restarts
Rules added with iptables live only in the kernel, so a reboot wipes them. Docker leaves DOCKER-USER alone when it restarts, but at boot it builds its chains from scratch. A tiny systemd unit runs the script once Docker is up at boot, and again whenever Docker is restarted, so the chain always holds your rules.
PartOf=docker.service means a systemctl restart docker restarts this unit too, so the rules are re-applied right after Docker finishes its own.
Why not netfilter-persistent? It works, but netfilter-persistent save snapshots every chain, including Docker’s own DOCKER and DOCKER-FORWARD chains with container addresses that will be wrong by the next boot. Keeping your rules in a script that owns exactly one chain is cleaner, and it is the same shape you’d use in Ansible.
Check it works
From a machine inside ALLOW_FROM:
bash
curl-sI --max-time 5 http://docker-host:8080
From a machine outside it, the same command prints nothing and gives up after five seconds instead of being refused; that’s DROP doing its job. Back on the host, the counters on the DROP rule should have moved:
bash
iptables -L DOCKER-USER-n-v
Confirm the container can still reach out:
bash
dockerexec web wget-q-O /dev/null https://deb.debian.org/ &&echo egress-ok
Last, the real test of the systemd unit: reboot, log back in (and sudo -i again), and look at the chain.
bash
systemctl reboot
bash
iptables -S DOCKER-USER
You should see your four rules (six if you added the per-port ones). An empty chain means the unit didn’t run, and systemctl status docker-user-rules.service says why.
Roll it out with Ansible
If you have more than one Docker host, this is the whole play. It copies the script and unit, then enables the unit; because the script is idempotent, so is the play.
docker-user-rules.yml
-name: Restrict Docker-published ports
hosts: docker_hosts
become:truevars:ext_if: eth0
allow_from: 192.0.2.0/24
tasks:-name: Install the rules script
ansible.builtin.template:src: docker-user-rules.sh.j2
dest: /usr/local/sbin/docker-user-rules.sh
mode:'0755'notify: Reapply rules
-name: Install the systemd unit
ansible.builtin.copy:src: docker-user-rules.service
dest: /etc/systemd/system/docker-user-rules.service
mode:'0644'notify: Reapply rules
-name: Enable the unit
ansible.builtin.systemd:name: docker-user-rules.service
enabled:truedaemon_reload:truestate: started
handlers:-name: Reapply rules
ansible.builtin.systemd:name: docker-user-rules.service
state: restarted
Next to the playbook, put the script in templates/docker-user-rules.sh.j2, with EXT_IF="{{ ext_if }}" and ALLOW_FROM="{{ allow_from }}" in place of the hard-coded values, and the unit in files/docker-user-rules.service. Those are the two folders Ansible looks in.
Clean up the demo container
bash
dockerrm-f web
Looking ahead: Docker 29 adds an experimental nftables backend ("firewall-backend": "nftables" in daemon.json). It has no DOCKER-USER chain at all; you add your own nftables table with a forward hook and a lower priority number than Docker’s, so it runs first. Debian 13 with Docker’s apt packages still uses the iptables backend by default, so everything above applies unchanged, but if you switch backends, this chain disappears with it.