How the nat Table Works

The nat table is separate from the filter table. A common mistake is adding NAT rules to the filter table - they silently do nothing. The nat table only sees the first packet of a connection. conntrack (nf_conntrack) handles all subsequent packets by replaying the same translation decision.

The three chains in the nat table serve distinct purposes. PREROUTING fires before routing decisions, so it can redirect incoming packets to a different destination. POSTROUTING fires after routing decisions, just before a packet leaves the machine. OUTPUT handles locally generated packets before routing.

To confirm iptables is using the nat table and conntrack is loaded:

You need ip_conntrack or nf_conntrack loaded. On modern kernels, nf_conntrack is built in or loaded automatically when you add your first NAT rule. Verify with 'lsmod | grep conntrack' and check /proc/net/nf_conntrack for existing tracked connections.

lsmod | grep conntrack
nf_conntrack          192512  4 nf_nat,nf_conntrack_netlink,xt_conntrack,xt_MASQUERADE

iptables -t nat -L -n -v --line-numbers

Enable IP Forwarding

NAT is useless without IP forwarding enabled. The kernel drops packets by default when they arrive on one interface destined for another. Enable forwarding persistently in /etc/sysctl.conf or a drop-in file under /etc/sysctl.d/.

After writing the sysctl config, apply it immediately without rebooting using 'sysctl -p'. Verify the value is 1 with 'cat /proc/sys/net/ipv4/ip_forward'. A value of 0 means forwarding is off and NAT will not work for transit traffic, even if your iptables rules look correct.

# Write to /etc/sysctl.d/10-ip-forward.conf
echo 'net.ipv4.ip_forward = 1' > /etc/sysctl.d/10-ip-forward.conf
sysctl -p /etc/sysctl.d/10-ip-forward.conf

# Verify
cat /proc/sys/net/ipv4/ip_forward
# Expected output: 1

MASQUERADE: Dynamic SNAT for Outbound Traffic

MASQUERADE is the right target when your external IP is assigned dynamically, such as a DHCP address from an ISP or a cloud provider that may reassign the address. It reads the source IP from the outgoing interface at packet time. This is slightly more expensive than SNAT because iptables must look up the interface address per-packet rather than using a compiled-in address.

In this example, eth0 is the WAN interface and 192.168.1.0/24 is the LAN subnet. Every packet leaving eth0 that originated from the LAN will have its source IP rewritten to eth0's current address. The FORWARD chain in the filter table needs a matching ACCEPT rule, or packets will be NATed but then dropped.

The two FORWARD rules below are the minimum required: allow established/related traffic back in (so return traffic from the internet reaches LAN hosts), and allow new outbound connections from the LAN.

# NAT rule
iptables -t nat -A POSTROUTING -s 192.168.1.0/24 -o eth0 -j MASQUERADE

# Filter rules to allow the forwarded traffic
iptables -A FORWARD -i eth0 -o eth1 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A FORWARD -i eth1 -o eth0 -s 192.168.1.0/24 -j ACCEPT

# Check the rules are in place
iptables -t nat -L POSTROUTING -n -v
iptables -L FORWARD -n -v
// advertisement

SNAT: Static Source NAT for Fixed IPs

When the outbound IP is static, prefer SNAT over MASQUERADE. SNAT compiles the target address into the rule and avoids the interface lookup overhead per packet. The syntax uses '--to-source' to specify the public IP or a range.

You can also SNAT to an IP range, which distributes connections across multiple public addresses. iptables will assign source IPs in round-robin fashion within the range. This is useful when you have a /29 or larger block and want to spread load or avoid per-IP connection limits on external services.

SNAT only works in POSTROUTING (or OUTPUT for locally generated packets). Do not put it in PREROUTING.

# SNAT to a single fixed IP
iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -o eth0 -j SNAT --to-source 203.0.113.10

# SNAT to an IP range (distributes connections)
iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -o eth0 -j SNAT --to-source 203.0.113.10-203.0.113.14

# SNAT with port range (useful when source port exhaustion is a concern)
iptables -t nat -A POSTROUTING -p tcp -s 10.0.0.0/24 -o eth0 \
  -j SNAT --to-source 203.0.113.10:1024-65535

DNAT: Destination NAT and Port Forwarding

DNAT rewrites the destination address of incoming packets. It lives in PREROUTING because the rewrite must happen before the kernel makes the routing decision. If you put a DNAT rule in POSTROUTING, the packet has already been routed and the destination change has no effect on where it goes.

The classic use case is port forwarding: a server behind NAT is not directly reachable, so you rewrite incoming traffic on a public port to the private IP and port of the backend server.

In the example below, TCP traffic arriving on the router's port 8080 is forwarded to an internal server at 192.168.1.50:80. The matching FORWARD rule is required - without it, the packet gets DNAT'd but then dropped by the default filter policy.

# Forward port 8080 on eth0 to internal host port 80
iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 8080 -j DNAT --to-destination 192.168.1.50:80

