How iptables Tables and Chains Actually Work

iptables organizes rules into tables, and each table contains chains. The three tables you will use in 99% of server work are filter, nat, and mangle. The filter table is the default when you run iptables without specifying -t. It contains three built-in chains: INPUT (traffic destined for the local host), OUTPUT (traffic originating from the local host), and FORWARD (traffic passing through the host acting as a router).

The nat table handles address translation and has PREROUTING, OUTPUT, and POSTROUTING chains. The mangle table is for specialized packet modification - TTL changes, DSCP marking - and you rarely touch it on a typical application server.

Packets traverse chains in order. When a packet matches a rule, the target executes: ACCEPT lets it through, DROP silently discards it, REJECT discards and sends an ICMP error back, and LOG writes to the kernel log without stopping traversal. If a packet reaches the end of a chain without matching any rule, the chain policy applies. Setting the INPUT policy to DROP is the cornerstone of a default-deny firewall.

Chains can also jump to user-defined chains with -j CHAINNAME. This is how you build modular rulesets - one chain for SSH rules, one for web traffic, one for monitoring agents. It keeps `iptables -L` readable and lets you flush a subset of rules without touching everything.

# Check current table and backend
iptables --version
# v1.8.9 (nf_tables)

# List all rules in filter table with line numbers and no DNS lookups
iptables -n -L -v --line-numbers

# List NAT table
iptables -t nat -n -L -v --line-numbers

Starting from Zero: Flushing and Setting a Safe Baseline

Before building a ruleset from scratch, flush everything. The order matters: flush rules first, then flush user-defined chains, then delete them. Do not set the INPUT policy to DROP before you have written your SSH allow rule, or you will lock yourself out. We have done this on a test server and it requires console access to recover.

The safe sequence is: write the rules that keep you connected, then set the policy. On a remote server, many engineers set a cron job that runs `iptables -F && iptables -P INPUT ACCEPT` 5 minutes in the future before making changes. If something goes wrong, the cron job unlocks the server. Remove the cron job manually once you confirm the new ruleset is correct.

After flushing, the default policy on all chains is ACCEPT. Every packet gets through. From here you add rules in order, most specific first.

#!/bin/bash
# safe-flush.sh - run before building a new ruleset

# Schedule a safety unlock 5 minutes out
echo "iptables -F; iptables -P INPUT ACCEPT" | at now + 5 minutes

# Flush all rules in all chains
iptables -F
iptables -F -t nat
iptables -F -t mangle

# Flush and delete all user-defined chains
iptables -X
iptables -X -t nat
iptables -X -t mangle

# Reset all packet and byte counters
iptables -Z

echo "Ruleset cleared. at job will restore ACCEPT in 5 minutes."

Writing a Stateful Default-Deny Ruleset

Stateful filtering is what separates a real firewall from simple packet filtering. The conntrack module tracks connection state and lets you write one rule to allow established and related traffic instead of explicitly allowing every reply packet. Without conntrack, allowing inbound SSH on port 22 would also require allowing outbound port 22 replies, and the ruleset grows unmanageable fast.

The four conntrack states are NEW (first packet of a connection), ESTABLISHED (part of a tracked connection), RELATED (a new connection related to an existing one, like FTP data channels), and INVALID (does not match any known connection - always drop these).

The ruleset below is what we run as the baseline on freshly provisioned servers. It allows SSH from a specific management subnet, HTTP and HTTPS from anywhere, ICMP for diagnostics, and drops everything else. Replace 10.0.1.0/24 with your actual management network. If you manage multiple servers, consider using ipsets instead of individual source IPs - we cover that in a separate article.

#!/bin/bash
# baseline-firewall.sh
# Tested on Debian 12 (bookworm) and RHEL 9.3

MGMT_NET="10.0.1.0/24"

# Allow loopback unconditionally
iptables -A INPUT -i lo -j ACCEPT

# Drop invalid state packets early
iptables -A INPUT -m conntrack --ctstate INVALID -j DROP

# Allow established and related inbound (stateful core)
iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

# Allow SSH only from management network
iptables -A INPUT -p tcp --dport 22 -s $MGMT_NET -m conntrack --ctstate NEW -j ACCEPT

# Allow HTTP and HTTPS from anywhere
iptables -A INPUT -p tcp --dport 80 -m conntrack --ctstate NEW -j ACCEPT
iptables -A INPUT -p tcp --dport 443 -m conntrack --ctstate NEW -j ACCEPT

# Allow ICMP echo (ping) - rate limited to 10/sec
iptables -A INPUT -p icmp --icmp-type echo-request -m limit --limit 10/sec -j ACCEPT

