Docker & ContainersXXVII Β· FirewallsDOCKER-USER
The DOCKER-USER chain, and making rules survive a reboot
What you'll learn
- Write DOCKER-USER rules that match the traffic you meant to match
- Use conntrack to match the pre-DNAT address and port
- Persist the rules with a mechanism that survives a reboot and a daemon restart
- Verify persistence rather than assuming it
Prerequisites
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-11
Docker owns its netfilter chains and reconciles them whenever the
daemon starts. Editing them is pointless: your change is gone at the
next systemctl restart docker.
DOCKER-USER is the exception. Upstream describes it as βa
placeholder for user-defined rules that will be processed before
rules in the DOCKER-FORWARD and DOCKER chainsβ. Docker creates
it, jumps to it, and does not put its own rules in it. That is the
whole contract, and it is the only supported place to filter
container traffic on an iptables host.
The chains Docker creates
In the filter table:
| Chain | Purpose |
|---|---|
DOCKER-USER | Yours. Evaluated before Dockerβs own filter rules. |
DOCKER-FORWARD | First-stage handling; lets established connections through and dispatches the rest. |
DOCKER | Accepts traffic to published ports. |
DOCKER-BRIDGE, DOCKER-INTERNAL | Per-network accept and isolation rules. |
DOCKER-CT | Per-bridge connection-tracking rules. |
DOCKER-INGRESS | Swarm ingress routing mesh. |
FORWARD contains unconditional jumps to DOCKER-USER,
DOCKER-FORWARD and DOCKER-INGRESS, in that order, at the top of
the chain.
sudo iptables -S FORWARD
sudo iptables -S DOCKER-USER
sudo iptables -t nat -S DOCKERInsert, do not append
Read the chain before you write to it:
sudo iptables -S DOCKER-USER
If the chain ends in a rule that terminates traversal β a RETURN or
a catch-all -j DROP β anything you append with -A sits below it
and is dead. Upstreamβs examples all use -I, which inserts at
position 1. Use -I, then read the chain back and confirm your rule
is where you think it is.
Matching the traffic you meant to match
This is where most hand-written DOCKER-USER rules fail, and the
failure is silent β the rule exists, matches nothing, and the
operator believes the port is filtered.
Upstream states it directly: βwhen packets arrive to the
DOCKER-USER chain, they have already passed through a Destination
Network Address Translation (DNAT) filter.β
Concretely, for a container published as -p 5432:5432:
--dport 5432inDOCKER-USERmatches the container port, which happens to also be 5432 here. Publish it as-p 15432:5432and--dport 15432matches nothing.-d 203.0.113.10(the host address the client connected to) matches nothing at all. By this point the destination is172.17.0.2.
The source address is untouched by DNAT, so -s and -i still work
normally. That is why the canonical upstream rule is written the way
it is:
EXT_IF=eth0
MGMT=192.0.2.0/24
sudo iptables -I DOCKER-USER -i "$EXT_IF" ! -s "$MGMT" -j DROPWhen you need the address or port the client actually asked for, ask conntrack for the pre-DNAT tuple:
sudo iptables -I DOCKER-USER \
-p tcp -m conntrack --ctorigdst 198.51.100.2 --ctorigdstport 80 -j ACCEPT--ctorigdst, --ctorigdstport, --ctorigsrc and --ctorigsrcport
are documented in iptables-extensions(8); check them on your own
host with man iptables-extensions before you rely on the syntax.
The rule does not survive a reboot
iptables -I writes to the running kernel. Nothing writes it to
disk. ufw persists only the rules ufw manages; firewalld persists
only what you marked --permanent. A hand-added DOCKER-USER rule
is written nowhere.
So the exposure comes back at the next reboot β typically during a kernel patch window, months later, with nobody watching.
There are three mechanisms that actually work. Pick one and use it everywhere.
1. netfilter-persistent (Debian, Ubuntu)
sudo apt-get install -y iptables-persistent
sudo netfilter-persistent save
This writes the whole current ruleset to /etc/iptables/rules.v4 and
restores it at boot. It is the least surgical option: the saved file
also contains a snapshot of Dockerβs chains from the moment you
saved, which then has to be reconciled by the daemon at start-up. It
works, but a stale snapshot is easy to accumulate. Re-save after any
Docker version change.
2. A systemd unit tied to the daemon (portable, explicit)
This is the option that expresses the actual dependency: the rules belong to Dockerβs chains, so they should be re-applied whenever Docker starts.
# /etc/systemd/system/docker-user-rules.service
[Unit]
Description=Restrict access to Docker published ports
After=docker.service
BindsTo=docker.service
PartOf=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/docker-user-rules.sh
[Install]
WantedBy=docker.service
The script must be idempotent, because it runs on every daemon start:
#!/bin/sh
# /usr/local/sbin/docker-user-rules.sh
set -eu
EXT_IF=eth0
MGMT=192.0.2.0/24
# Remove our rule if it is already there, then add exactly one copy.
while iptables -C DOCKER-USER -i "$EXT_IF" ! -s "$MGMT" -j DROP 2>/dev/null; do
iptables -D DOCKER-USER -i "$EXT_IF" ! -s "$MGMT" -j DROP
done
iptables -I DOCKER-USER -i "$EXT_IF" ! -s "$MGMT" -j DROP
iptables -C tests for a rule without adding it, which is what makes
the script safe to re-run. Enable it with
sudo systemctl enable --now docker-user-rules.service.
3. firewalld direct rules (RHEL, Fedora, SUSE)
On a firewalld host, firewalld will persist a DOCKER-USER rule for
you if you add it as a permanent direct rule:
sudo firewall-cmd --permanent --direct --add-rule ipv4 filter DOCKER-USER 0 \
-i eth0 '!' -s 192.0.2.0/24 -j DROP
sudo firewall-cmd --reload
sudo firewall-cmd --direct --get-all-rules
The 0 is the priority within the chain; lower numbers are
evaluated first.
- Read the chain first.
iptables -S DOCKER-USER. Know what is already there before you insert. - **Insert with
-I, never append with-A.** A rule below a RETURN is dead. - Match on source and interface, or use conntrack. Destination address and port have already been rewritten by DNAT.
- Confirm the counters move. A DROP rule with zero packets has not been proven to work.
- Persist through one chosen mechanism. netfilter-persistent, a systemd unit ordered after docker.service, or a firewalld permanent direct rule.
- Reboot and re-verify. Both the rule and the external scan.
Sanity check
Knowledge check Β· 4 questions
Q1. A container is published with -p 15432:5432. Which DOCKER-USER match will select that traffic?
Q2. Why does upstream use iptables -I rather than -A for DOCKER-USER rules?
Q3. Which of these will still be in place after the host reboots? Select all that apply.
Q4. Rules you add directly to the DOCKER or DOCKER-FORWARD chains are discarded when the daemon next starts.
Passing score: 75%. Answers are checked in this browser.