Skip to main content
RunBook Academy

Docker & ContainersXXVII · FirewallsHost firewall

nftables and iptables — the host firewall in the Docker era

Advanced⏱ ~30 mindockeriptablesnft

What you'll learn

  • Name every chain Docker creates and say which table it lives in
  • Trace a published-port packet through netfilter and show where INPUT is skipped
  • Place a rule in DOCKER-USER so it survives a daemon restart
  • Match on the pre-DNAT destination using conntrack
  • Recognise what changes under the Engine 29 nftables backend

Prerequisites

None — start here.

Verified against Docker Engine 29.x · Docker Engine 28.x · Docker Compose 2.x · containerd 2.x · runc 1.2.x · BuildKit 0.20+ · Linux kernel 5.15+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-12

Not yet marked complete on this device.

A Docker host runs two firewall rulesets. One is yours: the input chain you wrote, or the ufw rules you added, or the firewalld zone you assigned. The other belongs to the daemon, which rewrites it every time it starts and every time a container publishes a port.

They do not conflict in the usual sense. They are worse than conflicting: they act on different hooks in the packet path, and each one reports only on itself. ufw status describes your ruleset accurately and says nothing whatsoever about container exposure.

What Docker actually installs

Docker does not write a couple of rules. On a host with one published port it installs a small tree of chains across two tables.

In the filter table:

ChainWhat it is for
DOCKER-USERA placeholder for your rules. Processed before DOCKER-FORWARD and DOCKER. Docker creates it and never writes to it.
DOCKER-FORWARDFirst-stage forward processing: separates established connections from new traffic.
DOCKERAccepts or drops based on what is actually published.
DOCKER-BRIDGEPer-bridge dispatch into DOCKER.
DOCKER-INTERNALEnforces --internal networks.
DOCKER-CTPer-bridge connection-tracking accepts.
DOCKER-ISOLATION-STAGE-1 / -STAGE-2Keeps one user-defined bridge from reaching another.
DOCKER-INGRESSSwarm routing mesh. Present only in Swarm mode.

In the nat table, a single DOCKER chain carries the masquerade and port-mapping rules. This is the chain that does DNAT.

Read-only / Safechain inventory
# The NAT rules that implement -p
sudo iptables -t nat -S DOCKER

# The forward-path filter chains
sudo iptables -S DOCKER-USER
sudo iptables -S DOCKER-FORWARD
sudo iptables -S DOCKER

# Everything Docker owns, in one pass
sudo iptables -S | grep -E 'DOCKER'

Where the packet actually goes

Publish a port and look at the rule Docker adds:

Read-only / Safethe DNAT rule
$ docker run -d --name web -p 8080:80 nginx:1.27-alpine
sudo iptables -t nat -S DOCKER
-N DOCKER
-A DOCKER -i docker0 -j RETURN
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80

Illustrative output

That rule sits in the DOCKER chain of the nat table, which PREROUTING jumps to. PREROUTING runs before the kernel decides whether the packet is for this host or for somewhere else. By the time the routing decision happens, the destination address has already been rewritten from 203.0.113.10:8080 to 172.17.0.2:80 — an address that is not local.

So the kernel routes it. Forwarded packets traverse FORWARD. They do not traverse INPUT.

flowchart TD
  A[Packet arrives on eth0<br/>dst 203.0.113.10:8080] --> B[nat PREROUTING]
  B --> C[chain DOCKER<br/>DNAT to 172.17.0.2:80]
  C --> D{Routing decision:<br/>is the destination local?}
  D -->|"No — it is now 172.17.0.2"| E[filter FORWARD]
  D -.->|"the path your INPUT rules are on"| F[filter INPUT]
  E --> G[DOCKER-USER]
  G --> H[DOCKER-FORWARD]
  H --> I[DOCKER]
  I --> J[veth into the container]
  F -.-> K[sshd, a native nginx,<br/>a --network host container]

The dotted branch is where every rule you wrote lives. The solid branch is where the container traffic went.

DOCKER-USER — the supported insertion point

DOCKER-USER exists so that you have somewhere to put a rule that is on the right path and survives a daemon restart. Docker jumps to it before its own forward chains and never touches the contents.

The documented pattern is a negated source match: allow one source, drop everything else arriving on the external interface.

Service impact possiblerestrict a published port to one source
EXT_IF=eth0

# Permit only 192.0.2.2; drop everything else arriving on EXT_IF
sudo iptables -I DOCKER-USER -i "$EXT_IF" ! -s 192.0.2.2 -j DROP

# A whole subnet instead of a single address
sudo iptables -I DOCKER-USER -i "$EXT_IF" ! -s 192.0.2.0/24 -j DROP

# A range, using the iprange module
sudo iptables -I DOCKER-USER -i "$EXT_IF" -m iprange \
! --src-range 192.0.2.1-192.0.2.63 -j DROP

