Docker and iptables
How Your Containers Are Firewalled (and How They Get Past Your Firewall)
Why This Matters
Here's a scenario that happens a lot. You set up a fresh server, enable UFW, deny every port except SSH, and feel secure. Then you run:
docker run -d -p 8080:80 nginx
A week later a port scanner on the other side of the world finds your nginx on port 8080. UFW still says the port is denied.
This isn't a Docker bug or a UFW bug. Docker writes its own rules directly into the kernel's packet-filtering framework, Netfilter, and those rules sit on a path through the kernel that tools like UFW usually don't look at. If you don't know which path a packet takes, your firewall configuration is guesswork.
Knowing how Docker uses iptables lets you:
Know what is actually exposed instead of trusting what the firewall tool claims.
Put your own rules where they take effect, and where Docker won't overwrite them.
Debug connectivity problems. "Container can't reach the internet," "network A can't reach network B," and "why is this port open?" all have specific, findable causes.
Here's what happens to a packet once Docker is managing your firewall.
The Big Picture
When the Docker daemon starts, it does not go through host firewall managers like UFW. It inserts its own custom chains straight into the kernel's nat and filter tables. (Firewalld is the partial exception: recent Docker versions register their bridges in a dedicated docker firewalld zone.)
The rules do four jobs:
Publishing: routing traffic from host ports to containers.
Ingress isolation: keeping everything else away from containers.
Inter-network isolation: keeping separate Docker networks apart.
Egress: letting containers reach the internet using the host's IP.
Version note: Docker 28 reorganized the chain layout and added helper chains such as
DOCKER-FORWARDandDOCKER-CT. Newer engines also offer an experimental native nftables backend. The concepts below still apply, but chain names may differ on your machine. Check withsudo iptables -Sandsudo iptables -t nat -S.
1. The Core Architecture: Custom Chains
Docker doesn't put everything in the built-in INPUT and FORWARD chains. It organizes its logic into custom chains:
DOCKER(in both thenatandfiltertables). This chain does the actual port publishing, in two halves:In the
nattable it DNATs traffic: a packet addressed tohost:8080is rewritten to172.17.0.3:80.In the
filtertable it ACCEPTs the rewritten packet, but only if it matches a published port.
DOCKER-USER. A chain evaluated near the top ofFORWARD, before Docker's own forwarding rules. Docker creates it empty (aside from aRETURN) and never modifies it, so it's the supported place for your own allow and deny rules for container traffic.DOCKER-ISOLATION-STAGE-1andDOCKER-ISOLATION-STAGE-2. Together these block Layer 3 routing between different Docker bridge networks.
2. Ingress: Keeping the Internet Away from Containers
By default, a container on a bridge network can't be reached from outside unless you publish a port. The order of evaluation is important here: the NAT rewrite happens first, and the filtering comes after it.
[ Packet from the internet → host:8080 ]
│
▼
nat: PREROUTING → DOCKER (nat)
Published port? → DNAT to 172.17.0.3:80
│
Routing decision
/ \
For the host itself For a container
│ │
filter: INPUT filter: FORWARD
(UFW lives here) │
DOCKER-USER ← your rules go here
│
DOCKER-ISOLATION-STAGE-1/2
│
DOCKER (filter) ← ACCEPT if it matches a published port
│
No match → FORWARD policy DROP
Default-deny forwarding. Docker sets the
FORWARDchain's policy toDROP. Any packet heading for a container that wasn't accepted by a published-port rule, or by the stateful rule below, is dropped.Stateful return traffic (
conntrack). Containers need to download packages and call external APIs, so the replies have to get back in. Docker adds a stateful rule per bridge, roughly:-A FORWARD -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPTThe kernel's connection tracker remembers every connection a container opens. Only packets belonging to those connections are let back in. This is how stateful firewalls are supposed to work, not a loophole: the internet can reply to your containers but can't start a conversation with them.
Older engines: Before Docker 28, hosts on the same LAN segment could sometimes reach unpublished container ports by routing directly to the container IP. Docker 28 added explicit protection against this. Upgrade if you're on something older.
3. Inter-Network Isolation: Keeping Bridges Apart
Say you create two user-defined bridge networks, network_a and network_b. Both are attached to the same host kernel, and the host's routing table knows how to reach both subnets. Without extra rules, the host would happily route traffic between them. Docker's isolation chains prevent that in two steps.
Stage 1: "Is this packet leaving its own network?" For each bridge, Stage 1 catches packets that came in on that bridge and are going out on a different interface, and sends them to Stage 2:
-A DOCKER-ISOLATION-STAGE-1 -i br-1a2b3c4d5e6f ! -o br-1a2b3c4d5e6f -j DOCKER-ISOLATION-STAGE-2Stage 2: "Is it trying to enter another Docker network?" If the outbound interface is any other Docker bridge, the packet is dropped:
-A DOCKER-ISOLATION-STAGE-2 -o br-9f8e7d6c5b4a -j DROPTraffic going to the physical uplink (the internet) passes through, because it doesn't match any bridge.
Bridge interfaces are named br- followed by the start of the network ID. Run docker network ls and ip link to match them up.
Know the limits. This is network isolation, not tenant isolation:
All containers still share one kernel.
A container on
network_acan still reach a port published bynetwork_bthrough the host's IP, because that traffic goes through the host rather than directly across bridges.For real staging/production separation, use separate hosts or VMs.
4. Egress: Masquerading Behind the Host
Containers use private RFC 1918 address ranges (e.g. 172.17.0.0/16), which aren't routable on the public internet. To let them reach the outside world, Docker uses source NAT through the MASQUERADE target in the nat table:
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
How it works:
A container (
172.17.0.3) sends a packet to8.8.8.8.As the packet leaves the host, the kernel replaces the private source IP with the IP of the host's outgoing interface.
The kernel records the mapping in its conntrack table.
When
8.8.8.8replies to the host, conntrack translates the destination back to172.17.0.3and forwards the reply into the right container namespace.
Read the rule's ! -o docker0 part as "only when leaving the bridge." Traffic between containers on the same bridge keeps its real source IP.
⚠️ The Gotcha: Docker Bypasses UFW
Back to the opening scenario. An engineer writes:
ufw deny 8080/tcp
and assumes port 8080 is closed to the internet. It isn't.
Why it fails
UFW's rules apply to the
INPUTchain: traffic addressed to the host itself.Docker's DNAT runs in
nat PREROUTING, before the routing decision. By the time the kernel decides where the packet goes, its destination is already172.17.0.3:80, a container.A packet for a container takes the
FORWARDpath, notINPUT. UFW's deny rule is never checked, and Docker's ACCEPT rule lets the packet through.
The fixes
Option 1: Don't publish to every interface. If only the host itself (or a reverse proxy on the host) needs the port, bind it to loopback:
docker run -p 127.0.0.1:8080:80 nginx
Better still, don't publish at all. Put the services on a shared Docker network and expose only your reverse proxy.
Option 2: Filter in DOCKER-USER. One catch: rules in DOCKER-USER see the packet after DNAT, so --dport 8080 won't match. The destination port has already been rewritten to the container's port. Match on the original destination with conntrack instead:
# Drop external traffic to published port 8080, except from a trusted subnet
sudo iptables -I DOCKER-USER -i eth0 ! -s 203.0.113.0/24 -p tcp \
-m conntrack --ctorigdstport 8080 --ctdir ORIGINAL -j DROP
Docker doesn't save DOCKER-USER rules across reboots. Persist them with iptables-persistent, a systemd unit, or your configuration management tool.
Option 3: Set a safer default bind address. "ip": "127.0.0.1" in /etc/docker/daemon.json makes plain -p 8080:80 bind to loopback unless an address is given explicitly.
See It for Yourself
Commands to explore your own host:
sudo iptables -t nat -S DOCKER
sudo iptables -L DOCKER-USER -v -n --line-numbers
sudo iptables -S | grep DOCKER-ISOLATION
sudo conntrack -L | grep 172.17
The first shows the DNAT rules for your published ports.
The second shows your rules and how many packets each one has matched.
The third shows the isolation rules between your bridge networks.
The fourth lists live NAT sessions (needs
conntrack-tools).
Quick Reference
Job | Table / chain | Mechanism | Key rule |
|---|---|---|---|
Publish a port |
| DNAT host:port → container:port |
|
Allow published traffic |
| ACCEPT matching container IP/port | Created automatically |
Your own policy |
| Evaluated first in | Match with |
Block everything else |
| Policy | Default-deny |
Allow replies |
| conntrack | Stateful |
Separate networks |
| Drop bridge-to-bridge forwarding | Two-step match |
Reach the internet |
|
|
|
What We Learn
The packet's path decides which rules apply. Traffic for the host goes through
INPUT. Traffic for a container goes throughFORWARD. A firewall that only managesINPUTdoesn't see your containers.NAT runs before filtering. By the time a filter rule sees a published-port packet, its destination has already been rewritten. That's why
DOCKER-USERrules need--ctorigdstport.-p 8080:80means "open to the world." It binds0.0.0.0(and::) unless you say otherwise. Publish to127.0.0.1or avoid publishing at all.DOCKER-USERis your chain. Docker evaluates it first and never touches it. Put your rules there and persist them yourself.conntrack lets containers make outbound connections while keeping inbound ones blocked. The same mechanism handles NAT translation in both directions.
Bridge isolation is not tenant isolation. Networks can't route to each other directly, but they share a kernel and can reach each other through the host's published ports.
Check, don't assume.
iptables -S, packet counters andconntrack -Lshow what is really happening. Firewall tool output shows what the tool thinks is happening.
Outro: The Two Bouncers
Picture your server as a nightclub with two doors.
UFW guards the front door (INPUT). It checks every name against the list and is very strict about port 8080.
Docker built a side entrance (FORWARD) right next to it, with its own guard. That guard has one rule: "If you're on the -p list, come in." No queue, and no check with the front door.
Meanwhile, in the back, conntrack remembers every guest who stepped out for a smoke and lets them back in without asking. MASQUERADE sends every container out into the city wearing the host's face. And the isolation chains make sure guests from the VIP room (network_a) can't walk into the staff room (network_b). They can still go outside and come back in through the published front door.
Your job as the club owner: post your own guard at the DOCKER-USER door, give them a list based on the original destination, and stop assuming the front-door guard is watching the side entrance.
Next time someone says "but UFW is enabled!", just ask: "On which chain?" 🐳🔥
Did we mention you can make a nerdy t-shirt for that?