How iptables Tables and Chains Map to Packet Flow

iptables organizes rules into tables, and each table contains chains. The three tables you will use most are filter, nat, and mangle. The filter table is the default and handles accept, drop, and reject decisions. The nat table handles source and destination address translation. The mangle table lets you modify packet headers - TTL, TOS, MARK.

Every packet entering or leaving the host passes through a fixed sequence of chains depending on its origin and destination. A packet arriving on eth0 destined for a local process hits PREROUTING (nat), then INPUT (filter). A packet generated by the host hits OUTPUT (filter, nat, mangle), then POSTROUTING (nat). A forwarded packet hits PREROUTING, FORWARD (filter), then POSTROUTING.

This traversal order matters because rules in the wrong chain are simply never evaluated. If you add a DNAT rule in the filter table instead of nat PREROUTING, nothing happens. Run the following to see all built-in chains across all tables:

iptables -t filter -L -n --line-numbers iptables -t nat -L -n --line-numbers iptables -t mangle -L -n --line-numbers

The --line-numbers flag is critical during debugging - it shows the rule position you need when inserting or deleting by index.

# Show all tables with verbose packet/byte counters
iptables -t filter -nvL --line-numbers
iptables -t nat -nvL --line-numbers
iptables -t mangle -nvL --line-numbers

Chain Policies: The Default Verdict

Every built-in chain has a policy, either ACCEPT or DROP. This is the verdict applied when a packet reaches the end of the chain without matching any rule. On a freshly installed system the policy for INPUT, FORWARD, and OUTPUT is ACCEPT - meaning everything is allowed by default.

The standard hardening approach is to set INPUT and FORWARD to DROP and then explicitly allow what you need. Do not set the policy to DROP on a remote session without first allowing your own SSH connection - we have locked ourselves out of test servers this way more than once.

The safe sequence when hardening remotely:

1. Flush existing rules. 2. Allow established and related connections. 3. Allow loopback. 4. Allow SSH from your management IP. 5. Set INPUT policy to DROP.

Do steps 1-4 before step 5. Never combine the flush and policy change into a single command.

# Safe remote hardening sequence
iptables -F
iptables -X
iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A INPUT -i lo -j ACCEPT
iptables -A INPUT -s 203.0.113.10 -p tcp --dport 22 -j ACCEPT
iptables -P INPUT DROP
iptables -P FORWARD DROP
iptables -P OUTPUT ACCEPT

Writing Rules: Syntax, Matches, and Targets

Every iptables rule follows this structure: iptables [-t table] -A|-I|-D chain [matches] -j target. The -A flag appends to the end of the chain. The -I flag inserts at a position (default is position 1, the top). Order matters because iptables evaluates rules sequentially and stops at the first match.

Matches are the conditions a packet must meet. Basic matches include -s (source IP), -d (destination IP), -p (protocol), -i (input interface), -o (output interface). Extended matches require -m to load a module: conntrack for connection state, multiport for multiple ports in one rule, iprange for IP ranges, string for payload inspection.

Targets define the action: ACCEPT, DROP, REJECT, LOG, DNAT, SNAT, MASQUERADE, MARK, RETURN. REJECT is DROP with an ICMP error back to the sender - useful for services that should fail fast rather than hang. LOG does not terminate processing; a packet matching a LOG rule continues to the next rule, so pair LOG rules with a DROP rule immediately after.

A common mistake is using DROP instead of REJECT for port 113 (ident). Many older SMTP servers wait for a timeout response on port 113 before accepting connections. Using REJECT there cuts mail delivery latency from 30 seconds to near zero.

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

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

# Log and drop port scans (TCP with no flags set)
iptables -A INPUT -p tcp --tcp-flags ALL NONE -j LOG --log-prefix "NULL_SCAN: " --log-level 4
iptables -A INPUT -p tcp --tcp-flags ALL NONE -j DROP

# REJECT ident instead of DROP
iptables -A INPUT -p tcp --dport 113 -j REJECT --reject-with tcp-reset
// advertisement

The conntrack Module: Stateful Filtering

Raw packet filtering without state tracking is almost useless in practice. The conntrack module (also called state in older syntax) tracks TCP connections, UDP pseudo-connections, and ICMP exchanges. This lets you write an INPUT rule that only accepts packets that are part of an already-established connection initiated from the host or explicitly allowed by an earlier rule.

The four conntrack states you need to know: NEW means the first packet of a new connection. ESTABLISHED means the connection is established and packets are flowing in both directions. RELATED means a packet that starts a new connection but is related to an existing one - classic example is FTP data connections or ICMP error responses. INVALID means the packet does not match any known connection and often indicates a spoofed or malformed packet; drop these unconditionally.

On a busy server, conntrack table exhaustion is a real failure mode. The default nf_conntrack_max on a 4GB RAM server is often 65536, which can fill up under SYN flood or heavy legitimate load. We saw this cause intermittent connection drops on a 10Gbps webserver before we identified the cause in /proc/net/nf_conntrack_stat.

