The Linux kernel has a powerful packet filtering framework built in, but the userspace tools for managing it have evolved significantly over the years. Today, administrators face a choice between iptables (the legacy standard), nftables (its modern replacement), firewalld (a dynamic management layer), and UFW (a simplicity-focused frontend). Each has its strengths, and understanding the differences is critical for building effective firewall rules -- especially on Docker hosts where container networking adds significant complexity.

Architecture Overview

All Linux firewall tools ultimately interact with the same kernel framework. Understanding the architecture helps you choose the right tool:

Tool Backend Default On Strengths
iptables xtables (netfilter) Debian, Ubuntu (legacy) Universal knowledge base, extensive documentation
nftables nf_tables (netfilter) Debian 10+, Arch, Fedora Modern syntax, atomic rule replacement, better performance
firewalld nftables or iptables RHEL, CentOS, Fedora Zone-based, dynamic rules, D-Bus API
ufw iptables Ubuntu Simple syntax, beginner-friendly

Note: On modern systems, the iptables command often uses the iptables-nft backend, which translates iptables syntax to nftables rules internally. Check with iptables --version -- if it says nf_tables, you are already using nftables under the hood.

iptables: Chains and Rules

iptables organizes rules into tables and chains. The most commonly used table is filter, which contains three default chains:

  • INPUT -- Packets destined for the local machine
  • FORWARD -- Packets being routed through the machine
  • OUTPUT -- Packets generated by the local machine
# View current rules
iptables -L -n -v            # List rules with line numbers
iptables -L -n --line-numbers

# Set default policies
iptables -P INPUT DROP
iptables -P FORWARD DROP
iptables -P OUTPUT ACCEPT

# Allow loopback
iptables -A INPUT -i lo -j ACCEPT
iptables -A OUTPUT -o lo -j ACCEPT

# Allow established and related connections
iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

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

# Allow SSH
iptables -A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW -j ACCEPT

# Allow HTTP/HTTPS
iptables -A INPUT -p tcp -m multiport --dports 80,443 \
  -m conntrack --ctstate NEW -j ACCEPT

# Allow ICMP (ping)
iptables -A INPUT -p icmp --icmp-type echo-request \
  -m limit --limit 5/sec -j ACCEPT

# Log dropped packets
iptables -A INPUT -m limit --limit 5/min \
  -j LOG --log-prefix "iptables-drop: " --log-level 4

# Drop everything else (redundant with policy but explicit)
iptables -A INPUT -j DROP

Rate Limiting with iptables

# Limit SSH connections to 3 per minute per source IP
iptables -A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW \
  -m recent --set --name SSH --rsource
iptables -A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW \
  -m recent --update --seconds 60 --hitcount 4 --name SSH --rsource -j DROP
iptables -A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW -j ACCEPT

# Rate limit HTTP connections
iptables -A INPUT -p tcp --dport 80 -m conntrack --ctstate NEW \
  -m limit --limit 100/sec --limit-burst 200 -j ACCEPT
iptables -A INPUT -p tcp --dport 80 -m conntrack --ctstate NEW -j DROP

Port Forwarding with iptables

# Enable IP forwarding
echo 1 > /proc/sys/net/ipv4/ip_forward

# Forward external port 8080 to internal host 192.168.1.100:80
iptables -t nat -A PREROUTING -p tcp --dport 8080 \
  -j DNAT --to-destination 192.168.1.100:80
iptables -t nat -A POSTROUTING -p tcp -d 192.168.1.100 --dport 80 \
  -j MASQUERADE
iptables -A FORWARD -p tcp -d 192.168.1.100 --dport 80 -j ACCEPT

Saving and Restoring Rules

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

# Restore rules (typically done at boot)
iptables-restore < /etc/iptables/rules.v4

# On Debian/Ubuntu, install iptables-persistent
apt install iptables-persistent
# Rules auto-loaded from /etc/iptables/rules.v4 on boot

nftables: The Modern Replacement

nftables replaces iptables, ip6tables, arptables, and ebtables with a single tool. Its syntax is cleaner, and it supports atomic rule replacement (all rules are applied at once, so there is no window where rules are partially loaded):

#!/usr/sbin/nft -f
# /etc/nftables.conf

flush ruleset

table inet filter {
    chain input {
        type filter hook input priority 0; policy drop;

        # Connection tracking
        ct state established,related accept
        ct state invalid drop

        # Loopback
        iif lo accept

        # ICMP rate limited
        ip protocol icmp icmp type echo-request \
            limit rate 5/second accept
        ip6 nexthdr icmpv6 icmpv6 type { echo-request, nd-neighbor-solicit, nd-router-advert } \
            limit rate 5/second accept

        # SSH rate limited
        tcp dport 22 ct state new limit rate 3/minute accept

        # Web traffic
        tcp dport { 80, 443 } accept

        # Custom application ports
        tcp dport { 8080, 9090 } accept

        # Logging (rate limited)
        limit rate 5/minute log prefix "nft-drop: " level warn
    }

    chain forward {
        type filter hook forward priority 0; policy drop;
        # Docker will insert its rules here
    }

    chain output {
        type filter hook output priority 0; policy accept;
    }
}

# NAT table for port forwarding
table inet nat {
    chain prerouting {
        type nat hook prerouting priority -100;
        tcp dport 8080 dnat to 192.168.1.100:80
    }

    chain postrouting {
        type nat hook postrouting priority 100;
        oifname "eth0" masquerade
    }
}
# Apply rules
nft -f /etc/nftables.conf