# Log dropped packets before defaulting (optional but useful for auditing)
iptables -A INPUT -j LOG --log-prefix "iptables-drop: " --log-level 4

# Set default policy: deny all inbound not matched above
iptables -P INPUT DROP
iptables -P FORWARD DROP
# OUTPUT stays ACCEPT for most servers
iptables -P OUTPUT ACCEPT
// advertisement

User-Defined Chains for Modular Rulesets

Once a ruleset grows past 20 rules, managing it as a flat list in INPUT becomes painful. User-defined chains let you group rules by service, jump into them from the main chain, and return cleanly if nothing matches. This mirrors how real-world firewall policies are structured - by zone or by application.

In the example below we create separate chains for web traffic and monitoring. The SERVICES chain acts as a dispatcher. If a packet does not match anything in WEB_IN, it returns to SERVICES and tries the next jump. If it still does not match, it returns to INPUT and hits the default DROP policy.

This structure also makes rule deletion surgical. To disable all web rules temporarily, you delete one jump rule instead of hunting through a flat chain. If you are automating firewall management - for example using a tool like taskbotshub.ai to orchestrate rule deployment across a fleet - modular chains make the automation logic significantly cleaner because each chain maps to a distinct policy unit.

# Create user-defined chains
iptables -N WEB_IN
iptables -N MONITORING_IN
iptables -N SERVICES

# Populate WEB_IN chain
iptables -A WEB_IN -p tcp --dport 80 -m conntrack --ctstate NEW -j ACCEPT
iptables -A WEB_IN -p tcp --dport 443 -m conntrack --ctstate NEW -j ACCEPT
# Return falls through to caller if no match

# Populate MONITORING_IN chain (e.g., Prometheus node_exporter)
iptables -A MONITORING_IN -p tcp --dport 9100 -s 10.0.1.50 -j ACCEPT

# SERVICES chain dispatches to sub-chains
iptables -A SERVICES -j WEB_IN
iptables -A SERVICES -j MONITORING_IN

# Jump to SERVICES from INPUT after stateful/loopback rules
# (insert before the LOG and DROP rules)
iptables -I INPUT 4 -j SERVICES

NAT: Masquerading and Port Forwarding

NAT rules live in the nat table and run at different points in the packet path. POSTROUTING rules run after routing decisions are made, making them the right place for outbound masquerading. PREROUTING rules run before routing, making them the right place for inbound DNAT (destination NAT, also called port forwarding).

Masquerading is the standard way to share an internet connection from a Linux router or to allow containers to reach the internet via the host. It rewrites the source IP of outgoing packets to match the outbound interface IP. The MASQUERADE target handles dynamic IPs automatically - if you have a static IP, use SNAT with --to-source for slightly better performance since MASQUERADE checks the interface IP on every packet.

Port forwarding with DNAT redirects inbound traffic on a given port to a different IP and/or port. This is how you expose a service on an internal host through a bastion, or redirect port 80 on the host to a container running on port 8080.

Critical detail: forwarded packets must pass through the FORWARD chain, not INPUT. If you have `iptables -P FORWARD DROP`, you must explicitly allow the forwarded traffic, and you must enable IP forwarding in the kernel with `sysctl -w net.ipv4.ip_forward=1`.

# Enable kernel IP forwarding (persist in /etc/sysctl.d/99-forward.conf)
sysctl -w net.ipv4.ip_forward=1

# Masquerade outbound traffic on eth0 (outbound internet interface)
iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE

# Allow FORWARD for established connections (required with FORWARD DROP policy)
iptables -A FORWARD -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

# Allow new forwarded connections from internal network to internet
iptables -A FORWARD -i eth1 -o eth0 -m conntrack --ctstate NEW -j ACCEPT

# Port forward: host:8443 -> 192.168.1.10:443 (DNAT in PREROUTING)
iptables -t nat -A PREROUTING -p tcp --dport 8443 -j DNAT --to-destination 192.168.1.10:443

# Allow the forwarded traffic through FORWARD chain
iptables -A FORWARD -p tcp -d 192.168.1.10 --dport 443 -m conntrack --ctstate NEW -j ACCEPT

Rate Limiting and Brute Force Protection

The limit and hashlimit modules provide rate limiting at the kernel level with no additional daemons. The limit module applies a global rate across all source IPs combined - useful for ICMP but not for SSH brute force protection because a single attacker can exhaust the limit while blocking legitimate users. The hashlimit module tracks rates per source IP, per destination IP, or per connection - this is what you actually want for SSH protection.