# Check current conntrack table usage
cat /proc/sys/net/netfilter/nf_conntrack_count
cat /proc/sys/net/netfilter/nf_conntrack_max

# Increase conntrack table size permanently
echo 'net.netfilter.nf_conntrack_max = 262144' >> /etc/sysctl.conf
sysctl -p

# The essential stateful INPUT rules
iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A INPUT -m conntrack --ctstate INVALID -j DROP

NAT: SNAT, DNAT, and MASQUERADE

Network address translation rules live in the nat table. SNAT rewrites the source address of outgoing packets - use it when you have a static external IP. MASQUERADE is SNAT for dynamic IPs where the external address may change (typical for PPPoE or DHCP-assigned IPs). DNAT rewrites the destination address, routing incoming connections to an internal host or different port.

DNAT happens in PREROUTING before the routing decision. This is important: if you DNAT a packet to 192.168.1.50:8080, the routing table then sees 192.168.1.50 as the destination, not the original IP. You must also enable IP forwarding with sysctl net.ipv4.ip_forward=1 or DNAT will accept the packet but silently discard it because the kernel does not forward by default.

A practical example: a bastion host at 203.0.113.5 that forwards port 443 to an internal nginx server at 10.0.0.20. We use this pattern regularly for staging environments that need a real public IP without exposing the backend directly.

# Enable IP forwarding
echo 'net.ipv4.ip_forward = 1' >> /etc/sysctl.conf
sysctl -p

# DNAT: forward inbound port 443 to internal server
iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 443 -j DNAT --to-destination 10.0.0.20:443

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

# MASQUERADE for outbound traffic from internal network
iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -o eth0 -j MASQUERADE

# Static SNAT when external IP is fixed
iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -o eth0 -j SNAT --to-source 203.0.113.5

Custom Chains: Organizing Complex Rulesets

When a ruleset grows past 20-30 rules, flat chains become unmanageable. Custom chains act like subroutines: you jump to them with -j CHAIN_NAME, and if a packet reaches the end without matching, it returns to the calling chain at the next rule. If a rule in the custom chain matches and the target is a terminating action like ACCEPT or DROP, processing ends there.

Organize custom chains by function: one chain for SSH rate limiting, one for HTTP/HTTPS, one for ICMP, one for management interfaces. This makes auditing faster and reduces mistakes when adding rules under pressure.

The RETURN target inside a custom chain sends the packet back to the parent chain. Use this to build exception lists: jump to a whitelist chain first, return for unlisted IPs, then apply rate limiting to everything that returned.

# Create and populate a custom SSH protection chain
iptables -N SSH_PROTECT

# Whitelist management IPs - return immediately (no rate limit applied)
iptables -A SSH_PROTECT -s 203.0.113.0/24 -j RETURN

# Rate limit new SSH connections: max 3 new connections per minute per IP
iptables -A SSH_PROTECT -p tcp --dport 22 -m conntrack --ctstate NEW \
  -m recent --set --name SSH_RATE
iptables -A SSH_PROTECT -p tcp --dport 22 -m conntrack --ctstate NEW \
  -m recent --update --seconds 60 --hitcount 4 --name SSH_RATE -j DROP

# Accept remaining new SSH connections
iptables -A SSH_PROTECT -p tcp --dport 22 -j ACCEPT

# Jump to the custom chain from INPUT
iptables -A INPUT -p tcp --dport 22 -j SSH_PROTECT
// advertisement

Saving, Restoring, and Managing Rules Across Reboots

iptables rules are in-memory only. Reboot the server and they vanish. The canonical tools for persistence are iptables-save and iptables-restore.

On Debian and Ubuntu, install iptables-persistent. It writes rules to /etc/iptables/rules.v4 and /etc/iptables/rules.v6 and restores them at boot via systemd. On RHEL and CentOS, the iptables-services package does the same job writing to /etc/sysconfig/iptables.

For teams running infrastructure-as-code, generating iptables rules programmatically and applying them with iptables-restore is cleaner than maintaining a shell script of individual iptables commands. iptables-restore is atomic in that it replaces all rules in one operation rather than adding incrementally, which avoids the race condition where half the rules are loaded and the server is briefly misconfigured.

If your team is using automation pipelines to deploy firewall configs across a fleet, tools like those at taskbotshub.ai can help orchestrate templated iptables rule generation and deployment as part of a CI/CD workflow, reducing the manual error rate on large node counts.

# Save current rules
iptables-save > /etc/iptables/rules.v4
ip6tables-save > /etc/iptables/rules.v6

# Restore from file
iptables-restore < /etc/iptables/rules.v4

# On RHEL/CentOS with iptables-services
service iptables save
# Rules saved to /etc/sysconfig/iptables

# Verify the saved file looks sane before restoring in production
iptables-restore --test < /etc/iptables/rules.v4 && echo 'Ruleset OK'

Debugging Rules: Logging, Tracing, and Counting

When a rule is not behaving as expected, the first tool is the packet and byte counters. Run iptables -nvL --line-numbers and watch the pkts column. If a rule that should be matching shows zero packets, either packets are not reaching that chain or a rule higher in the chain is catching them first.