# Allow the forwarded traffic through the filter table
iptables -A FORWARD -i eth0 -o eth1 -p tcp -d 192.168.1.50 --dport 80 \
  -m conntrack --ctstate NEW,ESTABLISHED,RELATED -j ACCEPT

# Forward an entire port range (e.g., passive FTP data ports)
iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 21000:21100 \
  -j DNAT --to-destination 192.168.1.60:21000-21100

DNAT to Multiple Backends with Statistics

iptables supports primitive load balancing using the statistic module. You can distribute incoming connections across multiple backend servers without a dedicated load balancer. This works by applying DNAT rules with probability-based matching in sequence.

For three backends, the math is: first rule matches with probability 1/3. If it doesn't match, the second rule matches with probability 1/2 of remaining (which equals 1/3 overall). The third rule catches everything left. The result is an even 33/33/33 distribution over many connections.

This is stateless - conntrack handles session persistence within a single connection, but there is no sticky session support. For stateful applications, use a proper load balancer. For stateless services like DNS or simple HTTP, this works well and we have used it in production on high-traffic authoritative DNS servers.

# 3-backend round-robin DNAT
iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 80 \
  -m statistic --mode random --probability 0.33 \
  -j DNAT --to-destination 192.168.1.10:80

iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 80 \
  -m statistic --mode random --probability 0.50 \
  -j DNAT --to-destination 192.168.1.11:80

iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 80 \
  -j DNAT --to-destination 192.168.1.12:80
// advertisement

Hairpin NAT: LAN Clients Reaching Internal Servers by Public IP

Hairpin NAT (also called NAT loopback or NAT reflection) solves the problem where LAN clients try to reach an internal server using the router's public IP. Without hairpin NAT, the packet goes to the router, gets DNAT'd to the internal server, but the server sees the source as the LAN client's private IP and sends the reply directly back to the client - bypassing the router. The client rejects this reply because it expects it from the public IP, not from 192.168.1.50.

The fix is to also MASQUERADE the source IP of hairpin packets so the backend server sends replies back through the router. This requires matching packets that arrive on the LAN interface, are headed for the public IP, and will be forwarded to an internal host.

Note: this only applies to traffic originating from the LAN side (eth1 in this example). Traffic from the WAN (eth0) follows the normal DNAT path.

# Assume: eth0=WAN (203.0.113.10), eth1=LAN, internal server=192.168.1.50:80
# Step 1: DNAT inbound (already configured above)
iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 80 -j DNAT --to-destination 192.168.1.50:80

# Step 2: Hairpin DNAT for LAN clients hitting the public IP
iptables -t nat -A PREROUTING -i eth1 -p tcp -d 203.0.113.10 --dport 80 \
  -j DNAT --to-destination 192.168.1.50:80

# Step 3: Masquerade hairpin traffic so server replies go through the router
iptables -t nat -A POSTROUTING -o eth1 -p tcp -d 192.168.1.50 --dport 80 \
  -j MASQUERADE

# Step 4: Allow the forward
iptables -A FORWARD -i eth1 -o eth1 -p tcp -d 192.168.1.50 --dport 80 \
  -m conntrack --ctstate NEW,ESTABLISHED,RELATED -j ACCEPT

Saving and Restoring iptables Rules

iptables rules are in-memory only. A reboot or 'iptables -F' wipes them. Use iptables-save to dump the current ruleset and iptables-restore to reload it. On systemd systems, the preferred approach is to write the rules to a file and create a systemd unit or use iptables-persistent (Debian/Ubuntu) or iptables-services (RHEL/Rocky).

For Debian/Ubuntu, install iptables-persistent and run 'netfilter-persistent save'. This writes rules to /etc/iptables/rules.v4 and /etc/iptables/rules.v6 and restores them at boot via a systemd service.

For RHEL 9 / Rocky Linux 9, nftables is the default and iptables is a compatibility shim. If you are on RHEL 9 and starting fresh, consider writing your NAT rules in nftables syntax instead - the iptables-legacy binary still works but is not the recommended path forward on those systems.

For DevOps teams managing firewall rules across many servers, tools like Ansible or purpose-built automation platforms such as taskbotshub.ai can template and distribute iptables rule files at scale, version-control the rulesets, and apply changes idempotently without manual SSH sessions.

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

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

# Debian/Ubuntu: install and save persistently
apt install iptables-persistent
netfilter-persistent save
systemctl enable netfilter-persistent

# RHEL 8 / Rocky 8
yum install iptables-services
service iptables save
systemctl enable iptables

# Verify saved rules include the nat table
grep -A5 '\*nat' /etc/iptables/rules.v4

Debugging NAT: conntrack and Packet Tracing

When NAT is not working, the first tool to reach for is conntrack. The conntrack utility reads /proc/net/nf_conntrack and shows active connection translation state. If a connection is not listed, NAT rules were not matched or conntrack is not tracking the protocol.

