Docker & ContainersXXVII · FirewallsHost firewall
nftables and iptables — the host firewall in the Docker era
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
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:
| Chain | What it is for |
|---|---|
DOCKER-USER | A placeholder for your rules. Processed before DOCKER-FORWARD and DOCKER. Docker creates it and never writes to it. |
DOCKER-FORWARD | First-stage forward processing: separates established connections from new traffic. |
DOCKER | Accepts or drops based on what is actually published. |
DOCKER-BRIDGE | Per-bridge dispatch into DOCKER. |
DOCKER-INTERNAL | Enforces --internal networks. |
DOCKER-CT | Per-bridge connection-tracking accepts. |
DOCKER-ISOLATION-STAGE-1 / -STAGE-2 | Keeps one user-defined bridge from reaching another. |
DOCKER-INGRESS | Swarm 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.
# 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:
$ 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:80Illustrative 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.
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 DROPTwo details that decide whether this works:
-I, not-A.DOCKER-USERends with aRETURN. 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.
# /etc/docker/daemon.json
{
"firewall-backend": "nftables"
}What changes for you:
- Docker creates
ip docker-bridgesandip6 docker-bridgestables, with base chains plus per-network chains. It no longer writes iptables rules for bridge networks. - There is no
DOCKER-USERequivalent. 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-fwmarkdaemon 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:
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 DROPNote 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.
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:
# -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 DOCKERlists one DNAT rule per published port, and nothing you did not publish.sudo iptables -S DOCKER-USERshows your rules above the trailingRETURN, each with-inaming the external interface.- An
nc -zfrom a second host agrees with what you believe is exposed. - Your
DOCKER-USERrules are iniptables-saveoutput and restored by a unit, not only in the live ruleset.
Knowledge check
Knowledge check · 5 questions
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?
Q2. Which chain is the supported place for your own rules under the iptables backend?
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?
Q4. Which statements about the Engine 29 nftables backend are true? Select all that apply.
Q5. Restarting the Docker daemon restores any rules you previously added to DOCKER-USER.
Passing score: 75%. Answers are checked in this browser.