The LOG target writes to the kernel ring buffer (dmesg) and syslog. Prefix every LOG rule with a unique string so you can grep it out of /var/log/kern.log or journalctl -k. Set --log-level to 4 (warning) to avoid flooding the syslog at informational level.

For deeper tracing, the TRACE target in the raw table logs every chain and rule a packet passes through. This is expensive - enable it only for specific source IPs or short sessions and disable immediately after. It writes one log line per rule traversed per packet, which under load can generate gigabytes of logs in minutes.

Zero out counters before a test with iptables -Z to get clean numbers. Use watch -n1 'iptables -nvL INPUT --line-numbers' to see counters update in real time.

# Enable TRACE for a specific source IP (raw table, PREROUTING)
modprobe nf_log_ipv4
sysctl net.netfilter.nf_log.2=nf_log_ipv4
iptables -t raw -A PREROUTING -s 198.51.100.55 -j TRACE
iptables -t raw -A OUTPUT -d 198.51.100.55 -j TRACE

# Read trace output
journalctl -k | grep 'TRACE:'

# Remove trace rules when done
iptables -t raw -D PREROUTING -s 198.51.100.55 -j TRACE
iptables -t raw -D OUTPUT -d 198.51.100.55 -j TRACE

# Zero counters and watch INPUT chain
iptables -Z INPUT
watch -n1 'iptables -nvL INPUT --line-numbers'

Rate Limiting, Port Knocking, and Recent Module

The recent module maintains a per-IP timestamp list that you can use for rate limiting and simple port knocking. It is built into the kernel and requires no external daemon.

Port knocking with iptables uses three custom chains, each tracking whether an IP has hit the previous port in the sequence. An IP that hits port 7000, then 8000, then 9000 in sequence within a timeout window gets added to a KNOCKALLOW list and can connect to SSH. This works without any userspace daemon and survives high packet rates.

The limit module applies token-bucket rate limiting per chain, not per IP. Use it for global rate limiting of connection attempts. For per-IP rate limiting, the recent module is more appropriate because it tracks state per source address.

We tested a three-stage knock sequence on a test server running kernel 6.6.30. The sequence had zero false positives over 48 hours of background scan traffic and added no measurable latency for legitimate administrators who knew the sequence.

# Simple three-stage port knock for SSH
# Stage 1: knock port 7000
iptables -A INPUT -p tcp --dport 7000 -m conntrack --ctstate NEW \
  -m recent --set --name KNOCK1

# Stage 2: knock port 8000 within 10 seconds of stage 1
iptables -A INPUT -p tcp --dport 8000 -m conntrack --ctstate NEW \
  -m recent --rcheck --seconds 10 --name KNOCK1 \
  -m recent --set --name KNOCK2

# Stage 3: knock port 9000 within 10 seconds of stage 2, open SSH
iptables -A INPUT -p tcp --dport 9000 -m conntrack --ctstate NEW \
  -m recent --rcheck --seconds 10 --name KNOCK2 \
  -m recent --set --name KNOCKALLOW

# Allow SSH for IPs in KNOCKALLOW list (5 minute window)
iptables -A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW \
  -m recent --rcheck --seconds 300 --name KNOCKALLOW -j ACCEPT
// advertisement

iptables vs nftables in 2026: When to Migrate

nftables has been the recommended replacement since Linux 3.13, and on RHEL 9 and Debian 12, firewalld and ufw both use nftables as the backend. The iptables commands on those systems are iptables-legacy or iptables-nft wrappers. Running iptables-nft alongside direct nftables rules can cause rule ordering conflicts because nftables translates the iptables rules into its own rule format, and priority interaction between native nftables tables and translated iptables tables is not always obvious.

For new deployments on kernel 5.4 or newer, writing native nftables rules is the right choice. The syntax is more consistent, atomic rule replacement is built in without needing iptables-restore, and sets allow you to match multiple IPs or ports in a single rule without loading extension modules.

For existing systems with thousands of iptables rules, migration is a project, not an afternoon task. The iptables-translate and ip6tables-translate utilities convert individual rules to nftables syntax. Run them across your saved ruleset to get a starting point, then audit the output carefully - some iptables extensions have no direct nftables equivalent and require rewriting the logic.

If you manage a fleet and need to decide which approach to standardize on, the answer in 2026 is nftables for new servers and iptables for any system that you cannot fully test the nftables ruleset on before cutover. Do not run mixed rulesets on the same host in production.

# Check which backend iptables is using
iptables --version
# Output example: iptables v1.8.9 (nf_tables)
# vs: iptables v1.8.9 (legacy)

# Translate an existing iptables rule to nftables syntax
iptables-translate -A INPUT -p tcp --dport 22 -j ACCEPT
# Output: nft add rule ip filter INPUT tcp dport 22 counter accept

# Translate entire saved ruleset
iptables-save | iptables-restore-translate -f /etc/iptables/rules.v4