For deeper debugging, use the TRACE target in the raw table. This logs every table/chain/rule that a packet matches, which tells you exactly where a packet is being accepted, dropped, or translated. Enable it for a specific source IP to avoid flooding the log.

The TRACE output appears in the kernel log (dmesg or journalctl -k). Each line shows the table, chain, rule number, and action. Cross-reference rule numbers with 'iptables -t nat -L --line-numbers' to identify the exact rule.

Also check that you have not set a restrictive conntrack_max. On high-traffic systems, /proc/sys/net/netfilter/nf_conntrack_max being too low causes conntrack to drop tracking new connections, which silently breaks NAT for new flows.

# Install conntrack utilities
apt install conntrack   # Debian/Ubuntu
yum install conntrack-tools   # RHEL/Rocky

# Show all NAT'd connections
conntrack -L -n

# Filter for a specific host
conntrack -L | grep 192.168.1.50

# Enable TRACE for packets from a specific source
iptables -t raw -A PREROUTING -s 203.0.113.99 -j TRACE
iptables -t raw -A OUTPUT -d 203.0.113.99 -j TRACE

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

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

# Increase if count is near max
echo 131072 > /proc/sys/net/netfilter/nf_conntrack_max
// advertisement

NAT with Network Namespaces: Testing Without Physical Hardware

Network namespaces let you build a complete multi-interface NAT environment on a single machine for testing. We use this regularly on our test server before deploying rule changes to production. Two namespaces connected by a veth pair can simulate a router with LAN and WAN sides.

The namespace 'client' simulates a LAN machine. The default namespace acts as the router. With the veth pair connected and NAT rules applied in the default namespace, you can test that MASQUERADE, DNAT, and hairpin configurations behave exactly as expected before touching production hardware.

This approach also works well for automated CI tests of firewall configurations. A pipeline that creates namespaces, applies rules, runs curl or netcat to validate connectivity, and then tears everything down takes about 10 seconds and catches regressions before deployment.

# Create a client namespace
ip netns add client

# Create a veth pair: veth0 stays in default ns, veth1 goes to client ns
ip link add veth0 type veth peer name veth1
ip link set veth1 netns client

# Configure addresses
ip addr add 10.99.0.1/24 dev veth0
ip link set veth0 up
ip netns exec client ip addr add 10.99.0.2/24 dev veth1
ip netns exec client ip link set veth1 up
ip netns exec client ip route add default via 10.99.0.1

# Enable forwarding and apply MASQUERADE in default namespace
echo 1 > /proc/sys/net/ipv4/ip_forward
iptables -t nat -A POSTROUTING -s 10.99.0.0/24 -o eth0 -j MASQUERADE
iptables -A FORWARD -i veth0 -o eth0 -j ACCEPT
iptables -A FORWARD -i eth0 -o veth0 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

# Test from the client namespace
ip netns exec client curl -s https://example.com | head -5

# Cleanup
ip netns del client
ip link del veth0

iptables NAT vs nftables: When to Switch

On RHEL 9, Rocky Linux 9, and Ubuntu 22.04+, the iptables command is actually iptables-nft - a translation layer that converts iptables syntax to nftables rules internally. You can confirm this with 'iptables --version', which will show '(nf_tables)' rather than '(legacy)'.

The practical consequence: iptables-nft and native nftables rules coexist in the same kernel subsystem. If you mix iptables-legacy (which some Docker versions still use) with iptables-nft on the same machine, you can get duplicate or conflicting rule processing. Check which backend your tooling uses before deploying.

For new deployments in 2026, we recommend writing NAT rules in native nftables syntax on RHEL 9+ and Ubuntu 22.04+. The equivalent nftables config for MASQUERADE is roughly 8 lines in a table/chain block and is significantly more readable than an iptables script. iptables-legacy remains appropriate if you are managing systems running kernel 4.x or if your configuration management tooling generates iptables syntax and a migration is not worth the risk.

For teams evaluating their firewall tooling, consistent naming conventions matter when you have dozens of chains and tables. The same discipline applies to naming your infrastructure projects or services - the same care you would put into naming a domain via a registrar like nicename.me applies to naming your nftables tables and chains: names should be unambiguous and reflect function.

# Check which iptables backend is in use
iptables --version
# Output examples:
# iptables v1.8.9 (nf_tables)   <- nftables backend
# iptables v1.8.9 (legacy)      <- legacy backend

# Equivalent nftables config for MASQUERADE
nft add table ip nat
nft add chain ip nat postrouting '{ type nat hook postrouting priority srcnat; policy accept; }'
nft add rule ip nat postrouting ip saddr 192.168.1.0/24 oif eth0 masquerade

# View nftables ruleset
nft list ruleset

# Check if Docker is using iptables-legacy (can conflict)
iptables-legacy -t nat -L -n 2>/dev/null | head -5