Skip to content

iptables & nftables Footguns

Mistakes that lock you out, break production traffic, or leave your firewall silently ineffective.


1. Flushing rules over SSH locks you out

You SSH into a remote server and run iptables -F to start fresh. The flush clears all rules, including the one allowing your SSH connection. If the chain policy is DROP, your session dies instantly. You now need console access or a KVM to recover.

# The classic self-lockout
$ iptables -P INPUT DROP
$ iptables -A INPUT -p tcp --dport 22 -j ACCEPT
$ iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

# Later, you "clean up"...
$ iptables -F    # All rules gone. Policy is still DROP. SSH is dead.

Fix: Before flushing, set a cron job to restore rules in 5 minutes. Or set the policy to ACCEPT before flushing, then re-apply rules, then set the policy back to DROP.

# Safe approach: schedule a restore before making changes
$ cp /etc/iptables/rules.v4 /tmp/rules.backup
$ echo "iptables-restore < /tmp/rules.backup" | at now + 5 minutes

# Alternative: always flush with policy reset
$ iptables -P INPUT ACCEPT && iptables -F
# Now re-apply your rules, then:
$ iptables -P INPUT DROP

2. Rule order matters: first match wins

iptables evaluates rules top-to-bottom in each chain. The first matching rule determines the packet's fate. If you append a DROP rule after an ACCEPT rule that matches the same traffic, the DROP never fires.

# This does NOT block port 8080 from 10.0.0.5
$ iptables -A INPUT -p tcp --dport 8080 -j ACCEPT
$ iptables -A INPUT -s 10.0.0.5 -p tcp --dport 8080 -j DROP
# The ACCEPT matches first. The DROP is dead code.

# Correct: put the specific deny BEFORE the general allow
$ iptables -A INPUT -s 10.0.0.5 -p tcp --dport 8080 -j DROP
$ iptables -A INPUT -p tcp --dport 8080 -j ACCEPT

Fix: Use -I (insert) to place rules at the top when order matters. Use iptables -L -n --line-numbers to verify rule order.


3. Forgetting to save rules (lost on reboot)

You spend 30 minutes crafting the perfect ruleset. The server reboots for a kernel update. All rules are gone. iptables rules live in kernel memory and are not persisted automatically.

# You did all this work...
$ iptables -A INPUT -p tcp --dport 443 -j ACCEPT
$ iptables -A INPUT -p tcp --dport 80 -j ACCEPT
# ... but never saved. Reboot wipes everything.

# Save on Debian/Ubuntu
$ iptables-save > /etc/iptables/rules.v4
$ ip6tables-save > /etc/iptables/rules.v6

# Save on RHEL/CentOS
$ service iptables save
# Or:
$ iptables-save > /etc/sysconfig/iptables

Fix: Install iptables-persistent (Debian/Ubuntu) or enable the iptables service (RHEL). Always run iptables-save after changing rules. Put it in your change management checklist.


4. Docker overwrites your iptables rules

Docker manages its own iptables chains (DOCKER, DOCKER-USER, DOCKER-ISOLATION). When Docker starts, it inserts rules that may bypass your carefully crafted firewall. A container with -p 8080:80 is exposed to the world even if your INPUT chain drops everything, because Docker uses the FORWARD chain and NAT.

# You think you've locked down the host
$ iptables -P INPUT DROP
$ iptables -A INPUT -p tcp --dport 22 -j ACCEPT

# But Docker published port 8080 via FORWARD + NAT, not INPUT
$ docker run -d -p 8080:80 nginx
# Port 8080 is now publicly accessible. Your INPUT rules don't help.

Fix: Use the DOCKER-USER chain for rules that should apply to Docker-forwarded traffic. This chain is evaluated before Docker's own rules:

# Block external access to Docker-published port 8080
$ iptables -I DOCKER-USER -i eth0 -p tcp --dport 8080 -j DROP
# Allow only from internal network
$ iptables -I DOCKER-USER -i eth0 -s 10.0.0.0/8 -p tcp --dport 8080 -j ACCEPT

Or use --iptables=false in Docker daemon config and manage everything manually (advanced, error-prone).


5. Not allowing ESTABLISHED/RELATED connections

You create rules to allow inbound SSH and HTTP, but forget to allow return traffic for outbound connections. Your server can't resolve DNS, fetch packages, or download updates because the response packets are dropped.

