Back to Computing & Systems Foundations Series

Part 14: Routing, NAT & Linux Firewalls

May 13, 2026Wasil Zafar19 min read

Routing is the mechanism that connects networks — it determines how every packet traverses from source to destination across multiple hops, and firewalls decide which packets are allowed through.

Table of Contents

  1. IP Routing Fundamentals
  2. NAT — Network Address Translation
  3. Netfilter & iptables
  4. nftables — The Modern Replacement
  5. Practical Firewall Patterns
  6. Exercises
  7. Conclusion

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
Key Insight: Linux must have IP forwarding enabled to act as a router (forward packets between interfaces). By default it's disabled — the kernel drops packets not destined for its own IPs. Enable with: 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.

NAT: Private Network → Public Internet
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

TypeDirectionChainUse Case
SNATOutgoing — rewrites source IPPOSTROUTINGMany internal hosts sharing one public IP
DNATIncoming — rewrites destination IPPREROUTINGPort forwarding: external port → internal service
MASQUERADEOutgoing — dynamic SNATPOSTROUTINGLike 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).

Netfilter Packet Flow & Hook Points
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

TablePurposeChains Available
filter (default)Accept/drop/reject packetsINPUT, FORWARD, OUTPUT
natNetwork Address TranslationPREROUTING, OUTPUT, POSTROUTING
mangleAlter packet headers (TTL, TOS, mark)All five chains
rawBypass connection trackingPREROUTING, OUTPUT
Rule Order Matters — First Match Wins: iptables processes rules top-to-bottom within each chain. The first matching rule determines the packet's fate. If you place a broad ACCEPT before a specific DROP, the DROP will never fire. Always place more specific rules before general ones, and end each chain with a default DROP policy for a secure-by-default posture.

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 vs REJECT: 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

Container Networking

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 DOCKER chain — maps host port 8080 to container's internal IP:80
  • FORWARD rules: In the DOCKER-USER and DOCKER chains — 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.

DockeriptablesMASQUERADEDNAT
# 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.