iptables on Docker Hosts: Complete Firewall Guide from Zero to Production

Published

No UFW wrapper. No hand-waving. Understand what actually happens to packets on a Docker server — then write rules that survive reboots.

Docker does not bypass the firewall. It becomes the firewall for container traffic. When you publish a port with -p 80:80, Docker inserts DNAT rules in the nat table and ACCEPT rules in filter. A wrapper like UFW often fights this — showing "deny" while Docker still exposes ports.

This guide teaches iptables from first principles on a Docker host. You will:

  1. Remove UFW cleanly (you do not need it)
  2. Learn tables, chains, and rule syntax
  3. See exactly what Docker adds and where
  4. Write production rules for INPUT and DOCKER-USER
  5. Persist everything with iptables-persistent
Reference server (our example throughout):
Ubuntu 22.04 · public interface eth0 · Docker Compose stack · Nginx on ports 80/443 · SSH on port 22 (key-only) · Postgres/Redis internal only · no Swarm

Part 1 — Remove UFW (you do not need it)

UFW (Uncomplicated Firewall) is a front-end that generates iptables rules. On a Docker host it causes three problems:

When UFW is fine: Bare-metal server with no Docker, single admin, simple allow/deny. The moment you run Docker in production, switch to direct iptables + DOCKER-USER.

Step 1 — Make sure you have console access

Before changing firewall rules, confirm you have out-of-band access (VPS web console, IPMI, second SSH session). One mistake on INPUT and you lock yourself out.

Step 2 — Disable and remove UFW

# Check current UFW status
sudo ufw status verbose

# Disable immediately (flushes UFW-managed rules)
sudo ufw disable

# Prevent start on boot
sudo systemctl disable ufw
sudo systemctl stop ufw

# Remove the package entirely (Ubuntu/Debian)
sudo apt remove --purge ufw -y
sudo apt autoremove -y

Step 3 — Verify iptables is the active firewall

# iptables should exist (part of netfilter)
sudo iptables -L -n -v

# Confirm UFW is gone
which ufw          # should return nothing
systemctl status ufw  # should show not found or inactive

After removal, Docker's rules remain. Your INPUT chain may default to ACCEPT — we fix that in Part 10.

Part 2 — iptables fundamentals: tables, chains, packets

iptables is the user-space tool for netfilter, the kernel packet filtering framework. Every network packet crossing your server passes through hooks; iptables rules decide what happens.

The four tables you actually use

filter (default table)

Purpose: Allow or deny packets — the main firewall logic.

Chains: INPUT (to host), OUTPUT (from host), FORWARD (through host to another destination)

Docker uses heavily: FORWARD chain, DOCKER-USER, DOCKER chains

nat

Purpose: Network Address Translation — rewrite source/destination IP or port.

Chains: PREROUTING, POSTROUTING, OUTPUT

Docker uses: DNAT for -p 80:80 (redirect incoming :80 to container IP:80), MASQUERADE for container outbound internet

mangle

Purpose: Packet marking, TTL tweaks, TOS — advanced traffic shaping.

Docker uses: Occasionally for isolation rules. You rarely touch this manually.

raw

Purpose: Connection tracking exemptions (NOTRACK). Rare on Docker hosts unless you know you need it.

iptables -t <table> ...   |   omit -t → defaults to filter

Chains: INPUT, OUTPUT, FORWARD

Internet → [PREROUTING nat] → routing decision
                ↓
         destined for host?  → INPUT chain  → local process (sshd, dockerd)
         destined elsewhere? → FORWARD chain → container / other interface
                ↓
         from local process?  → OUTPUT chain → network

Container → FORWARD → [POSTROUTING nat] → Internet
SSH to host: packet hits INPUT → sshd
curl google.com from host: OUTPUT
Browser → host:443 → Nginx container: PREROUTING (DNAT) → FORWARD → container
Container → api.external.com: FORWARD → POSTROUTING (MASQUERADE)

Rule anatomy — reading one line

iptables -A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW,ESTABLISHED -j ACCEPT
  -A INPUT          Append to INPUT chain
  -p tcp            Protocol TCP only
  --dport 22        Destination port 22
  -m conntrack      Load conntrack module
  --ctstate NEW,ESTABLISHED   Allow new and established connections
  -j ACCEPT         Target: accept packet

Common match flags:

Insert vs append:

Targets: ACCEPT, DROP, REJECT, RETURN

ACCEPT

Let the packet through. Stop processing rules in this chain for this packet.

DROP

Silently discard. Sender gets no response — looks like a black hole. Preferred for hostile traffic (no information leak).

REJECT

Discard and send ICMP "port unreachable" back. Useful for debugging; slightly more informative to scanners.

RETURN

Stop processing this chain, return to parent chain. In DOCKER-USER, RETURN means "I have no opinion — let Docker decide below."

Part 3 — What Docker adds to iptables