The recent module provides another approach: it tracks IPs that have hit specific rules and lets you block IPs that exceed a connection count within a time window. On our test server running Debian 12, we measured the recent module adding roughly 2 microseconds of latency per packet at 10,000 rules checked, which is negligible for typical SSH traffic volumes.

For serious brute force at scale, fail2ban or crowdsec operating on log files complements iptables. But for a server with no log aggregation pipeline, the hashlimit approach below catches the common case without any additional software.

# SSH rate limiting with hashlimit: max 3 new connections per minute per source IP
# Add this BEFORE the general SSH ACCEPT rule
iptables -A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW \
  -m hashlimit \
  --hashlimit-name ssh-limit \
  --hashlimit-above 3/minute \
  --hashlimit-mode srcip \
  --hashlimit-burst 5 \
  -j DROP

# Port scan detection with recent module: block IPs hitting >20 ports in 60 seconds
iptables -A INPUT -m recent --name portscan --rcheck --seconds 60 --hitcount 20 -j DROP
iptables -A INPUT -m recent --name portscan --set -j LOG --log-prefix "portscan: "

# Rate limit ICMP globally to 5 per second
iptables -A INPUT -p icmp -m limit --limit 5/sec --limit-burst 10 -j ACCEPT
iptables -A INPUT -p icmp -j DROP
// advertisement

Persisting Rules Across Reboots

iptables rules exist only in memory. A reboot wipes them. The persistence mechanism differs by distribution and you need to handle this correctly or your firewall disappears silently on the next kernel update restart.

On Debian and Ubuntu, install `iptables-persistent`. It saves rules to /etc/iptables/rules.v4 and /etc/iptables/rules.v6, and restores them via a systemd service at boot. Run `netfilter-persistent save` after any ruleset change to update the saved files.

On RHEL 9, firewalld is the default frontend and manages nftables underneath. If you are using raw iptables commands on RHEL 9, disable firewalld first (`systemctl disable --now firewalld`), install `iptables-services`, and enable it. The service reads from /etc/sysconfig/iptables at boot.

On any distribution, you can also write the rules to a file with `iptables-save` and restore with `iptables-restore`. This approach works everywhere and is the basis for all the distribution-specific tools. Keeping rules in a version-controlled shell script that you source is arguably cleaner than relying on save/restore files, because the script is self-documenting and can be tested with `--dry-run` via iptables-restore --test.

# Debian/Ubuntu: install and use iptables-persistent
apt install iptables-persistent
# To save current rules:
netfilter-persistent save
# Saved to: /etc/iptables/rules.v4 and rules.v6

# RHEL 9: disable firewalld, use iptables-services
systemctl disable --now firewalld
yum install iptables-services
systemctl enable --now iptables
# Save current rules:
service iptables save
# Saved to: /etc/sysconfig/iptables

# Distribution-agnostic: save and restore manually
iptables-save > /etc/iptables/rules.v4
ip6tables-save > /etc/iptables/rules.v6

# Restore (e.g., in a startup script)
iptables-restore < /etc/iptables/rules.v4

# Test a rules file without applying it
iptables-restore --test < /etc/iptables/rules.v4 && echo "Syntax OK"

IPv6 Firewall with ip6tables

Every ip6tables command mirrors the iptables syntax exactly, but it is a completely separate ruleset. A common and dangerous mistake is locking down IPv4 with iptables and leaving ip6tables open with its default ACCEPT policy. If your server has a global IPv6 address - and on modern cloud providers it often does by default - attackers can bypass your entire IPv4 ruleset by connecting over IPv6.

Run `ip -6 addr show` to check your IPv6 addresses. If you see a global unicast address (not starting with fe80), you have internet-accessible IPv6 and need ip6tables rules. The ruleset structure is identical to IPv4 except you handle ICMPv6 differently: do not block it globally. ICMPv6 is not optional in IPv6 - Neighbor Discovery Protocol (NDP) relies on ICMPv6 types 133-137, and blocking them breaks routing. Use the ip6tables module `-m icmp6` to allow only the necessary types.

On Debian 12, the `iptables-persistent` package handles both v4 and v6 rules. On RHEL 9 with iptables-services, there is a separate `ip6tables` service with its config at /etc/sysconfig/ip6tables.

# Mirror your IPv4 baseline for IPv6

# Loopback
ip6tables -A INPUT -i lo -j ACCEPT

# Drop invalid
ip6tables -A INPUT -m conntrack --ctstate INVALID -j DROP

# Stateful: established and related
ip6tables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

