IP Routing Fundamentals
Every host and router maintains a routing table — a set of rules that determine where to send packets based on their destination IP address. When a packet arrives, the kernel performs a longest-prefix match against the table: the most specific matching route wins.
The Routing Table
# Display the routing table (modern iproute2)
ip route show
# Typical output:
# default via 192.168.1.1 dev eth0 proto dhcp metric 100
# 10.0.0.0/24 dev docker0 proto kernel scope link src 10.0.0.1
# 172.17.0.0/16 dev br-abc123 proto kernel scope link src 172.17.0.1
# 192.168.1.0/24 dev eth0 proto kernel scope link src 192.168.1.50
# Show the route a specific packet would take
ip route get 8.8.8.8
# 8.8.8.8 via 192.168.1.1 dev eth0 src 192.168.1.50
# Legacy command (still works)
route -n
netstat -rn
Each route entry specifies: a destination network (CIDR), a gateway (next hop) or direct link, an interface (dev), a metric (lower = preferred), and a protocol (how the route was learned — kernel, dhcp, static, or a routing daemon).
Static Routes
# Add a static route: traffic to 10.20.0.0/16 goes via gateway 192.168.1.254
sudo ip route add 10.20.0.0/16 via 192.168.1.254 dev eth0
# Add a route to a single host
sudo ip route add 10.99.1.5/32 via 192.168.1.254
# Delete a route
sudo ip route del 10.20.0.0/16
# Replace existing route (add or update)
sudo ip route replace 10.20.0.0/16 via 192.168.1.253 dev eth0
# Change default gateway
sudo ip route replace default via 192.168.1.254 dev eth0
# Make routes persist across reboots (Debian/Ubuntu)
# Add to /etc/network/interfaces or /etc/netplan/*.yaml:
# routes:
# - to: 10.20.0.0/16
# via: 192.168.1.254
sysctl -w net.ipv4.ip_forward=1. Without this, NAT, VPNs, and container networking won't work.
# Enable IP forwarding (required for routing/NAT)
# Check current state
sysctl net.ipv4.ip_forward
# 0 = disabled, 1 = enabled
# Enable temporarily (lost on reboot)
sudo sysctl -w net.ipv4.ip_forward=1
# Enable permanently
echo "net.ipv4.ip_forward = 1" | sudo tee -a /etc/sysctl.d/99-routing.conf
sudo sysctl --system
NAT — Network Address Translation
NAT rewrites IP addresses (and often ports) in packet headers as they traverse a router. It allows multiple private hosts to share a single public IP, and enables port forwarding from external IPs to internal services.
flowchart LR
A["Host A
192.168.1.10:4500"] --> R["Router / NAT Gateway
Public: 203.0.113.5"]
B["Host B
192.168.1.11:4501"] --> R
C["Host C
192.168.1.12:4502"] --> R
R --> I["Internet
Destination Server"]
R -.- T["NAT Table:
192.168.1.10:4500 ↔ 203.0.113.5:30001
192.168.1.11:4501 ↔ 203.0.113.5:30002
192.168.1.12:4502 ↔ 203.0.113.5:30003"]
SNAT vs DNAT
| Type | Direction | Chain | Use Case |
|---|---|---|---|
| SNAT | Outgoing — rewrites source IP | POSTROUTING | Many internal hosts sharing one public IP |
| DNAT | Incoming — rewrites destination IP | PREROUTING | Port forwarding: external port → internal service |
| MASQUERADE | Outgoing — dynamic SNAT | POSTROUTING | Like SNAT but auto-detects outbound IP (for DHCP/dynamic IPs) |
Masquerading
# Enable masquerading: hosts on 192.168.1.0/24 share the public IP on eth0
sudo iptables -t nat -A POSTROUTING -s 192.168.1.0/24 -o eth0 -j MASQUERADE
# SNAT (static public IP — slightly faster than MASQUERADE)
sudo iptables -t nat -A POSTROUTING -s 192.168.1.0/24 -o eth0 -j SNAT --to-source 203.0.113.5
# DNAT — port forward: public port 8080 → internal host 192.168.1.20:80
sudo iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 8080 -j DNAT --to-destination 192.168.1.20:80
# Also need to allow the forwarded traffic:
sudo iptables -A FORWARD -p tcp -d 192.168.1.20 --dport 80 -j ACCEPT
# View current NAT rules
sudo iptables -t nat -L -n -v --line-numbers
# View active NAT connection tracking entries
sudo conntrack -L | head -20
Netfilter & iptables
Netfilter is the kernel framework that provides hooks at five points in the packet processing path. iptables is the user-space tool that inserts rules at those hooks. Rules are organized into tables (by function) and chains (by hook point).
flowchart TD
IN["Incoming Packet"] --> PRE["PREROUTING
(raw → mangle → nat)"]
PRE --> RD{"Routing
Decision"}
RD -->|"For this host"| INPUT["INPUT
(mangle → filter)"]
RD -->|"Forward to another host"| FWD["FORWARD
(mangle → filter)"]
INPUT --> APP["Local Process"]
APP --> OUTPUT["OUTPUT
(raw → mangle → nat → filter)"]
OUTPUT --> POST["POSTROUTING
(mangle → nat)"]
FWD --> POST
POST --> OUT["Outgoing Packet"]
Chains & Tables
| Table | Purpose | Chains Available |
|---|---|---|
| filter (default) | Accept/drop/reject packets | INPUT, FORWARD, OUTPUT |
| nat | Network Address Translation | PREROUTING, OUTPUT, POSTROUTING |
| mangle | Alter packet headers (TTL, TOS, mark) | All five chains |
| raw | Bypass connection tracking | PREROUTING, OUTPUT |
Writing Rules
# List all rules in the filter table (verbose, numeric)
sudo iptables -L -n -v --line-numbers
# Set default policy: drop everything not explicitly allowed
sudo iptables -P INPUT DROP
sudo iptables -P FORWARD DROP
sudo iptables -P OUTPUT ACCEPT
# Allow loopback traffic (critical — many services use localhost)
sudo iptables -A INPUT -i lo -j ACCEPT
# Allow established and related connections (stateful tracking)
sudo iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
# Allow SSH from a specific subnet
sudo iptables -A INPUT -p tcp -s 10.0.0.0/8 --dport 22 -j ACCEPT
# Allow HTTP and HTTPS from anywhere
sudo iptables -A INPUT -p tcp -m multiport --dports 80,443 -j ACCEPT
# Rate-limit ICMP (ping) — max 1/second, burst of 4
sudo iptables -A INPUT -p icmp --icmp-type echo-request -m limit --limit 1/s --limit-burst 4 -j ACCEPT
# Log and drop everything else
sudo iptables -A INPUT -j LOG --log-prefix "IPT-DROP: " --log-level 4
sudo iptables -A INPUT -j DROP
# Delete a rule by line number (from --line-numbers output)
sudo iptables -D INPUT 5
# Flush all rules (start fresh)
sudo iptables -F
DROP silently discards the packet — the sender gets no response and must wait for a timeout. REJECT sends back an ICMP "port unreachable" or TCP RST, immediately telling the sender the port is closed. Use DROP for external-facing rules (gives attackers no information), use REJECT for internal/trusted networks (faster failure for legitimate clients).
nftables — The Modern Replacement
nftables replaces iptables, ip6tables, arptables, and ebtables with a single unified framework. It uses a cleaner syntax, supports sets and maps natively, and is the default on Debian 10+, RHEL 8+, and Ubuntu 20.04+.
# List all nftables rules
sudo nft list ruleset
# Create a table and chain (equivalent to iptables filter/INPUT)
sudo nft add table inet myfilter
sudo nft add chain inet myfilter input { type filter hook input priority 0 \; policy drop \; }
# Allow loopback
sudo nft add rule inet myfilter input iif lo accept
# Allow established/related connections
sudo nft add rule inet myfilter input ct state established,related accept
# Allow SSH from 10.0.0.0/8
sudo nft add rule inet myfilter input ip saddr 10.0.0.0/8 tcp dport 22 accept
# Allow HTTP/HTTPS using a set
sudo nft add rule inet myfilter input tcp dport { 80, 443 } accept
# Rate-limit ICMP
sudo nft add rule inet myfilter input icmp type echo-request limit rate 1/second accept
# Log and count dropped packets
sudo nft add rule inet myfilter input counter log prefix \"NFT-DROP: \" drop
# NAT with nftables: masquerade outgoing traffic
sudo nft add table ip nat
sudo nft add chain ip nat postrouting { type nat hook postrouting priority 100 \; }
sudo nft add rule ip nat postrouting oifname "eth0" masquerade
# Port forward (DNAT) with nftables
sudo nft add chain ip nat prerouting { type nat hook prerouting priority -100 \; }
sudo nft add rule ip nat prerouting iifname "eth0" tcp dport 8080 dnat to 192.168.1.20:80
# Save nftables rules persistently
sudo nft list ruleset > /etc/nftables.conf
sudo systemctl enable nftables
Practical Firewall Patterns
How Docker Uses iptables for Container Networking
Docker automatically manages iptables rules for container networking. When you run docker run -p 8080:80 nginx, Docker creates:
- docker0 bridge: A virtual bridge (172.17.0.0/16) connecting all containers on the default network
- MASQUERADE: In the nat/POSTROUTING chain — containers can reach the internet via the host's IP
- DNAT: In a custom
DOCKERchain — maps host port 8080 to container's internal IP:80 - FORWARD rules: In the
DOCKER-USERandDOCKERchains — allow traffic between host ports and container ports
Inspect Docker's rules with: sudo iptables -t nat -L DOCKER -n -v and sudo iptables -L DOCKER-USER -n -v. If you add custom iptables rules, put them in DOCKER-USER — Docker won't overwrite that chain.
# Inspect Docker's iptables rules
sudo iptables -t nat -L -n -v | grep -A 5 "Chain DOCKER"
sudo iptables -L FORWARD -n -v | head -20
# See the docker0 bridge and container IPs
ip addr show docker0
docker network inspect bridge | grep -A 5 "Containers"
# Docker's NAT chain (port mappings live here)
sudo iptables -t nat -L DOCKER -n --line-numbers
# Example output:
# 1 DNAT tcp -- !docker0 * 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.17.0.2:80
# Custom rules go in DOCKER-USER (Docker respects this chain)
sudo iptables -I DOCKER-USER -s 192.168.1.0/24 -j ACCEPT
sudo iptables -A DOCKER-USER -j DROP
# === Complete Minimal Server Firewall (iptables) ===
#!/bin/bash
# Flush existing rules
iptables -F
iptables -X
iptables -t nat -F
# Default policies: deny incoming, allow outgoing
iptables -P INPUT DROP
iptables -P FORWARD DROP
iptables -P OUTPUT ACCEPT
# Loopback
iptables -A INPUT -i lo -j ACCEPT
# Stateful: allow established connections
iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
# SSH (from management network only)
iptables -A INPUT -p tcp -s 10.0.0.0/8 --dport 22 -j ACCEPT
# Web traffic
iptables -A INPUT -p tcp -m multiport --dports 80,443 -j ACCEPT
# ICMP (ping) — limited
iptables -A INPUT -p icmp --icmp-type echo-request -m limit --limit 1/s -j ACCEPT
# Drop invalid packets
iptables -A INPUT -m conntrack --ctstate INVALID -j DROP
# Log remaining drops (check with: dmesg | grep IPT-DROP)
iptables -A INPUT -j LOG --log-prefix "IPT-DROP: "
iptables -A INPUT -j DROP
echo "Firewall rules applied."
iptables -L -n -v --line-numbers
Exercises
# Exercise 1: View your routing table and identify the default gateway
ip route show
ip route get 1.1.1.1
# Exercise 2: Add and remove a static route
sudo ip route add 10.99.0.0/16 via $(ip route | grep default | awk '{print $3}')
ip route show | grep 10.99
sudo ip route del 10.99.0.0/16
# Exercise 3: Check if IP forwarding is enabled
sysctl net.ipv4.ip_forward
# Exercise 4: List current iptables rules
sudo iptables -L -n -v --line-numbers
sudo iptables -t nat -L -n -v
# Exercise 5: Test nftables (if available)
sudo nft list ruleset
# Exercise 6: View connection tracking table (active NAT sessions)
sudo conntrack -L 2>/dev/null | wc -l || echo "conntrack not installed"
Conclusion & Next Steps
Routing tables determine where packets go (longest-prefix match, default gateway, static routes). NAT (SNAT/DNAT/MASQUERADE) allows private networks to reach the public internet and exposes internal services via port forwarding. Netfilter's hook-based architecture gives iptables (and its successor nftables) fine-grained control at every point in the packet's journey through the kernel. Understanding the chain order — PREROUTING → routing decision → INPUT/FORWARD → OUTPUT → POSTROUTING — is the key to debugging any Linux firewall or NAT issue.