# List all rules
nft list ruleset

# Add a rule interactively
nft add rule inet filter input tcp dport 3306 accept

# Delete a rule (by handle)
nft -a list chain inet filter input   # Show handles
nft delete rule inet filter input handle 15

# Enable on boot
systemctl enable nftables

nftables Sets and Maps

One of nftables' most powerful features is named sets -- allowing you to group IPs, ports, or interfaces:

table inet filter {
    set blocked_ips {
        type ipv4_addr
        flags timeout
        elements = { 192.168.1.200 timeout 1h, 10.0.0.50 timeout 24h }
    }

    set allowed_ports {
        type inet_service
        elements = { 22, 80, 443, 8080 }
    }

    chain input {
        type filter hook input priority 0; policy drop;

        # Drop blocked IPs
        ip saddr @blocked_ips drop

        # Allow connections to allowed ports
        tcp dport @allowed_ports accept

        ct state established,related accept
        iif lo accept
    }
}
# Add to a set dynamically
nft add element inet filter blocked_ips { 10.0.0.100 timeout 2h }

# Remove from a set
nft delete element inet filter blocked_ips { 10.0.0.100 }

firewalld: Zone-Based Firewall

firewalld uses the concept of zones, where each zone represents a trust level. Network interfaces are assigned to zones, and rules are defined per zone:

# Check status
firewall-cmd --state

# List zones
firewall-cmd --get-zones

# List active zones
firewall-cmd --get-active-zones

# Show rules for a zone
firewall-cmd --zone=public --list-all

# Add a service
firewall-cmd --zone=public --add-service=http --permanent
firewall-cmd --zone=public --add-service=https --permanent

# Add a custom port
firewall-cmd --zone=public --add-port=8080/tcp --permanent

# Remove a service
firewall-cmd --zone=public --remove-service=ssh --permanent

# Reload to apply permanent rules
firewall-cmd --reload

# Rich rules for complex logic
firewall-cmd --zone=public --add-rich-rule='
  rule family="ipv4" source address="192.168.1.0/24"
  service name="ssh" accept' --permanent

# Rate limiting with rich rules
firewall-cmd --zone=public --add-rich-rule='
  rule service name="http" limit value="100/m" accept' --permanent

# Port forwarding
firewall-cmd --zone=public --add-forward-port=\
  port=8080:proto=tcp:toaddr=192.168.1.100:toport=80 --permanent

UFW: Uncomplicated Firewall

# Enable UFW
ufw enable

# Default policies
ufw default deny incoming
ufw default allow outgoing

# Allow SSH
ufw allow 22/tcp

# Allow HTTP/HTTPS
ufw allow 80/tcp
ufw allow 443/tcp

# Allow from specific IP
ufw allow from 192.168.1.0/24 to any port 22

# Rate limiting
ufw limit ssh

# Check status
ufw status verbose

# Delete a rule
ufw delete allow 80/tcp

# Application profiles
ufw app list
ufw allow 'Nginx Full'

Docker and Firewall Interaction

This is where many administrators get burned. Docker manipulates iptables/nftables directly to set up container networking, and it does so in ways that can bypass your firewall rules entirely:

Warning: By default, Docker inserts its own rules into the FORWARD chain and the nat table. This means that publishing a port with -p 8080:80 makes that port accessible from the network regardless of your INPUT chain rules. Your carefully crafted firewall is effectively bypassed for container ports.
# See Docker's iptables rules
iptables -L DOCKER -n -v
iptables -L DOCKER-USER -n -v
iptables -t nat -L -n -v

Solution 1: Use DOCKER-USER Chain

Docker provides the DOCKER-USER chain specifically for user rules. Rules here are processed before Docker's own rules:

# Restrict access to Docker containers from external networks
iptables -I DOCKER-USER -i eth0 -j DROP
iptables -I DOCKER-USER -i eth0 -s 192.168.1.0/24 -j RETURN
iptables -I DOCKER-USER -i eth0 -m conntrack \
  --ctstate ESTABLISHED,RELATED -j RETURN

Solution 2: Disable Docker iptables Management

# In /etc/docker/daemon.json
{
  "iptables": false
}

# You must now manually configure NAT and forwarding for containers
# This is more work but gives you full control

Solution 3: Bind to Localhost Only

# Instead of publishing to all interfaces:
# docker run -p 8080:80 myapp

# Bind to localhost only:
docker run -p 127.0.0.1:8080:80 myapp

# Then use a reverse proxy (nginx/traefik) to expose services
# The reverse proxy handles TLS and is controlled by your firewall

Choosing the Right Tool

Scenario Recommended Reason
Simple server (few rules) UFW Minimal syntax, quick to configure
Production server nftables Atomic loading, sets, modern syntax
RHEL/CentOS environment firewalld Default, well-integrated, zone model
Docker host nftables + DOCKER-USER Best control over Docker interaction
Legacy systems iptables Broadest compatibility
Multi-zone networks firewalld or nftables Zone model or sets for organized rules
Tip: Regardless of which tool you choose, always test your rules from an external machine before closing your SSH session. A firewall misconfiguration that locks you out of a remote server is one of the most common and preventable operations disasters.

When managing Docker containers across multiple servers with usulnet, understanding the firewall landscape on each host is essential. Container port mappings interact directly with the host firewall, and misconfigurations can either expose services unintentionally or block them unexpectedly.

The golden rule of firewalling: Default deny, explicit allow. Start by dropping everything, then add rules only for traffic you explicitly need. Every open port is a potential attack surface. Every rule you add should be documented and justified.