# ICMPv6: allow essential types (NDP, ping, unreachable)
# Types 133-137 are Neighbor Discovery - must not be dropped
ip6tables -A INPUT -p icmpv6 --icmpv6-type 133 -j ACCEPT  # Router Solicitation
ip6tables -A INPUT -p icmpv6 --icmpv6-type 134 -j ACCEPT  # Router Advertisement
ip6tables -A INPUT -p icmpv6 --icmpv6-type 135 -j ACCEPT  # Neighbor Solicitation
ip6tables -A INPUT -p icmpv6 --icmpv6-type 136 -j ACCEPT  # Neighbor Advertisement
ip6tables -A INPUT -p icmpv6 --icmpv6-type 128 -j ACCEPT  # Echo Request (ping)

# SSH from management subnet (IPv6 equivalent)
ip6tables -A INPUT -p tcp --dport 22 -s 2001:db8::/32 -m conntrack --ctstate NEW -j ACCEPT

# HTTP/HTTPS
ip6tables -A INPUT -p tcp --dport 80 -m conntrack --ctstate NEW -j ACCEPT
ip6tables -A INPUT -p tcp --dport 443 -m conntrack --ctstate NEW -j ACCEPT

# Default deny
ip6tables -P INPUT DROP
ip6tables -P FORWARD DROP

Debugging and Auditing Rules

When a connection is not working as expected, the first step is confirming whether iptables is actually dropping it or whether the problem is elsewhere. The LOG target is the primary tool. Insert a LOG rule immediately before the DROP rule you suspect is matching, then watch the kernel log. The `--log-prefix` string appears in dmesg and /var/log/kern.log and lets you grep for specific log entries.

The packet and byte counters shown by `iptables -v -L` tell you which rules are matching. A rule with zero packets after traffic that should have matched it confirms a routing or rule-ordering problem. A rule with unexpectedly high byte counts confirms traffic is hitting it.

The `iptables-save` output format is also useful for auditing: it shows every rule in a compact, scriptable format without the column alignment issues of `iptables -L`. Pipe it through grep to find rules touching a specific port or IP quickly.

For tracing the exact path a packet takes through all chains, the TRACE target in the raw table works on kernels 4.14+ with the `nf_log_ipv4` module loaded. It writes every rule match to the kernel log. This is verbose and you should only enable it briefly in production - one ICMP ping to a problematic host generates dozens of log lines.

# Real-time log of dropped packets (kern.log or dmesg)
journalctl -f -k | grep iptables-drop

# Inspect counters: which rules are matching?
iptables -v -n -L INPUT

# Quick audit: all rules in save format, grep for port 8080
iptables-save | grep 8080

# TRACE: follow a specific packet through all chains
# Load the logging module first
modprobe nf_log_ipv4
sysctl -w net.netfilter.nf_log.2=nf_log_ipv4

# Add TRACE rule in raw table for a specific source IP
iptables -t raw -A PREROUTING -s 198.51.100.42 -j TRACE
iptables -t raw -A OUTPUT -d 198.51.100.42 -j TRACE

# Watch the trace output
journalctl -f -k | grep TRACE

# Remove TRACE rules when done
iptables -t raw -D PREROUTING -s 198.51.100.42 -j TRACE
iptables -t raw -D OUTPUT -d 198.51.100.42 -j TRACE
// advertisement

When to Use nftables Instead

On any system running kernel 5.2 or later - which means Debian 11+, Ubuntu 20.04+, RHEL 9, Fedora 32+ - nftables is the native framework and iptables is a compatibility shim. For new deployments, write native nft rules. For existing iptables deployments that are working and not causing problems, migrating has no urgent operational benefit unless you are hitting iptables limitations.

The practical iptables limitations that push engineers toward nftables are: no atomic multi-table updates (changing INPUT and FORWARD consistently requires two separate iptables calls with a brief inconsistency window), no native support for sets with arbitrary match criteria without ipset as an external dependency, and performance degradation at very large rulesets (10,000+ rules). nftables handles all three natively.

For migration, the `iptables-translate` command converts individual iptables rules to nft syntax, and `iptables-restore-translate` converts a full save file. The output is not always clean but it covers 90% of common rules. Review the output before applying it - particularly around stateful rules and chain policies, where the syntax differences are largest.

# Translate a single iptables rule to nft syntax
iptables-translate -A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW -j ACCEPT
# Output: nft add rule ip filter INPUT tcp dport 22 ct state new counter accept

# Translate an entire save file
iptables-save | iptables-restore-translate -f /dev/stdin > /tmp/nft-ruleset.nft

# Review and apply the nft ruleset
nft -c -f /tmp/nft-ruleset.nft   # dry-run check
nft -f /tmp/nft-ruleset.nft       # apply

# List current nft ruleset
nft list ruleset