iptables on Docker Hosts: Complete Firewall Guide from Zero to Production
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:
- Remove UFW cleanly (you do not need it)
- Learn tables, chains, and rule syntax
- See exactly what Docker adds and where
- Write production rules for INPUT and DOCKER-USER
- Persist everything with
iptables-persistent
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:
- Conflict: Docker rewrites
FORWARDpolicy and inserts its own chains. UFW's rules may not apply to published container ports. - False sense of security:
ufw deny 8080does not block a container published with-p 8080:8080— Docker's DNAT happens before UFW's logic in many setups. - Double management: You debug UFW while the real rules live in iptables. One tool is enough: iptables directly.
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.
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
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:
-i eth0— incoming interface-o docker0— outgoing interface-s 10.50.0.0/24— source IP or CIDR (example range — use yours)-d 10.0.0.0/8— destination CIDR-m conntrack --ctstate RELATED,ESTABLISHED— allow return traffic for open connections-m limit --limit 5/min --limit-burst 10— rate limiting
Insert vs append:
-A(append) — add rule at end of chain-I(insert) — add at position 1 (or-I CHAIN 3for line 3). Rules are evaluated top to bottom — first match wins.-D(delete) — remove a rule-F(flush) — delete all rules in chain (dangerous)-P(policy) — set default policy for chain: ACCEPT or DROP
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":
- nat/PREROUTING: Docker DNAT rule rewrites destination to
172.18.0.5:443(container IP) - filter/FORWARD: Packet enters FORWARD (not INPUT — it's destined for container)
- DOCKER-USER: Your custom rules run first
- DOCKER-ISOLATION, DOCKER: Docker's rules ACCEPT if port is published
- Packet reaches container veth interface
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
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:
- Client → Server: SYN (only SYN set — "hello, open a connection?")
- Server → Client: SYN+ACK ("yes, acknowledged")
- Client → Server: ACK (connection established — data flows)
- Close: FIN+ACK from either side, answered with ACK
- 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:
- MASK — which flags to look at (comma-separated list)
- MATCH — which of those masked flags must be 1 (set) for the rule to fire
- Flags in MASK but not listed in MATCH must be 0 (unset)
--tcp-flags SYN,RST SYN,RSTLook 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 NONELook at all six flags. MATCH is
NONE → none 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 *mangle → PREROUTING. 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:
- CHECK_INPUT — all inbound policy: which ports are open, who may connect, what to drop
- CHECK_OUTPUT — outbound policy (most servers: allow everything out)
- Why separate? Readable blocks, easy to reorder rules, flush CHECK_INPUT without touching Docker-related jumps at the top of INPUT
# 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):
ACCEPTif source IP is in the setDROPeveryone else on that port
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:
- Public website (80/443) → open to
0.0.0.0/0— no ipset needed - Restricted service (custom port) → ipset allowlist + DROP for the rest
- SSH → often port 22 open to all (with key auth), or move to non-standard port + ipset if you want extra lockdown
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
sudo iptables-restore --test /etc/iptables/rules.v4sudo netfilter-persistent reloadTest 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
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:
- Each table starts with
*filter,*nat, or*mangleand ends withCOMMIT - Chain policies:
:INPUT DROP [0:0]sets default policy - Rules look like CLI output:
-A INPUT ... - Order matters — same top-to-bottom evaluation as live iptables
- Save only your static rules — not Docker's dynamic DOCKER/NAT entries (Docker recreates those)
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:
netfilter-persistent reload— flushes and reloads from/etc/iptables/rules.v4andrules.v6netfilter-persistent save— writes current live rules back to those filessystemctl restart netfilter-persistent— same reload on boot-style path
# 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:
netfilter-persistentloads/etc/iptables/rules.v4(your INPUT + DOCKER-USER rules)- Docker daemon starts and inserts DOCKER, NAT, FORWARD rules
What to save in rules.v4:
- INPUT chain rules and policy
- DOCKER-USER chain rules
- FORWARD policy (DROP is fine — Docker adds ACCEPT below)
- Do not rely on saving Docker's dynamic DOCKER chain rules — Docker recreates them when containers start
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
- Remove UFW — single firewall tool: iptables
- Console access confirmed before changing INPUT policy
- INPUT: loopback → established → SSH → policy DROP
- DOCKER-USER: established → restrictions → RETURN
- Do not flush Docker-managed chains (DOCKER, nat/DOCKER)
- Script in
/usr/local/sbin/for repeatable rules - Part 8 template — mangle hardening + CHECK_INPUT + DOCKER-USER in rules.v4
- ipset for restricted ports only — public web stays open; use fictional names in templates
- iptables-restore --test before every reload
- netfilter-persistent reload after editing rules.v4 (no reboot needed)
- systemd unit after docker.service for DOCKER-USER
- Test SSH, HTTPS, and blocked ports after every change
- ip6tables if IPv6 is enabled
- Document which Compose ports are public vs IP-restricted
- 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.