Skip to main content
RunBook Academy

Docker & ContainersXXVII Β· FirewallsDOCKER-USER

The DOCKER-USER chain, and making rules survive a reboot

Advanced⏱ ~24 min

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

Not yet marked complete on this device.

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:

ChainPurpose
DOCKER-USERYours. Evaluated before Docker’s own filter rules.
DOCKER-FORWARDFirst-stage handling; lets established connections through and dispatches the rest.
DOCKERAccepts traffic to published ports.
DOCKER-BRIDGE, DOCKER-INTERNALPer-network accept and isolation rules.
DOCKER-CTPer-bridge connection-tracking rules.
DOCKER-INGRESSSwarm ingress routing mesh.

FORWARD contains unconditional jumps to DOCKER-USER, DOCKER-FORWARD and DOCKER-INGRESS, in that order, at the top of the chain.

Read-only / Safesee the chains
sudo iptables -S FORWARD
sudo iptables -S DOCKER-USER
sudo iptables -t nat -S DOCKER

Insert, 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 5432 in DOCKER-USER matches the container port, which happens to also be 5432 here. Publish it as -p 15432:5432 and --dport 15432 matches nothing.
  • -d 203.0.113.10 (the host address the client connected to) matches nothing at all. By this point the destination is 172.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:

Destructiverestrict by source
EXT_IF=eth0
MGMT=192.0.2.0/24
sudo iptables -I DOCKER-USER -i "$EXT_IF" ! -s "$MGMT" -j DROP

When you need the address or port the client actually asked for, ask conntrack for the pre-DNAT tuple:

Destructivematch the published port
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.

  1. Read the chain first. iptables -S DOCKER-USER. Know what is already there before you insert.
  2. **Insert with -I, never append with -A.** A rule below a RETURN is dead.
  3. Match on source and interface, or use conntrack. Destination address and port have already been rewritten by DNAT.
  4. Confirm the counters move. A DROP rule with zero packets has not been proven to work.
  5. Persist through one chosen mechanism. netfilter-persistent, a systemd unit ordered after docker.service, or a firewalld permanent direct rule.
  6. Reboot and re-verify. Both the rule and the external scan.

Sanity check

Knowledge check Β· 4 questions

  1. Q1. A container is published with -p 15432:5432. Which DOCKER-USER match will select that traffic?

  2. Q2. Why does upstream use iptables -I rather than -A for DOCKER-USER rules?

  3. Q3. Which of these will still be in place after the host reboots? Select all that apply.

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