Two details that decide whether this works:

  • -I, not -A. DOCKER-USER ends with a RETURN. Appending puts your rule after it, where it is never reached.
  • -i "$EXT_IF" is not optional. Without it the rule also matches container-to-container traffic on the bridges, and you will drop traffic between your own services while debugging why the block “did nothing”.

A clean host firewall for Docker

table inet filter {
    chain input {
        type filter hook input priority 0; policy drop;

        # Established traffic
        ct state established accept

        # Loopback
        iif lo accept

        # ICMP
        ip protocol icmp accept

        # SSH (management)
        tcp dport 22 accept

        # Services the HOST itself listens on — a host-networked
        # container or a native daemon such as nginx or sshd.
        tcp dport { 80, 443 } accept

        # Drop the rest
    }
}

Read that ruleset for what it is: a policy for traffic terminating on the host. It governs sshd, a native web server, and any container run with --network host, because all of those are delivered locally and traverse the input hook.

It governs nothing about a container published with -p. Those packets are DNATed and forwarded, so the input chain above is never consulted for them — and note that this is a table inet filter, while Docker’s iptables rules live in table ip filter, a separate ruleset entirely. Container exposure is decided on the forward path, by DOCKER-USER and Docker’s own chains.

The Engine 29 nftables backend

Docker 29.0.0 added an experimental nftables backend. It is off by default and it is not a rename of the iptables one.

Configuration changedaemon.json
# /etc/docker/daemon.json
{
"firewall-backend": "nftables"
}

What changes for you:

  • Docker creates ip docker-bridges and ip6 docker-bridges tables, with base chains plus per-network chains. It no longer writes iptables rules for bridge networks.
  • There is no DOCKER-USER equivalent. Upstream is explicit about this. You create your own table with base chains on the same hooks and give them a lower priority value so they run first, or you use the --bridge-accept-fwmark daemon option to mark packets Docker should accept.
  • Overlay network rules have not been migrated, so the nftables backend cannot be used in Swarm mode.

Direct container firewalling

You can apply firewall rules inside an individual container’s own network namespace with NET_ADMIN and the container’s own iptables:

Service impact possiblein-container rules
CONTAINER=web

docker run -d --name "$CONTAINER" --cap-add=NET_ADMIN myapp:1.0.0
docker exec "$CONTAINER" iptables -A INPUT -p tcp --dport 80 -j DROP

Note what you just granted. NET_ADMIN inside the container is enough to manipulate that namespace’s routing and firewall, which is a real capability increase over the default set. Use it for debugging, and prefer DOCKER-USER or a network the container simply is not attached to for anything you intend to leave running.

Verification that can fail

Reading your own ruleset back proves that you wrote what you wrote. It proves nothing about reachability. The only check that can actually fail is a probe from a different host.

Read-only / Safeexternal probe
TARGET=203.0.113.10

# Is the published port answering to an off-host client?
nc -z -w 3 "$TARGET" 8080 && echo "OPEN from outside" || echo "closed from outside"

# The full picture, if nmap is available
nmap -Pn -p 22,80,443,8080 "$TARGET"

Then correlate the answer with the counters, which tell you which ruleset made the decision:

Read-only / Safewhich chain decided
# -v -n prints packet and byte counters per rule
sudo iptables -L DOCKER-USER -v -n --line-numbers

# Compare with the input path
sudo iptables -L INPUT -v -n | head -20

# nftables hosts
sudo nft list ruleset | grep -A5 'hook forward'

If the external probe says OPEN and your DOCKER-USER counter is zero, your rule is not on the path — almost always because it was appended after the RETURN or because -i names the bridge instead of the external interface.

Sanity check

  • sudo iptables -t nat -S DOCKER lists one DNAT rule per published port, and nothing you did not publish.
  • sudo iptables -S DOCKER-USER shows your rules above the trailing RETURN, each with -i naming the external interface.
  • An nc -z from a second host agrees with what you believe is exposed.
  • Your DOCKER-USER rules are in iptables-save output and restored by a unit, not only in the live ruleset.

Knowledge check

Knowledge check · 5 questions

  1. Q1. A host has policy DROP on INPUT and a container published with `-p 8080:80`. A client on the internet connects to port 8080. What happens?

  2. Q2. Which chain is the supported place for your own rules under the iptables backend?

  3. Q3. You add `iptables -I DOCKER-USER -i eth0 -d 203.0.113.10 --dport 8080 -j DROP` to block a published port. The port stays reachable and the rule counter reads zero. Why?

  4. Q4. Which statements about the Engine 29 nftables backend are true? Select all that apply.

  5. Q5. Restarting the Docker daemon restores any rules you previously added to DOCKER-USER.

Passing score: 75%. Answers are checked in this browser.