Idunworks Issue #01
I can has lingo?

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:

  1. Publishing: routing traffic from host ports to containers.

  2. Ingress isolation: keeping everything else away from containers.

  3. Inter-network isolation: keeping separate Docker networks apart.

  4. 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-FORWARD and DOCKER-CT. Newer engines also offer an experimental native nftables backend. The concepts below still apply, but chain names may differ on your machine. Check with sudo iptables -S and sudo 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 the nat and filter tables). This chain does the actual port publishing, in two halves:

    • In the nat table it DNATs traffic: a packet addressed to host:8080 is rewritten to 172.17.0.3:80.

    • In the filter table it ACCEPTs the rewritten packet, but only if it matches a published port.

  • DOCKER-USER. A chain evaluated near the top of FORWARD, before Docker's own forwarding rules. Docker creates it empty (aside from a RETURN) and never modifies it, so it's the supported place for your own allow and deny rules for container traffic.

  • DOCKER-ISOLATION-STAGE-1 and DOCKER-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 FORWARD chain's policy to DROP. 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 ACCEPT
    

    The 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-2
    
  • Stage 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 DROP
    

    Traffic 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_a can still reach a port published by network_b through 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:

  1. A container (172.17.0.3) sends a packet to 8.8.8.8.

  2. As the packet leaves the host, the kernel replaces the private source IP with the IP of the host's outgoing interface.

  3. The kernel records the mapping in its conntrack table.

  4. When 8.8.8.8 replies to the host, conntrack translates the destination back to 172.17.0.3 and 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

  1. UFW's rules apply to the INPUT chain: traffic addressed to the host itself.

  2. Docker's DNAT runs in nat PREROUTING, before the routing decision. By the time the kernel decides where the packet goes, its destination is already 172.17.0.3:80, a container.

  3. A packet for a container takes the FORWARD path, not INPUT. 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

nat → DOCKER

DNAT host:port → container:port

-p / --publish

Allow published traffic

filter → DOCKER

ACCEPT matching container IP/port

Created automatically

Your own policy

filter → DOCKER-USER

Evaluated first in FORWARD

Match with --ctorigdstport

Block everything else

filter → FORWARD

Policy DROP

Default-deny

Allow replies

filter → FORWARD

conntrack RELATED,ESTABLISHED

Stateful

Separate networks

DOCKER-ISOLATION-STAGE-1/2

Drop bridge-to-bridge forwarding

Two-step match

Reach the internet

nat → POSTROUTING

MASQUERADE (SNAT)

! -o docker0


What We Learn

  1. The packet's path decides which rules apply. Traffic for the host goes through INPUT. Traffic for a container goes through FORWARD. A firewall that only manages INPUT doesn't see your containers.

  2. 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-USER rules need --ctorigdstport.

  3. -p 8080:80 means "open to the world." It binds 0.0.0.0 (and ::) unless you say otherwise. Publish to 127.0.0.1 or avoid publishing at all.

  4. DOCKER-USER is your chain. Docker evaluates it first and never touches it. Put your rules there and persist them yourself.

  5. conntrack lets containers make outbound connections while keeping inbound ones blocked. The same mechanism handles NAT translation in both directions.

  6. 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.

  7. Check, don't assume. iptables -S, packet counters and conntrack -L show 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?

Log in to like this article, or create an account .
0 reads

Comments

Log in to post comments. Log in
No comments yet. Be the first to comment.