# Broken: allows inbound but breaks all outbound-initiated flows
$ iptables -P INPUT DROP
$ iptables -A INPUT -p tcp --dport 22 -j ACCEPT
$ iptables -A INPUT -p tcp --dport 80 -j ACCEPT
# No rule for established connections. DNS replies, apt-get responses, etc. are all dropped.

# Fixed: always include this rule near the top
$ iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

Fix: The ESTABLISHED,RELATED rule should be the first or second rule in your INPUT chain (after any LOG rule). It matches response packets for connections your server initiated. Without it, your firewall is fundamentally broken for outbound traffic.


6. Blocking ICMP entirely breaks path MTU discovery

You block all ICMP because "ping is a security risk." This breaks Path MTU Discovery (PMTUD). When a router on the path needs to fragment a packet but can't (DF bit set), it sends an ICMP "Fragmentation Needed" message. If you drop that, connections over VPNs or through tunnels hang or transfer at glacial speed.

# Looks secure. Actually breaks networking.
$ iptables -A INPUT -p icmp -j DROP

# TCP connections through tunnels/VPNs will hang on large transfers.
# wget downloads stall. SCP hangs after initial handshake.

Fix: At minimum, allow ICMP type 3 (destination unreachable, which includes fragmentation needed) and type 11 (time exceeded, needed for traceroute):

$ iptables -A INPUT -p icmp --icmp-type destination-unreachable -j ACCEPT
$ iptables -A INPUT -p icmp --icmp-type time-exceeded -j ACCEPT
$ iptables -A INPUT -p icmp --icmp-type echo-request -j ACCEPT   # optional, for ping

7. iptables vs nftables conflict

Modern distros (Debian 11+, Ubuntu 22.04+, RHEL 9+) use nftables as the backend. The iptables command may be iptables-nft (a translation layer) or legacy iptables-legacy. Mixing the two creates invisible rule conflicts: rules added via iptables-legacy don't appear in nft list ruleset, and vice versa.

# Check which iptables you're actually running
$ update-alternatives --display iptables
# Or:
$ iptables --version
# "iptables v1.8.7 (nf_tables)" = nft backend
# "iptables v1.8.7 (legacy)" = legacy backend

# If you see rules in iptables but not in nft (or vice versa), you have a conflict
$ iptables -L -n          # shows rules from one backend
$ nft list ruleset        # shows rules from the other backend

Fix: Pick one and stick with it. On modern systems, use nftables directly or ensure iptables points to the nft backend. Never mix iptables-legacy and nft on the same system. Check with update-alternatives --config iptables.


8. Not logging before dropping (invisible debugging)

Your default policy is DROP. Something is broken. You have no idea what traffic is being dropped because there are no LOG rules. You're debugging blind.

# Invisible failures — you'll never know what was dropped
$ iptables -P INPUT DROP
$ iptables -A INPUT -p tcp --dport 22 -j ACCEPT

# Add logging BEFORE the drop (or as default policy drops)
$ iptables -A INPUT -j LOG --log-prefix "IPT-DROP: " --log-level 4
# This logs everything that falls through to the default DROP policy
# Check with: dmesg | grep "IPT-DROP" or journalctl -k | grep "IPT-DROP"

Fix: Add LOG rules before any explicit DROP or at the end of the chain (before the default policy takes effect). Use --log-prefix to make logs grep-friendly. Use rate limiting to avoid log floods:

$ iptables -A INPUT -m limit --limit 5/min -j LOG --log-prefix "IPT-DROP: "

9. Chain policy vs explicit DROP rule confusion

Setting -P INPUT DROP changes the chain's default policy. Any packet that doesn't match a rule is dropped. But iptables -F (flush) only removes rules, not the policy. People assume flushing "resets" the firewall. It doesn't — it leaves the DROP policy in place with no rules, which drops everything.

Conversely, some people add an explicit DROP rule at the end instead of setting the policy. This works, but if someone inserts a rule after your DROP, it's unreachable.

# Dangerous misconception: "flush resets everything"
$ iptables -P INPUT DROP
$ iptables -A INPUT -p tcp --dport 22 -j ACCEPT
$ iptables -F    # Rules gone. Policy still DROP. Everything blocked.

# Safer pattern: use ACCEPT policy + explicit drop at end
$ iptables -P INPUT ACCEPT
$ iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
$ iptables -A INPUT -p tcp --dport 22 -j ACCEPT
$ iptables -A INPUT -j DROP   # Explicit final rule, removed by flush

Fix: Understand the difference. If you use a DROP policy, never flush without first switching to ACCEPT. Document which approach your team uses and stick with it.