When the Docker daemon starts, it programs netfilter automatically. You do not need to manually allow container networking — Docker handles bridge, NAT, and isolation.

Packet flow for published ports

External client connects to your-server:443 where Compose has ports: "443:443":

  1. nat/PREROUTING: Docker DNAT rule rewrites destination to 172.18.0.5:443 (container IP)
  2. filter/FORWARD: Packet enters FORWARD (not INPUT — it's destined for container)
  3. DOCKER-USER: Your custom rules run first
  4. DOCKER-ISOLATION, DOCKER: Docker's rules ACCEPT if port is published
  5. Packet reaches container veth interface
Published port traffic → FORWARD chain (not INPUT)

This is why blocking port 443 in INPUT does nothing for a published Nginx container — the packet never hits INPUT after DNAT.

DOCKER-USER — your main lever

Docker creates an empty chain called DOCKER-USER at the top of FORWARD processing. Official Docker documentation: put your firewall rules here. Docker promises not to modify rules you add to DOCKER-USER.

# See if DOCKER-USER exists (Docker must be running)
sudo iptables -L DOCKER-USER -n -v

# Typical empty chain:
# Chain DOCKER-USER (0 references)
#  pkts bytes target     prot opt in     out     source               destination
# (empty — everything falls through to Docker's rules below)
Golden rule: Restrict traffic to containers in DOCKER-USER. Restrict traffic to the host in INPUT. Never flush DOCKER or DOCKER-ISOLATION chains manually — Docker manages those.

Inspect Docker's rules

# All filter table rules (verbose)
sudo iptables -t filter -L -n -v --line-numbers

# NAT rules — see DNAT for published ports
sudo iptables -t nat -L -n -v --line-numbers

# Find what Docker published on port 443
sudo iptables -t nat -L DOCKER -n -v | grep 443

# List only custom chain
sudo iptables -S DOCKER-USER
sudo iptables -S INPUT

Part 4 — INPUT chain: protect the host itself

INPUT filters packets destined for processes on the host: SSH, monitoring agents, Docker API (if bound to 0.0.0.0:2375 — don't do that), node_exporter, etc.

Containers with published ports are not protected by INPUT — use DOCKER-USER for those.

Recommended INPUT policy

Default policy: DROP (deny everything not explicitly allowed)

Always allow first: loopback, established connections, then specific ports

Order matters: add SSH allow before setting policy DROP

# SAFE ORDER — run while connected via console or existing SSH session

# 1. Allow loopback
sudo iptables -A INPUT -i lo -j ACCEPT

# 2. Allow established/related (return traffic for outbound connections)
sudo iptables -A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT

# 3. Allow SSH (change port if you use non-standard)
sudo iptables -A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW -j ACCEPT

# 4. Allow HTTP/HTTPS TO THE HOST (only if something listens on host, not container)
#    Skip if Nginx is container-only with published ports — those use FORWARD/DOCKER-USER
# sudo iptables -A INPUT -p tcp --dport 80 -j ACCEPT
# sudo iptables -A INPUT -p tcp --dport 443 -j ACCEPT

# 5. Drop ICMP echo requests from internet (optional hardening)
# sudo iptables -A INPUT -p icmp --icmp-type echo-request -j DROP

# 6. Set default policy LAST (after allows are in place)
sudo iptables -P INPUT DROP
sudo iptables -P FORWARD DROP   # Docker will add ACCEPT rules below FORWARD policy
sudo iptables -P OUTPUT ACCEPT  # usually leave OUTPUT open on servers
Our reference server: Nginx runs in Docker with ports: "80:80", "443:443". We do not open 80/443 in INPUT — traffic goes through FORWARD after DNAT. INPUT only needs SSH (22) + established + loopback.

Part 5 — DOCKER-USER: control access to containers

By default, any published port is open to the entire internet. DOCKER-USER lets you restrict by source IP, block specific ports, or add logging.

Restrict a published port by IP

Example: admin panel on :8080 published in Compose — only allow your office CIDR 10.50.0.0/24:

# Allow established connections first (always)
sudo iptables -I DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN

# Allow your office network to any container port
sudo iptables -I DOCKER-USER -p tcp -s 10.50.0.0/24 -j RETURN

# Block everyone else from reaching port 8080 on containers
sudo iptables -A DOCKER-USER -p tcp --dport 8080 -j DROP

# Default: let Docker handle everything else
sudo iptables -A DOCKER-USER -j RETURN

Public web (80/443) stays open because no DROP rule matches — Docker's ACCEPT rules below handle them.

Rate-limit SSH brute force on host (INPUT, not DOCKER-USER)

sudo iptables -A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW \
  -m recent --set --name SSH

sudo iptables -A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW \
  -m recent --update --seconds 60 --hitcount 4 --name SSH -j DROP

Block container outbound traffic (optional)

Prevent a compromised container from calling arbitrary internet hosts — allow only DNS and your API endpoints:

# Block container-initiated outbound by default (advanced — test carefully)
# sudo iptables -I DOCKER-USER -i br-+ -o eth0 -j DROP

# Better: use internal networks + no published ports for DB, egress proxy, or network policies

Outbound filtering breaks many images (apt update, external APIs). Use only when you have a clear egress allowlist.

Part 6 — NAT table: how port publishing works

When you run docker compose up with ports: "443:443", Docker adds rules like:

# Simplified — actual rules vary by Docker version
-A PREROUTING -p tcp -m tcp --dport 443 -j DOCKER
-A DOCKER -d 172.18.0.0/16 -p tcp --dport 443 -j DNAT --to-destination 172.18.0.5:443
-A POSTROUTING -s 172.18.0.0/16 ! -o docker0 -j MASQUERADE

Do not edit DOCKER chain in nat table manually

Docker recreates these on every container start/stop. To "close" a published port — remove it from Compose or block in DOCKER-USER filter chain.

Verify DNAT for a running stack:

docker ps --format '{{.Names}} {{.Ports}}'
sudo iptables -t nat -L DOCKER -n -v --line-numbers

Part 7 — Hardening: invalid TCP flags (mangle table)

Before packets reach filter/NAT logic, you can drop malformed TCP packets in the mangle table. These rules sit in PREROUTING — they run on every incoming TCP packet, including traffic eventually forwarded to Docker containers.

Why bother? Port scanners (nmap, masscan) and some attack tools deliberately send packets with impossible flag combinations. Valid TCP stacks never produce them. Dropping them early saves conntrack memory and keeps your logs clean.

What TCP flags are

Every TCP segment has a 6-bit flags field in its header. iptables can match on these single-letter flags:

Flag   Name         What it means in real TCP
────   ──────────   ─────────────────────────────────────────────────────
SYN    Synchronize  "I want to open a connection" — first step of 3-way handshake
ACK    Acknowledge  "I received data" — present in almost all packets after handshake
FIN    Finish       "I am done sending" — graceful connection close
RST    Reset        "Abort this connection immediately" — error or rejected port
PSH    Push         "Deliver this data to the app now" — don't buffer (common with HTTP)
URG    Urgent       Rare — out-of-band urgent pointer (almost never seen in normal traffic)

A legitimate TCP conversation looks like this:

  1. Client → Server: SYN (only SYN set — "hello, open a connection?")
  2. Server → Client: SYN+ACK ("yes, acknowledged")
  3. Client → Server: ACK (connection established — data flows)
  4. Close: FIN+ACK from either side, answered with ACK
  5. Problem: RST instead of FIN when something goes wrong or port is closed

How --tcp-flags MASK MATCH works

Every hardening rule uses this pattern:

iptables ... --tcp-flags MASK MATCH ...
Example: --tcp-flags SYN,RST SYN,RST
Look at SYN and RST only. Rule matches when both are set. SYN=0 or RST=0 → no match.

Example: --tcp-flags FIN,SYN,RST,PSH,ACK,URG NONE
Look at all six flags. MATCH is NONEnone of them may be set. This is a NULL packet.

Normal vs malicious packets

Type              │ Flags set        │ Legitimate? │ Who sends it
──────────────────┼──────────────────┼─────────────┼──────────────────────────
SYN (open)        │ SYN              │ Yes         │ Browser, curl, ssh client
SYN-ACK (reply)   │ SYN + ACK        │ Yes         │ Your web server, sshd
Data / ACK        │ ACK (+ maybe PSH)│ Yes         │ Any established connection
Graceful close    │ FIN + ACK        │ Yes         │ Normal teardown
Reset             │ RST (often +ACK) │ Yes*        │ Closed port, rejected conn
NULL scan         │ (none)           │ No          │ nmap -sN stealth scan
FIN scan          │ FIN only         │ No          │ nmap -sF (no handshake)
Xmas scan         │ FIN+PSH+URG all  │ No          │ nmap -sX ("lit up like Christmas")
SYN+FIN           │ SYN + FIN        │ No          │ Invalid — can't open and close at once
SYN+RST           │ SYN + RST        │ No          │ Invalid — open and abort together

*RST alone can be legitimate; the hardening rules target combinations that RFC 793 and real stacks never emit together.

Each hardening rule explained

All rules below belong in *manglePREROUTING. Target is always -j DROP (silent discard).

Rule 1 — NULL scan (no flags)

-A PREROUTING -p tcp -m tcp --tcp-flags FIN,SYN,RST,PSH,ACK,URG NONE -j DROP

What it matches: TCP segments where SYN, ACK, FIN, RST, PSH, and URG are all zero.

Real-world source: nmap NULL scan (nmap -sN), some IDS evasion tools.

Why invalid: Every real TCP packet has at least one flag set (usually SYN or ACK). A packet with zero flags is not part of any protocol conversation.

Effect: Scanner gets no response — port appears "filtered."

Rule 2 — SYN+FIN together

-A PREROUTING -p tcp -m tcp --tcp-flags FIN,SYN FIN,SYN -j DROP

What it matches: Both SYN and FIN bits set in the same packet.

Real-world source: Crafted packets from security scanners, some DDoS tools.

Why invalid: SYN means "start connection"; FIN means "end connection". A stack cannot logically do both in one segment. RFC 793 never defines this combination for valid traffic.

Effect: Blocks a classic "invalid flag" probe used to fingerprint firewalls and hosts.

Rule 3 — SYN+RST together

-A PREROUTING -p tcp -m tcp --tcp-flags SYN,RST SYN,RST -j DROP

What it matches: SYN and RST both set.

Real-world source: Automated vulnerability scanners, malformed exploit attempts.

Why invalid: SYN invites a new connection; RST kills a connection. No legitimate OS sends both simultaneously — you either open or abort, not both at once.

Effect: Drops packets that could confuse stateful firewalls or pollute conntrack tables.

Rule 4 — FIN+RST together

-A PREROUTING -p tcp -m tcp --tcp-flags FIN,RST FIN,RST -j DROP

What it matches: FIN and RST both set.

Real-world source: Scan tools probing edge-case stack behaviour.

Why invalid: FIN is graceful shutdown ("no more data"); RST is immediate abort. A real close uses one or the other, not both.

Rule 5 — FIN without ACK (stealth FIN scan)

-A PREROUTING -p tcp -m tcp --tcp-flags FIN,ACK FIN -j DROP

What it matches: FIN is set, ACK is not set (mask includes ACK but MATCH lists only FIN).

Real-world source: nmap FIN scan (nmap -sF) — sends FIN alone to map closed vs open|filtered ports on some OS types.

Why suspicious: In established TCP, FIN always arrives with ACK. Bare FIN without prior handshake is not normal client/server traffic.

Rule 6 — URG without ACK

-A PREROUTING -p tcp -m tcp --tcp-flags ACK,URG URG -j DROP

What it matches: URG set, ACK unset.

Real-world source: Old scan techniques, almost never legitimate on modern internet traffic.

Why invalid: URG flag is virtually unused in modern HTTP/TLS/SSH. When seen alone without ACK, it is almost always crafted.

Rule 7 — PSH without ACK

-A PREROUTING -p tcp -m tcp --tcp-flags PSH,ACK PSH -j DROP

What it matches: PSH (push) set, ACK not set.

Why invalid: PSH means "push data to application". Data segments in an established flow always include ACK. PSH alone with no ACK implies no valid connection state.

Rule 8 — Xmas scan (all flags set)

-A PREROUTING -p tcp -m tcp --tcp-flags FIN,SYN,RST,PSH,ACK,URG FIN,SYN,RST,PSH,ACK,URG -j DROP

What it matches: All six flags set at once — "lit up like a Christmas tree."

Real-world source: nmap Xmas scan (nmap -sX).

Why invalid: No TCP implementation sets every flag simultaneously. Pure reconnaissance packet.

Rules 9–11 — Other impossible multi-flag combos

-A PREROUTING -p tcp -m tcp --tcp-flags FIN,SYN,RST,PSH,ACK,URG FIN,PSH,URG -j DROP
-A PREROUTING -p tcp -m tcp --tcp-flags FIN,SYN,RST,PSH,ACK,URG FIN,SYN,PSH,URG -j DROP
-A PREROUTING -p tcp -m tcp --tcp-flags FIN,SYN,RST,PSH,ACK,URG FIN,SYN,RST,ACK,URG -j DROP

What they match: Variations of FIN+PSH+URG, FIN+SYN+PSH+URG, FIN+SYN+RST+ACK+URG — none are defined in RFC 793 for valid communication.

Real-world source: Advanced port scanners, firewall fingerprinting scripts.

Why keep them: Defense in depth — each catches a slightly different crafted bitmask that simpler rules might miss.

These rules do NOT block normal traffic. A browser opening HTTPS sends SYN → your server replies SYN+ACK → data with ACK+PSH. None of the dropped combinations appear in that flow. If something breaks after adding mangle rules, it is not legitimate HTTP/SSH — investigate with iptables -t mangle -L PREROUTING -n -v counters.

Apply live (test before persisting):

sudo iptables -t mangle -A PREROUTING -p tcp -m tcp --tcp-flags SYN,RST SYN,RST -j DROP
# ... add remaining lines from Part 8 template ...

# Watch counters increment when scanners hit your server
sudo iptables -t mangle -L PREROUTING -n -v --line-numbers

Part 8 — Example rules.v4 (full production file)

The file /etc/iptables/rules.v4 uses the exact format produced by iptables-save. You author it by hand (or copy the template below), replace every placeholder with your own ports and networks, then load it with iptables-persistent.

Important: All ports, ipset names, and IP ranges in this template are fictional examples. Never copy someone else's port numbers or allowlists into production — map each rule to a service you actually run.

Custom chains — CHECK_INPUT and CHECK_OUTPUT

Instead of one long INPUT chain, traffic jumps to named sub-chains:

# How jumping works:
-A INPUT -j CHECK_INPUT      # every inbound packet enters CHECK_INPUT first
# ... rules inside CHECK_INPUT ...
-A CHECK_INPUT -j DROP      # fail-safe at end of custom chain

ipset allowlist for restricted ports

ipset stores thousands of IP/CIDR entries efficiently. Use it when one port must not be public — staging site, internal API, VPN-only admin panel, etc.

Pattern (two lines per restricted port):

  1. ACCEPT if source IP is in the set
  2. DROP everyone else on that port
Placeholders — replace before use:
RESTRICTED_PORT → port of your non-public service (e.g. internal app on 9000)
trusted_nets → name of your ipset (pick any name you like)
10.50.0.0/24 → your office/VPN CIDR — not a real customer network from this guide
# Create ipset once (before loading rules.v4, or via systemd on boot)
sudo apt install ipset -y
sudo ipset create trusted_nets hash:net family inet hashsize 4096 maxelem 65536
sudo ipset add trusted_nets 10.50.0.0/24
sudo ipset add trusted_nets 192.0.2.10/32

sudo ipset list trusted_nets

Do not confuse restricted ports with public web:

Docker — where 80/443 rules go

If Nginx/your app runs inside Docker with ports: "80:80", "443:443", web traffic is DNAT'd and hits FORWARD + DOCKER-USER — not INPUT. Do not add 80/443 to CHECK_INPUT for containerized web; Docker handles it.

If a process listens on the host (no Docker publish), add 80/443 to CHECK_INPUT as in the template.

Full example — /etc/iptables/rules.v4

Structure mirrors a typical hardened host: mangle flag drops → filter with custom chains → ipset-gated port → public web → SSH → DOCKER-USER.

# /etc/iptables/rules.v4 — TEMPLATE ONLY
# Replace: RESTRICTED_PORT, trusted_nets, SSH_PORT, and CIDRs below

*mangle
:PREROUTING ACCEPT [0:0]
:INPUT ACCEPT [0:0]
:FORWARD ACCEPT [0:0]
:OUTPUT ACCEPT [0:0]
:POSTROUTING ACCEPT [0:0]

# Part 7 — invalid TCP flag drops (see each rule explained there)
-A PREROUTING -p tcp -m tcp --tcp-flags FIN,SYN,RST,PSH,ACK,URG NONE -j DROP
-A PREROUTING -p tcp -m tcp --tcp-flags FIN,SYN FIN,SYN -j DROP
-A PREROUTING -p tcp -m tcp --tcp-flags SYN,RST SYN,RST -j DROP
-A PREROUTING -p tcp -m tcp --tcp-flags FIN,RST FIN,RST -j DROP
-A PREROUTING -p tcp -m tcp --tcp-flags FIN,ACK FIN -j DROP
-A PREROUTING -p tcp -m tcp --tcp-flags ACK,URG URG -j DROP
-A PREROUTING -p tcp -m tcp --tcp-flags PSH,ACK PSH -j DROP
-A PREROUTING -p tcp -m tcp --tcp-flags FIN,SYN,RST,PSH,ACK,URG FIN,SYN,RST,PSH,ACK,URG -j DROP
-A PREROUTING -p tcp -m tcp --tcp-flags FIN,SYN,RST,PSH,ACK,URG FIN,PSH,URG -j DROP
-A PREROUTING -p tcp -m tcp --tcp-flags FIN,SYN,RST,PSH,ACK,URG FIN,SYN,PSH,URG -j DROP
-A PREROUTING -p tcp -m tcp --tcp-flags FIN,SYN,RST,PSH,ACK,URG FIN,SYN,RST,ACK,URG -j DROP
COMMIT

*filter
:INPUT DROP [0:0]
:FORWARD DROP [0:0]
:OUTPUT ACCEPT [0:0]
:CHECK_INPUT - [0:0]
:CHECK_OUTPUT - [0:0]
:DOCKER-USER - [0:0]

-A INPUT -j CHECK_INPUT
-A OUTPUT -j CHECK_OUTPUT

# --- CHECK_INPUT ---

# [A] Restricted port — only ipset members (staging / internal app / private API)
#     RESTRICTED_PORT = your non-public service port (example: 9000)
-A CHECK_INPUT -p tcp -m tcp --dport 9000 -m set --match-set trusted_nets src -j ACCEPT
-A CHECK_INPUT -p tcp -m tcp --dport 9000 -j DROP

# [B] Return traffic for connections this host already opened
-A CHECK_INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT

# [C] Loopback (localhost services talking to each other)
-A CHECK_INPUT -i lo -j ACCEPT

# [D] Public web ON HOST ONLY — skip this block if web runs in Docker
-A CHECK_INPUT -p tcp -m tcp --dport 80 -j ACCEPT
-A CHECK_INPUT -p tcp -m tcp --dport 443 -j ACCEPT
-A CHECK_INPUT -p udp -m udp --dport 443 -j ACCEPT

# [E] SSH — adjust SSH_PORT (example: 22). Optional: add ipset like [A]
-A CHECK_INPUT -p tcp -m tcp --dport 22 -j ACCEPT

# [F] ICMP ping (optional)
-A CHECK_INPUT -p icmp --icmp-type echo-request -j ACCEPT

# [G] Fail-safe — drop everything else hitting the host network stack
-A CHECK_INPUT -j DROP

# --- CHECK_OUTPUT ---
-A CHECK_OUTPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A CHECK_OUTPUT -j ACCEPT

# --- DOCKER-USER (container traffic via published ports) ---
-A DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN
# Example: restrict published admin port to same ipset
# -A DOCKER-USER -p tcp -m tcp --dport 8080 ! -m set --match-set trusted_nets src -j DROP
-A DOCKER-USER -j RETURN

COMMIT

Mapping rules to services (read before deploying)

Rule block │ Placeholder      │ Typical use
───────────┼──────────────────┼────────────────────────────────────────────
[A]        │ dport 9000       │ Non-public app — VPN/office IP only
[B]        │ conntrack        │ Responses to your outbound curl/apt/SSH
[C]        │ lo               │ 127.0.0.1 local services
[D]        │ 80, 443 tcp/udp  │ Public website on HOST (omit if Docker web)
[E]        │ dport 22         │ SSH admin access
[F]        │ icmp             │ Optional ping for monitoring
[G]        │ DROP             │ Default deny for anything not matched
DOCKER-USER│ dport 8080 etc.  │ Restrict published CONTAINER ports
Load this file:
sudo iptables-restore --test /etc/iptables/rules.v4
sudo netfilter-persistent reload
Test SSH and every public service from a second session before closing your terminal.

Legacy note: Older configs use -m state --state RELATED,ESTABLISHED. Modern kernels prefer -m conntrack --ctstate RELATED,ESTABLISHED.

Part 9 — Shell script ruleset (quick apply)

Full script for our reference server. Save as /usr/local/sbin/docker-host-firewall.sh:

#!/bin/bash
# docker-host-firewall.sh — reference production rules
# Interface: eth0 | SSH: 22 | Public web via Docker :80 :443

set -euo pipefail

PUB_IF="eth0"
OFFICE_CIDR="10.50.0.0/24"   # replace with your VPN/office range
SSH_PORT="22"

# --- INPUT: protect the host ---
iptables -P INPUT ACCEPT    # temporary while building rules
iptables -F INPUT

iptables -A INPUT -i lo -j ACCEPT
iptables -A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
iptables -A INPUT -p tcp --dport "$SSH_PORT" -m conntrack --ctstate NEW -j ACCEPT
iptables -A INPUT -p icmp --icmp-type echo-request -j ACCEPT  # optional: allow ping host
iptables -P INPUT DROP

# --- OUTPUT: allow (typical server) ---
iptables -P OUTPUT ACCEPT

# --- FORWARD: Docker needs this ---
iptables -P FORWARD DROP    # Docker inserts ACCEPT rules for published ports

# --- DOCKER-USER: restrict container access ---
# Flush only DOCKER-USER — never flush DOCKER or FORWARD entirely
iptables -F DOCKER-USER 2>/dev/null || true

iptables -A DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN

# Example: admin UI on :8080 — office IP only
iptables -A DOCKER-USER -p tcp --dport 8080 ! -s "$OFFICE_CIDR" -j DROP

# Example: block access to Docker metrics port if accidentally published
iptables -A DOCKER-USER -p tcp --dport 9100 -j DROP

iptables -A DOCKER-USER -j RETURN

echo "Firewall rules applied. Verify with: iptables -L -n -v"
sudo chmod +x /usr/local/sbin/docker-host-firewall.sh
sudo /usr/local/sbin/docker-host-firewall.sh
What this achieves:
Host: only SSH + loopback + established inbound
Containers: 80/443 public (Docker default), 8080 admin IP-locked, 9100 blocked
Postgres/Redis: no published ports → not reachable from internet at all

Part 10 — Walkthrough: configure from zero

Fresh Ubuntu 22.04 VPS with Docker already installed and stack running.

Step 1 — Remove UFW

sudo ufw disable
sudo apt remove --purge ufw -y

Step 2 — Open a second SSH session

Keep one terminal connected while testing. If rules lock you out, fix from console.

Step 3 — Inspect current state

sudo iptables -L -n -v --line-numbers
sudo iptables -t nat -L -n -v --line-numbers
docker ps

Step 4 — Apply INPUT rules (SSH first)

sudo iptables -A INPUT -i lo -j ACCEPT
sudo iptables -A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 22 -j ACCEPT
sudo iptables -P INPUT DROP

Step 5 — Test SSH in second session

ssh user@your-server   # must still work

Step 6 — Add DOCKER-USER rules

sudo iptables -A DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN
# add restrictions as needed
sudo iptables -A DOCKER-USER -j RETURN

Step 7 — Test web from outside

curl -I https://your-domain.com     # should work (FORWARD/DNAT)
curl -I http://your-server:8080       # should fail if IP-restricted

Step 8 — Persist (Part 11)

sudo apt install iptables-persistent -y
sudo netfilter-persistent save

Part 11 — iptables-persistent: rules.v4, reload, reboot

Rules added with iptables on the CLI are volatile — reboot clears them. The durable workflow is: maintain /etc/iptables/rules.v4 (and rules.v6 for IPv6), then load via iptables-persistent / netfilter-persistent.

Install and first save

# Ubuntu / Debian
sudo apt update
sudo apt install iptables-persistent ipset -y

# During install it may ask to save current IPv4/IPv6 rules — say Yes
# if you already applied your production rules

Files on disk:

/etc/iptables/rules.v4    # IPv4 — iptables-save format
/etc/iptables/rules.v6    # IPv6 — ip6tables-save format

Write /etc/iptables/rules.v4 by hand

You do not have to build rules live first. Create the file directly (use Part 8 template), then load it:

# 1. Create or edit the file
sudo mkdir -p /etc/iptables
sudo nano /etc/iptables/rules.v4

# 2. Test syntax WITHOUT applying (dry run)
sudo iptables-restore --test /etc/iptables/rules.v4
# Older systems:
sudo iptables-restore -t < /etc/iptables/rules.v4

# 3. If test passes, apply live
sudo iptables-restore < /etc/iptables/rules.v4

File format rules:

After editing, save from live rules (optional — merges current kernel state into file):

sudo iptables-save | sudo tee /etc/iptables/rules.v4
sudo ip6tables-save | sudo tee /etc/iptables/rules.v6
Prefer hand-authored rules.v4 for Docker hosts: copy the Part 8 template, edit ports/ipset, load with restore. Avoid blind iptables-save after containers run — stale container IPs get baked in.

Reload and restart the service

Once /etc/iptables/rules.v4 exists, use the persistent service to load it — no reboot required:

# Apply rules.v4 + rules.v6 from disk (preferred)
sudo netfilter-persistent reload

# Equivalent: restart the service
sudo systemctl restart netfilter-persistent

# Check service status
sudo systemctl status netfilter-persistent

# Enable load on every boot
sudo systemctl enable netfilter-persistent

What each command does:

# Typical edit cycle
sudo nano /etc/iptables/rules.v4
sudo iptables-restore --test /etc/iptables/rules.v4
sudo netfilter-persistent reload
# verify SSH + web, then commit file to git/ansible

Warning: reload replaces live rules. Docker's dynamic chains may disappear until Docker daemon re-inserts them — keep a second SSH session open and run reload when Docker is running, or follow with systemctl restart docker if FORWARD breaks.

Docker + reboot ordering

On boot, two things happen:

  1. netfilter-persistent loads /etc/iptables/rules.v4 (your INPUT + DOCKER-USER rules)
  2. Docker daemon starts and inserts DOCKER, NAT, FORWARD rules

What to save in rules.v4:

What happens if you save everything blindly:

sudo iptables-save > /etc/iptables/rules.v4   # includes stale container IPs

Container IPs change after recreate. Stale DNAT rules in rules.v4 can break networking. Save only your custom chains or use the script approach below.

systemd unit to re-apply DOCKER-USER after Docker starts

Most reliable pattern: keep custom rules in a script, run it after Docker is up.

# /usr/local/sbin/docker-host-firewall.sh  (from Part 7)

# /etc/systemd/system/docker-firewall.service
[Unit]
Description=Apply DOCKER-USER and INPUT firewall rules
After=docker.service network-online.target
Wants=network-online.target
Requires=docker.service

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/docker-host-firewall.sh

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable docker-firewall.service
sudo systemctl start docker-firewall.service
sudo systemctl status docker-firewall.service

Now even if DOCKER-USER is recreated empty on Docker restart, your unit re-applies restrictions.

iptables-persistent vs systemd script — use both

iptables-persistent: INPUT policy, base rules, survive reboot before Docker starts

docker-firewall.service: DOCKER-USER rules after Docker creates the chain

Workflow: edit script → run script → netfilter-persistent save → enable systemd unit

Editing rules.v4 by hand

Format is exactly iptables-save output:

# Generated by iptables-save
*filter
:INPUT DROP [0:0]
:FORWARD DROP [0:0]
:OUTPUT ACCEPT [0:0]
:DOCKER-USER - [0:0]
-A INPUT -i lo -j ACCEPT
-A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT
-A DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN
-A DOCKER-USER -p tcp -m tcp --dport 8080 ! -s 10.50.0.0/24 -j DROP
-A DOCKER-USER -j RETURN
COMMIT
# After editing:
sudo netfilter-persistent reload

Disable iptables management by Docker (not recommended)

# /etc/docker/daemon.json
{ "iptables": false }

Docker will not touch iptables. You must manually configure bridge, NAT, and FORWARD for every network. Only for advanced users with custom networking. Leave default true for normal production.

Part 12 — IPv6 (rules.v6 / ip6tables)

If your server has a public IPv6 address, containers may be reachable over IPv6 too. Mirror your IPv4 policy in /etc/iptables/rules.v6:

# Check IPv6 rules
sudo ip6tables -L -n -v

# Mirror INPUT rules for IPv6
sudo ip6tables -A INPUT -i lo -j ACCEPT
sudo ip6tables -A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
sudo ip6tables -A INPUT -p tcp --dport 22 -j ACCEPT
sudo ip6tables -P INPUT DROP

# Save
sudo netfilter-persistent save

If you do not use IPv6, disable it at the provider level or set enable_ipv6: false in Docker daemon.json — but still consider locking ip6tables INPUT to DROP.

Part 13 — Troubleshooting

Symptom                              │ Likely cause                         │ Fix
─────────────────────────────────────┼──────────────────────────────────────┼──────────────────────────────
Locked out of SSH                    │ INPUT DROP before SSH allow          │ Fix via VPS console; add port 22 first
Website works, UFW said deny         │ UFW ≠ Docker FORWARD path            │ Remove UFW; use DOCKER-USER
Published port open to world         │ No DOCKER-USER restrict rule         │ Add DROP/limit in DOCKER-USER
Can't reach container after reboot   │ Stale rules.v4 with old container IP │ Don't save DOCKER nat chain; use script
DOCKER-USER rules gone after restart │ Docker recreated chain               │ systemd docker-firewall.service
Container can't reach internet       │ FORWARD policy DROP, no Docker ACCEPT│ Ensure Docker running; don't flush FORWARD
Rules exist but no effect            │ Wrong chain (INPUT vs FORWARD)       │ Published ports use FORWARD/DOCKER-USER
iptables-restore test fails             │ Syntax error in rules.v4             │ Fix line; check *table and COMMIT
reload broke Docker forwarding         │ Flushed Docker chains                │ restart docker; use hand-authored v4
ip6tables bypass                     │ IPv6 open, only locked IPv4          │ Mirror rules in ip6tables

Useful debug commands

# Watch counters increase when you hit a rule
sudo iptables -L DOCKER-USER -n -v --line-numbers

# Trace packet path (kernel 4.x+, module nf_tables trace)
# Or add LOG target temporarily:
sudo iptables -I DOCKER-USER -j LOG --log-prefix "DOCKER-USER: "
sudo dmesg -w

# Remove LOG rule when done
sudo iptables -D DOCKER-USER -j LOG --log-prefix "DOCKER-USER: "

Part 14 — Production checklist

  1. Remove UFW — single firewall tool: iptables
  2. Console access confirmed before changing INPUT policy
  3. INPUT: loopback → established → SSH → policy DROP
  4. DOCKER-USER: established → restrictions → RETURN
  5. Do not flush Docker-managed chains (DOCKER, nat/DOCKER)
  6. Script in /usr/local/sbin/ for repeatable rules
  7. Part 8 template — mangle hardening + CHECK_INPUT + DOCKER-USER in rules.v4
  8. ipset for restricted ports only — public web stays open; use fictional names in templates
  9. iptables-restore --test before every reload
  10. netfilter-persistent reload after editing rules.v4 (no reboot needed)
  11. systemd unit after docker.service for DOCKER-USER
  12. Test SSH, HTTPS, and blocked ports after every change
  13. ip6tables if IPv6 is enabled
  14. Document which Compose ports are public vs IP-restricted
  15. Never expose Docker API (2375) or unpublished DB ports

Wrapping up

UFW is a wrapper you do not need on a Docker server. Docker owns container networking through iptables — your job is to add policy in the right places: INPUT for the host, DOCKER-USER for containers, and iptables-persistent + a post-Docker script so rules survive reboots.

Start by removing UFW, inspect iptables -L -n -v with Docker running, apply INPUT for SSH, add DOCKER-USER restrictions for sensitive published ports, save with netfilter-persistent save, and enable a systemd oneshot after docker.service. That is a production-grade firewall on a Docker host.