iptables on Docker Hosts: Complete Firewall Guide from Zero to Production
45 min read
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
How to read this guide (start here)
Every code block has a colored label. Do only what the label says.
- Read Diagram, formula, or explanation — do not paste into a terminal.
- Copy file Create this file on disk (path is in the label or first comment). Edit values for your environment.
- Run Paste into a shell on the machine this section describes.
- SQL Paste into a database client (
mariadb/mysql/ ProxySQL admin), not bash. - Sample output A realistic excerpt immediately after an observation command; compare fields and read the Meaning note before continuing.
Follow parts in order unless a part says you can skip it. Change example IPs, passwords, and paths to match your lab.
Suggested path: Part 2 basics → Part 3 Docker chains → Part 10 walkthrough → Parts 8–9 templates.
Part 1 — Remove UFW (you do not need it)
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
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
Sample output — ufw status verbose before removal
Status: active Logging: on (low) Default: deny (incoming), allow (outgoing), disabled (routed) 22/tcp ALLOW IN Anywhere
Meaning: UFW default-denies host INPUT, allows outbound, and has routed filtering disabled; SSH is open. This snapshot does not prove Docker-published ports are blocked because they traverse FORWARD. Removal succeeds when mutation commands exit 0 without stderr.
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
Sample output — iptables -L, which ufw, and UFW service check
Chain INPUT (policy ACCEPT 184 packets, 22031 bytes) pkts bytes target prot opt in out source destination Unit ufw.service could not be found.
Meaning: iptables is available; INPUT currently defaults ACCEPT, while no which ufw line plus missing unit confirms removal. If iptables reports backend errors, resolve nft/legacy tooling first; do not leave INPUT ACCEPT beyond the controlled build window.
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
What you should do: read this section unless a labeled block says otherwise.
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
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
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)
Sample output — iptables -L DOCKER-USER -n -v after you add allow/deny rules
Chain DOCKER-USER (1 references)
pkts bytes target prot opt in out source destination
120 9840 ACCEPT tcp -- * * 10.0.0.0/8 0.0.0.0/0 tcp dpt:443
45 3600 DROP tcp -- * * 0.0.0.0/0 0.0.0.0/0 tcp dpt:443
0 0 RETURN all -- * * 0.0.0.0/0 0.0.0.0/0
Meaning: packet counters on the left prove traffic is hitting these rules (zero forever = wrong chain or no published port). Order matters: ACCEPT for the office network before DROP for everyone else. Final RETURN hands unmatched traffic back to Docker’s own FORWARD rules. Empty chain = Docker publishes ports to the world.
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
Sample output — Docker filter/NAT inspection (excerpt)
1 841 65K DNAT tcp -- * * 0.0.0.0/0 0.0.0.0/0 tcp dpt:443 to:172.18.0.5:443 -N DOCKER-USER -A DOCKER-USER -j RETURN -P INPUT DROP -A INPUT -i lo -j ACCEPT -A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT -A INPUT -p tcp --dport 22 -j ACCEPT
Meaning: DNAT maps host 443 to the container; -S shows DOCKER-USER returns unmatched traffic and INPUT defaults DROP after safe allows. Missing DNAT means no live publish; zero counters under traffic imply the wrong interface or chain.
Part 4 — INPUT chain: protect the host itself
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
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
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
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
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
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
Sample output — docker ps ports and Docker DNAT rule
nginx 0.0.0.0:443->443/tcp, [::]:443->443/tcp 1 841 65K DNAT tcp -- !br-ab12 * 0.0.0.0/0 0.0.0.0/0 tcp dpt:443 to:172.18.0.5:443
Meaning: Docker publishes IPv4/IPv6 port 443 and forwards IPv4 to the current container IP. Missing output means no running publish; a stale destination after recreate indicates persisted dynamic rules—restart Docker and stop saving Docker NAT entries.
Part 7 — Hardening: invalid TCP flags (mangle table)
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
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)
rules.v4 *mangle → PREROUTING block).-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
rules.v4 *mangle → PREROUTING block.-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
rules.v4 *mangle → PREROUTING block.-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
rules.v4 *mangle → PREROUTING block.-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)
rules.v4 *mangle → PREROUTING block.-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
rules.v4 *mangle → PREROUTING block.-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
rules.v4 *mangle → PREROUTING block.-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)
rules.v4 *mangle → PREROUTING block.-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
rules.v4 *mangle → PREROUTING block.-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
Sample output — mangle PREROUTING counters (excerpt)
Chain PREROUTING (policy ACCEPT 921K packets, 803M bytes) num pkts bytes target prot opt in out source destination 1 14 560 DROP tcp -- * * 0.0.0.0/0 0.0.0.0/0 tcp flags:SYN,RST/SYN,RST
Meaning: 14 packets/560 bytes matched the impossible SYN+RST rule. Zero is normal; rapid growth suggests scanning. Counters rising during legitimate failed handshakes mean a rule is too broad—remove it before persistence.
Part 8 — Example rules.v4 (full production file)
What you should do: COPY file → adapt IPs → apply with caution.
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 (edit passwords/IPs first).# /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 --- # Rule order matters: loopback → established → port rules → DROP # [C] Loopback (localhost services talking to each other) -A CHECK_INPUT -i lo -j ACCEPT # [B] Return traffic for connections this host already opened (must be before port DROP rules) -A CHECK_INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT # [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 # [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.
trusted_nets must exist before iptables-restore. Save with ipset save > /etc/ipset.conf and restore on boot (ipset restore -f /etc/ipset.conf) or rules load fails after reboot.Part 9 — Shell script ruleset (quick apply)
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
Full script for our reference server. Save as /usr/local/sbin/docker-host-firewall.sh:
/usr/local/sbin/docker-host-firewall.sh (edit passwords/IPs first).#!/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
What you should do: follow steps in order; keep SSH in a second session.
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
Sample output — external HTTPS allow and restricted-port tests
HTTP/2 200 server: nginx curl: (28) Failed to connect to your-server port 8080 after 5002 ms: Timeout was reached
Meaning: HTTPS survives Docker FORWARD/DNAT, while restricted 8080 silently times out externally. Connection refused means no listener rather than firewall proof; a 200 on 8080 means DOCKER-USER source/port/order is wrong.
Step 8 — Persist (Part 11)
sudo apt install iptables-persistent -y sudo netfilter-persistent save
Part 11 — iptables-persistent: rules.v4, reload, reboot
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
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:
iptables-save format)./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
Sample output — iptables-restore --test and apply
(no output; exit status 0)
Meaning: the dry run parsed every table/chain/rule and apply completed without stderr. Any line number, unknown option, missing chain/ipset, or COMMIT error means do not reload; fix it while existing SSH-safe rules remain active.
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
Sample output — netfilter-persistent reload and service status (excerpt)
run-parts: executing /usr/share/netfilter-persistent/plugins.d/15-ip4tables start run-parts: executing /usr/share/netfilter-persistent/plugins.d/25-ip6tables start Active: active (exited) since Sun 2026-08-30 16:02:17 UTC
Meaning: IPv4/IPv6 plugins loaded and the oneshot exited successfully; active (exited) is normal. Any plugin/restore/ipset error means policy may be partial—keep the second SSH session and inspect live rules.
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 (edit passwords/IPs first).# /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
Sample output — docker-firewall.service status (excerpt)
Active: active (exited) since Sun 2026-08-30 16:05:41 UTC Process: 18192 ExecStart=/usr/local/sbin/docker-host-firewall.sh (code=exited, status=0/SUCCESS)
Meaning: the oneshot ran after Docker and exited zero; remaining active is expected. Non-zero means restrictions may be absent—inspect the journal for missing chain, syntax, or execute-permission errors.
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 (edit passwords/IPs first).# /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)
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
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
Sample output — ip6tables -L before mirrored policy (excerpt)
Chain INPUT (policy ACCEPT 18 packets, 1640 bytes) pkts bytes target prot opt in out source destination
Meaning: IPv6 INPUT was initially open, with counters proving traffic exists; mirror loopback, established, SSH, then DROP. If IPv6 is disabled, commands may report no address family—document that rather than assuming IPv4 covers it.
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
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
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: "
Sample output — DOCKER-USER counters and temporary kernel log (excerpt)
1 27 1620 LOG all -- * * 0.0.0.0/0 0.0.0.0/0 LOG prefix "DOCKER-USER: " [Sun Aug 30 16:09:04 2026] DOCKER-USER: IN=eth0 OUT=br-ab12 SRC=203.0.113.55 DST=172.18.0.5 PROTO=TCP DPT=8080
Meaning: counters and fields show traffic entered eth0, was forwarded to the Docker bridge/container, and targeted 8080. No events means traffic misses this chain or logging is rate-limited. Remove the LOG rule after capture to avoid log flooding.
Part 14 — Production checklist
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
- 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
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
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.