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

45 min read

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

How to read this guide (start here)

Every code block has a colored label. Do only what the label says.

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 this part is for: cover: 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:

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

RUN — paste into a shell on the host this section describes.
# 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

RUN — paste into a shell on the host this section describes.
# 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 this part is for: understand: 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.

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

Chains: INPUT, OUTPUT, FORWARD

READ — diagram or folder layout. Do not paste into a terminal.
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

RUN — paste into a shell on the host this section describes.
iptables -A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW,ESTABLISHED -j ACCEPT
READ — explanatory content. Follow the paragraph above.
  -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

What this part is for: cover: 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":

  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.

RUN — paste into a shell on the host this section describes.
# 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

RUN — paste into a shell on the host this section describes.
# 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 this part is for: cover: 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

RUN — paste into a shell on the host this section describes.
# 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

What this part is for: cover: 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:

RUN — paste into a shell on the host this section describes.
# 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)

RUN — paste into a shell on the host this section describes.
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:

READ — explanatory content. Follow the paragraph above.
# 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 this part is for: cover: 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:

READ — explanatory content. Follow the paragraph above.
# 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:

RUN — paste into a shell on the host this section describes.
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 this part is for: cover: 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:

READ — diagram or folder layout. Do not paste into a terminal.
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 NONE → none of them may be set. This is a NULL packet.

Normal vs malicious packets

READ — diagram or folder layout. Do not paste into a terminal.
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)

COPY FILE — invalid TCP flag drops (add to your 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

COPY FILE — add to your 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

COPY FILE — add to your 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

COPY FILE — add to your 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)

COPY FILE — add to your 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

COPY FILE — add to your 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

COPY FILE — add to your 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)

COPY FILE — add to your 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

COPY FILE — add to your 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):

RUN — paste into a shell on the host this section describes.
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 this part is for: have a full rules.v4 template.
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:

READ — explanatory content. Follow the paragraph above.
# 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
RUN — reload persisted rules and inspect service status.
# 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.

COPY FILE — save as /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)

READ — diagram or folder layout. Do not paste into a terminal.
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.

ipset persistence: 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 this part is for: cover: 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:

COPY FILE — save as /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"
RUN — paste into a shell on the host this section describes.
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

What this part is for: build a safe ruleset from zero on a lab host.
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

RUN — paste into a shell on the host this section describes.
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

RUN — paste into a shell on the host this section describes.
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)

RUN — paste into a shell on the host this section describes.
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

READ — explanatory content. Follow the paragraph above.
ssh user@your-server   # must still work

Step 6 — Add DOCKER-USER rules

RUN — paste into a shell on the host this section describes.
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

RUN — paste into a shell on the host this section describes.
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)

RUN — paste into a shell on the host this section describes.
sudo apt install iptables-persistent -y
sudo netfilter-persistent save

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

What this part is for: cover: 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

RUN — paste into a shell on the host this section describes.
# 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:

COPY FILE — typical persist path for IPv4 rules (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:

RUN — paste into a shell on the host this section describes.
# 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:

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

RUN — paste into a shell on the host this section describes.
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:

COPY FILE — create the config file named in the section (edit values first).
# 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:

RUN — paste into a shell on the host this section describes.
# 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:

RUN — paste into a shell on the host this section describes.
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.

COPY FILE — save as /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
RUN — paste into a shell on the host this section describes.
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:

COPY FILE — create the config file named in the section (edit values first).
# 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
RUN — paste into a shell on the host this section describes.
# After editing:
sudo netfilter-persistent reload

Disable iptables management by Docker (not recommended)

COPY FILE — save as /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 this part is for: cover: 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:

RUN — paste into a shell on the host this section describes.
# 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 this part is for: cover: Troubleshooting.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
READ — diagram or folder layout. Do not paste into a terminal.
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

RUN — paste into a shell on the host this section describes.
# 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 this part is for: cover: Production checklist.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
  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

What this part is for: cover: 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.