Mastodon

Linux Firewalls: How to Actually Secure a Cloud Server (iptables, nftables, firewalld, ufw)



Logo

You’ve just provisioned a fresh cloud server. It has a public IPv4 address, maybe an IPv6 range, and exactly zero protection between it and the internet’s endless stream of SSH brute-force bots, port scanners, and whatever that traffic on port 5900 is. The clock is ticking - within minutes, your auth log will start filling up with connection attempts from IP addresses you’ve never heard of in countries you’ve never visited.

Linux gives you several ways to build a firewall between your server and this chaos. The problem isn’t a lack of options - it’s too many. iptables, nftables, firewalld, ufw - they all filter packets, but they approach the job differently, target different audiences, and impose different mental models. This guide walks through all four with real configurations you can actually deploy on a freshly provisioned cloud server.

(And yes, I know - I’ve spent most of this blog praising PF on FreeBSD as the pinnacle of firewall design. PF’s syntax is cleaner, its stateful inspection is more elegant, and writing pf.conf genuinely brings me joy. But sometimes you’re handed a Linux box and told to make it safe. So here we are, slumming it on the other side of the fence. Let’s make the best of it.)

The Landscape: How We Got Here

Linux packet filtering has a history that mirrors Linux itself - organic, layered, and occasionally contradictory.

ipfwadm (1994) was the first Linux firewall, basic and limited. ipchains (1999, Linux 2.2) replaced it with chain-based filtering. iptables (2001, Linux 2.4) brought stateful inspection and became the dominant firewall for two decades. nftables (2014, Linux 3.13) was designed as iptables’ successor, with a unified framework and better performance. firewalld and ufw sit on top, providing higher-level abstractions.

The key thing to understand: all of these ultimately talk to Netfilter, the packet filtering framework in the Linux kernel. iptables and nftables are different interfaces to the same underlying machinery. firewalld and ufw are frontends that generate iptables or nftables rules for you. The packets don’t care which tool wrote the rules - they get filtered either way.

iptables: The Classic

iptables has been the standard Linux firewall since 2001. If you’ve administered a Linux server in the last twenty years, you’ve encountered it. Every tutorial, every Stack Overflow answer, every “how to set up a VPS” blog post assumes iptables.

The Mental Model

iptables organizes rules into tables and chains. The default table (filter) contains three built-in chains:

  • INPUT: packets destined for this machine
  • FORWARD: packets being routed through this machine
  • OUTPUT: packets originating from this machine

Each chain has a policy (default action) and a list of rules evaluated in order. The first matching rule wins.

Securing a Cloud Server with iptables

Here’s a complete iptables configuration for a cloud server running a web application and SSH:

#!/bin/bash
# Cloud server firewall - iptables
# Flush existing rules
iptables -F
iptables -X
ip6tables -F
ip6tables -X

# Default policies: drop everything, allow outbound
iptables -P INPUT DROP
iptables -P FORWARD DROP
iptables -P OUTPUT ACCEPT

ip6tables -P INPUT DROP
ip6tables -P FORWARD DROP
ip6tables -P OUTPUT ACCEPT

# Allow loopback
iptables -A INPUT -i lo -j ACCEPT
ip6tables -A INPUT -i lo -j ACCEPT

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

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

# SSH: rate-limited to slow down brute-force attempts
iptables -A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW \
    -m recent --set --name SSH
iptables -A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW \
    -m recent --update --seconds 60 --hitcount 4 --name SSH -j DROP
iptables -A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW -j ACCEPT

ip6tables -A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW \
    -m recent --set --name SSH
ip6tables -A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW \
    -m recent --update --seconds 60 --hitcount 4 --name SSH -j DROP
ip6tables -A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW -j ACCEPT

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

# ICMPv4: allow ping and necessary types
iptables -A INPUT -p icmp --icmp-type echo-request -j ACCEPT
iptables -A INPUT -p icmp --icmp-type destination-unreachable -j ACCEPT
iptables -A INPUT -p icmp --icmp-type time-exceeded -j ACCEPT

# ICMPv6: essential for IPv6 to function
ip6tables -A INPUT -p ipv6-icmp --icmpv6-type echo-request -j ACCEPT
ip6tables -A INPUT -p ipv6-icmp --icmpv6-type echo-reply -j ACCEPT
ip6tables -A INPUT -p ipv6-icmp --icmpv6-type destination-unreachable -j ACCEPT
ip6tables -A INPUT -p ipv6-icmp --icmpv6-type packet-too-big -j ACCEPT
ip6tables -A INPUT -p ipv6-icmp --icmpv6-type time-exceeded -j ACCEPT
ip6tables -A INPUT -p ipv6-icmp --icmpv6-type parameter-problem -j ACCEPT
ip6tables -A INPUT -p ipv6-icmp --icmpv6-type neighbour-solicitation -j ACCEPT
ip6tables -A INPUT -p ipv6-icmp --icmpv6-type neighbour-advertisement -j ACCEPT
ip6tables -A INPUT -p ipv6-icmp --icmpv6-type router-solicitation -j ACCEPT
ip6tables -A INPUT -p ipv6-icmp --icmpv6-type router-advertisement -j ACCEPT

# Log dropped packets (rate-limited to avoid log flooding)
iptables -A INPUT -m limit --limit 5/min -j LOG \
    --log-prefix "iptables-dropped: " --log-level 4
ip6tables -A INPUT -m limit --limit 5/min -j LOG \
    --log-prefix "ip6tables-dropped: " --log-level 4

Making Rules Persistent

iptables rules live in kernel memory - they vanish on reboot. Making them persistent depends on your distribution:

Debian/Ubuntu:

apt install iptables-persistent
netfilter-persistent save
netfilter-persistent reload

RHEL/Fedora (if not using firewalld):

dnf install iptables-services
systemctl enable iptables ip6tables
service iptables save
service ip6tables save

The rules are saved to /etc/iptables/rules.v4 and /etc/iptables/rules.v6 (Debian) or /etc/sysconfig/iptables and /etc/sysconfig/ip6tables (RHEL).

The Good and the Ugly

iptables works. It’s battle-tested, universally available, and documented to the point of exhaustion. But the syntax is verbose - especially with dual-stack configurations where you need to duplicate everything for IPv4 and IPv6. There’s no native concept of sets (you need ipset for that), and large rulesets become linear chains that the kernel evaluates rule by rule, which doesn’t scale elegantly.

Technically, iptables is considered deprecated in favor of nftables. Modern kernels include an iptables-nft compatibility layer that translates iptables commands into nftables rules behind the scenes. On many current distributions, when you run iptables, you’re actually running iptables-nft without knowing it. Check with iptables -V - if it says nf_tables, you’re already on the bridge.

nftables: The Modern Replacement

nftables is iptables’ designated successor, included in the Linux kernel since 3.13. It addresses iptables’ design limitations: a single tool handles both IPv4 and IPv6, the rule language is more expressive, and the kernel representation uses a virtual machine that evaluates rulesets more efficiently.

The Mental Model

nftables replaces the fixed table/chain structure with a flexible one you define yourself. You create your own tables, your own chains, and configure their hook points. This sounds more complex, but it means you can organize rules however makes sense for your infrastructure.

The Same Cloud Server, in nftables

#!/usr/sbin/nft -f

flush ruleset

table inet firewall {
    # Set for tracking SSH brute-force attempts
    set ssh_meter {
        type ipv4_addr
        flags dynamic
        timeout 60s
    }

    set ssh_meter6 {
        type ipv6_addr
        flags dynamic
        timeout 60s
    }

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

        # Loopback
        iif lo accept

        # Established/related connections
        ct state established,related accept

        # Drop invalid
        ct state invalid drop

        # SSH with rate limiting (max 4 new connections per 60 seconds)
        # The 'update' statement dynamically adds the source address to the set
        # and checks the rate limit in a single operation
        tcp dport 22 ct state new update @ssh_meter { ip saddr limit rate over 4/minute } drop
        tcp dport 22 ct state new update @ssh_meter6 { ip6 saddr limit rate over 4/minute } drop
        tcp dport 22 ct state new accept

        # HTTP/HTTPS
        tcp dport { 80, 443 } ct state new accept

        # ICMP and ICMPv6
        ip protocol icmp icmp type {
            echo-request,
            destination-unreachable,
            time-exceeded
        } accept

        ip6 nexthdr ipv6-icmp icmpv6 type {
            echo-request,
            echo-reply,
            destination-unreachable,
            packet-too-big,
            time-exceeded,
            parameter-problem,
            nd-neighbor-solicit,
            nd-neighbor-advert,
            nd-router-solicit,
            nd-router-advert
        } accept

        # Log dropped packets
        limit rate 5/minute log prefix "nft-dropped: " level warn
    }

    chain forward {
        type filter hook forward priority 0; policy drop;
    }

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

Notice the inet family - a single table handles both IPv4 and IPv6. No more duplicating every rule. The set syntax for rate limiting is built into nftables natively, no external ipset needed.

Loading and Persisting

# Validate syntax
nft -c -f /etc/nftables.conf

# Load rules
nft -f /etc/nftables.conf

# Enable at boot
systemctl enable nftables

The entire ruleset lives in /etc/nftables.conf. One file, one syntax, both address families. Compared to iptables’ separate save files for v4 and v6, this is a breath of fresh air.

Named Sets: IP Allowlists and Blocklists

One of nftables’ strongest features is native support for sets - named collections of addresses, ports, or interfaces that can be referenced in rules and updated at runtime:

table inet firewall {
    # Trusted management IPs
    set trusted_admins {
        type ipv4_addr
        elements = { 198.51.100.22, 203.0.113.50 }
    }

    set trusted_admins6 {
        type ipv6_addr
        flags interval
        elements = { 2001:db8:ffff::/48 }
    }

    # Blocklist - populated dynamically
    set blocklist {
        type ipv4_addr
        flags timeout
        timeout 24h
    }

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

        # Block known bad actors immediately
        ip saddr @blocklist drop

        # SSH only from trusted sources
        tcp dport 22 ip saddr @trusted_admins accept
        tcp dport 22 ip6 saddr @trusted_admins6 accept
        tcp dport 22 drop

        # ... rest of rules
    }
}

Manage sets at runtime without reloading the ruleset:

# Add to blocklist (auto-expires after 24h due to timeout flag)
nft add element inet firewall blocklist { 192.0.2.100 }

# Add with custom timeout
nft add element inet firewall blocklist { 192.0.2.101 timeout 1h }

# Remove from set
nft delete element inet firewall blocklist { 192.0.2.100 }

# List set contents
nft list set inet firewall blocklist

Why nftables Over iptables

  • Unified IPv4/IPv6: One ruleset, one syntax, one mental model
  • Native sets: No external ipset dependency
  • Better performance: Binary rules compiled into a virtual machine, not linear chain matching
  • Atomic rule replacement: Load an entire new ruleset atomically - no window where rules are partially loaded
  • Programmable Netlink interface: nftables exposes a Netlink socket (NETLINK_NETFILTER) that lets applications manage rules directly without shelling out to nft. Libraries like Google’s google/nftables for Go make this practical - you can build fully programmable firewall tooling, including things like injecting BPF into nftables rules. ngrok’s team used this approach to build firewall_toolkit, a host-level DDoS mitigation library
  • Cleaner syntax: Subjective, but ask anyone who’s written a 200-line iptables script

The learning curve is real if you’re coming from iptables, but I’ve found the transition is worth the effort. Once the nftables mental model clicks, going back to iptables feels like writing assembly after learning a proper language.

firewalld: The Enterprise Linux Firewall

firewalld is the default firewall manager on Fedora, RHEL, CentOS Stream, and their derivatives - which means it’s also the firewall running on the majority of production Linux servers in enterprise environments. Rather than asking you to write packet filter rules, firewalld introduces zones - named security contexts that you assign to network interfaces.

What sets firewalld apart from the other tools in this article is its architecture. It runs as a system daemon (firewalld.service) with a D-Bus API, which means any authorized application - Cockpit, NetworkManager, libvirt, Ansible, or your own tooling - can query and modify firewall rules programmatically without parsing text files or shelling out to command-line tools. It also cleanly separates runtime state (what’s active right now) from permanent configuration (what survives a reboot), giving you a built-in safety net for testing changes on remote systems.

The Mental Model

Zones represent trust levels. An interface in the public zone gets restrictive rules. An interface in the trusted zone allows everything. Instead of thinking about chains and rules, you think about “this interface is in this zone, and this zone allows these services.”

The predefined zones, from most restrictive to most permissive:

Zone Default Behavior
drop Drop all incoming, no reply sent
block Reject all incoming with ICMP response
public Only selected services allowed (default zone)
external Like public, with masquerading enabled
dmz Selected services, limited incoming
work Trust most networked machines
home Trust most networked machines
internal Trust most networked machines
trusted Accept everything

Cloud Server with firewalld

On RHEL/Fedora, firewalld is likely already running. Here’s how to configure it for our cloud server scenario:

# Check current state
firewall-cmd --state
firewall-cmd --get-active-zones

# Set the default zone
firewall-cmd --set-default-zone=public

# Allow SSH (usually already enabled in public zone)
firewall-cmd --zone=public --add-service=ssh --permanent

# Allow HTTP and HTTPS
firewall-cmd --zone=public --add-service=http --permanent
firewall-cmd --zone=public --add-service=https --permanent

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

# Verify
firewall-cmd --zone=public --list-all

Pro tip: Instead of adding --permanent to every command and then reloading, you can test rules in runtime first. Runtime rules are active immediately but disappear on reboot (or --reload). If you accidentally lock yourself out, a reboot reverts the damage. Once you’ve confirmed everything works, save the running state in one shot:

# Test in runtime first (no --permanent flag)
firewall-cmd --zone=public --add-service=http
firewall-cmd --zone=public --add-service=https

# Everything working? Save the current runtime state as permanent
firewall-cmd --runtime-to-permanent

This workflow is especially useful on remote servers where a typo in a permanent rule could mean a trip to the out-of-band console.

Output:

public (active)
  target: default
  icmp-block-inversion: no
  interfaces: eth0
  sources:
  services: dhcpv6-client http https ssh
  ports:
  protocols:
  forward: yes
  masquerade: no
  forward-ports:
  source-ports:
  rich-rules:

Service Definitions

firewalld uses XML service definitions in /usr/lib/firewalld/services/. Each service defines which ports and protocols it needs. When you add a “service” rather than a raw port, you’re working at a higher abstraction level:

# These are equivalent:
firewall-cmd --zone=public --add-service=https --permanent
firewall-cmd --zone=public --add-port=443/tcp --permanent

# But the service approach is self-documenting and multi-port aware

You can create custom services for your own applications:

# Create a custom service definition
firewall-cmd --permanent --new-service=myapp
firewall-cmd --permanent --service=myapp --set-description="My Application"
firewall-cmd --permanent --service=myapp --add-port=8080/tcp
firewall-cmd --permanent --service=myapp --add-port=8443/tcp

# Use it
firewall-cmd --zone=public --add-service=myapp --permanent
firewall-cmd --reload

Rich Rules: When Zones Aren’t Enough

For more granular control, firewalld supports “rich rules” - a more expressive syntax that sits between simple zone/service management and raw nftables rules:

# Allow SSH only from a trusted subnet
firewall-cmd --zone=public --add-rich-rule='
    rule family="ipv4"
    source address="198.51.100.0/24"
    service name="ssh"
    accept' --permanent

# Rate-limit SSH connections
firewall-cmd --zone=public --add-rich-rule='
    rule service name="ssh"
    accept
    limit value="4/m"' --permanent

# Block a specific IP
firewall-cmd --zone=public --add-rich-rule='
    rule family="ipv4"
    source address="192.0.2.100"
    drop' --permanent

# Log dropped packets from a specific source
firewall-cmd --zone=public --add-rich-rule='
    rule family="ipv4"
    source address="192.0.2.0/24"
    log prefix="suspect-traffic: " level="warning"
    drop' --permanent

firewall-cmd --reload

Multi-Zone Network Segmentation

One of firewalld’s most powerful enterprise features is the ability to assign different zones to different interfaces, creating proper network segmentation without touching nftables directly:

# Public-facing interface: restrictive
firewall-cmd --zone=public --change-interface=eth0 --permanent
firewall-cmd --zone=public --add-service=http --permanent
firewall-cmd --zone=public --add-service=https --permanent

# Internal management network: more permissive
firewall-cmd --zone=internal --change-interface=eth1 --permanent
firewall-cmd --zone=internal --add-service=ssh --permanent
firewall-cmd --zone=internal --add-service=cockpit --permanent
firewall-cmd --zone=internal --add-service=dns --permanent

# Database network: only database traffic
firewall-cmd --zone=dmz --change-interface=eth2 --permanent
firewall-cmd --zone=dmz --add-port=5432/tcp --permanent

firewall-cmd --reload

This maps naturally to how enterprise networks are actually designed: distinct interfaces for distinct trust levels, each with its own policy. A single nftables ruleset can do the same thing, but you’d be hand-coding the interface dispatch logic that firewalld provides out of the box.

firewalld’s Backend: nftables

Since firewalld 0.6.0, the default backend is nftables. firewalld translates its zone/service model into nftables rules. You can see the generated rules with:

nft list ruleset

The output will be significantly more complex than hand-written nftables - firewalld generates a multi-table structure with dispatch chains for its zone model. This is the trade-off: you get a managed, auditable interface with a well-defined API, while the underlying nftables ruleset handles the heavy lifting.

When firewalld Shines

firewalld is at its best in environments where firewall management isn’t a one-person artisanal craft but a repeatable, auditable process across many machines:

  • Cockpit integration: The web-based Cockpit console (standard on RHEL) can manage firewalld zones, services, and ports through a graphical interface. For teams where not everyone is comfortable with CLI firewall management, this lowers the barrier without sacrificing security.
  • Ansible automation: The ansible.posix.firewalld module maps directly to firewalld’s zone/service model. Declaring service: https, zone: public, state: enabled, permanent: true in a playbook is about as clean as firewall configuration management gets.
  • D-Bus API: Any application can query the current firewall state or request changes through D-Bus. libvirt uses this to dynamically open ports for VM networking. NetworkManager uses it to assign interfaces to zones when connections come up. This kind of runtime integration simply isn’t possible with static ruleset files.
  • Consistent across the ecosystem: Whether you’re managing a single RHEL server, a fleet of Fedora CoreOS nodes, or a CentOS Stream build farm, the firewall interface is identical. The same commands, the same zones, the same service definitions.

Where firewalld is less ideal is when you need complete, low-level control over rule ordering or heavily customized chain structures. Rich rules and direct passthrough rules can handle most advanced cases, but if you’re building something truly bespoke, nftables gives you more direct control.

ufw: Uncomplicated Firewall

ufw does exactly what its name promises: it makes firewall configuration uncomplicated. It’s the default on Ubuntu and is popular on any Debian-based system where the administrator wants a firewall without studying Netfilter internals.

The Mental Model

ufw has essentially no mental model beyond “allow or deny things.” It’s a command-line interface that generates iptables (or nftables) rules from simple, human-readable commands. It trades flexibility for approachability.

Cloud Server with ufw

# Reset to clean state
ufw reset

# Default policies
ufw default deny incoming
ufw default allow outgoing

# Allow SSH (do this BEFORE enabling ufw, or you'll lock yourself out)
ufw allow ssh

# Allow HTTP and HTTPS
ufw allow http
ufw allow https

# Enable the firewall
ufw enable

# Check status
ufw status verbose

Output:

Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip

To                         Action      From
--                         ------      ----
22/tcp                     ALLOW IN    Anywhere
80/tcp                     ALLOW IN    Anywhere
443/tcp                    ALLOW IN    Anywhere
22/tcp (v6)                ALLOW IN    Anywhere (v6)
80/tcp (v6)                ALLOW IN    Anywhere (v6)
443/tcp (v6)               ALLOW IN    Anywhere (v6)

That’s it. Six commands and your server is firewalled. ufw automatically handles both IPv4 and IPv6.

Slightly More Advanced ufw

# Allow SSH only from a specific subnet
ufw allow from 198.51.100.0/24 to any port 22 proto tcp

# Allow a port range
ufw allow 6000:6007/tcp

# Deny a specific IP
ufw deny from 192.0.2.100

# Rate-limit SSH (allows 6 connections per 30 seconds)
ufw limit ssh

# Delete a rule
ufw delete allow http

# Insert a rule at a specific position
ufw insert 1 deny from 192.0.2.0/24

# Allow from a specific IP to a specific port
ufw allow from 198.51.100.22 to any port 5432 proto tcp comment 'PostgreSQL admin'

# Show numbered rules for deletion
ufw status numbered

Application Profiles

ufw supports application profiles - predefined port configurations stored in /etc/ufw/applications.d/. Many packages drop their own profile files into this directory automatically when installed via apt - install Nginx or Apache and the corresponding ufw profile appears without any extra work. This is part of why ufw feels so frictionless on Ubuntu: the ecosystem does the heavy lifting.

# List available profiles
ufw app list

# Get profile details
ufw app info 'Nginx Full'
Profile: Nginx Full
Title: Web Server (Nginx, HTTP + HTTPS)
Description: Small, but very powerful and efficient web server

Ports:
  80,443/tcp
# Use a profile
ufw allow 'Nginx Full'

Behind the Scenes

ufw stores its rules in /etc/ufw/user.rules and /etc/ufw/user6.rules. It also has before.rules and after.rules files for advanced configurations that go beyond ufw’s command-line interface - for example, NAT rules or custom FORWARD chains:

# /etc/ufw/before.rules - add NAT for containers/VMs
*nat
:POSTROUTING ACCEPT [0:0]
-A POSTROUTING -s 10.0.0.0/24 -o eth0 -j MASQUERADE
COMMIT

When to Use ufw

ufw is perfect for single-purpose servers, personal VPS instances, and situations where you just need basic allow/deny rules without ceremony. It’s genuinely the fastest path from “naked server” to “firewalled server,” and there’s no shame in using it. Not everything needs to be a hand-crafted artisanal nftables configuration.

Where it falls short: complex multi-zone setups, advanced NAT, detailed logging configurations, or anything where you need to understand and control the exact rule structure. ufw deliberately hides that complexity, which is a feature until it isn’t.

Choosing Your Firewall

Aspect iptables nftables firewalld ufw
Learning curve Moderate Moderate Low Very low
IPv4/IPv6 unified No Yes Yes Yes
Syntax Verbose Clean CLI/XML/D-Bus Minimal
Best for Legacy systems New deployments Enterprise/fleet management Ubuntu/quick setups
Native sets No (needs ipset) Yes Via zones/ipsets No
Atomic updates No Yes Yes No
Runtime/permanent split No No Yes No
Programmatic API No Netlink D-Bus No
Web UI (Cockpit) No No Yes No
Config management Manual Manual Ansible module Manual
RHEL default No Backend Yes No
Ubuntu default No Backend No Yes
CLI complexity High Medium Low Very low

My general recommendation for new Linux deployments:

  • Running RHEL, Fedora, or CentOS Stream? Use firewalld. It’s the native choice and the integration benefits are substantial - Cockpit, Ansible, NetworkManager, and libvirt all speak firewalld natively. If you’re managing more than a handful of servers, the zone/service abstraction and D-Bus API will save you significant time compared to hand-rolled nftables across every machine.
  • Running Ubuntu or Debian and just need basic rules? Use ufw. It gets the job done in under a minute.
  • Need precise control, custom rulesets, or complex NAT? Use nftables directly. It’s the future of Linux packet filtering.
  • Maintaining an existing iptables setup that works? Keep it. The iptables-nft compatibility layer means your rules are already running on nftables under the hood. Migrate when you have a reason, not because someone on Reddit told you to.

Common Patterns for Cloud Servers

Regardless of which tool you choose, certain patterns apply to every cloud server firewall:

1. Default Deny Incoming

Every firewall configuration should start with a default deny policy for incoming traffic. This is the single most important rule. If you forget to allow a service, it’s unreachable (you notice immediately). If you forget to block a service with a default-allow policy, it’s exposed (you might never notice).

2. Always Allow Established Connections

Stateful tracking is essential. A rule like ct state established,related accept (nftables) or -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT (iptables) ensures that return traffic for allowed outbound connections gets through without explicit rules for every possible response.

3. Rate-Limit SSH

Every public-facing SSH server gets brute-forced. Rate limiting won’t stop a determined attacker, but it dramatically slows down automated scanners. Combine it with key-only authentication (PasswordAuthentication no in sshd_config) for real security.

4. Never Block Essential ICMPv6

Blocking all ICMPv6 will break IPv6 connectivity. Neighbor Discovery (the IPv6 equivalent of ARP) uses ICMPv6. Path MTU Discovery uses ICMPv6. If you block neighbour-solicitation or packet-too-big, your IPv6 connectivity will fail in mysterious ways.

5. Log Selectively

Logging every dropped packet will fill your disk. Log with rate limiting, and log the things that matter - unexpected traffic on specific ports, connections from specific sources, or rules that should never trigger but do.

6. The Docker Trap

This one burns almost every Linux admin at least once: Docker bypasses your firewall rules.

When you run docker run -p 8080:80 nginx, Docker doesn’t politely ask ufw or firewalld to open port 8080. Instead, it manipulates the iptables PREROUTING and DOCKER chains directly, inserting rules that take effect before your INPUT chain is evaluated. The result: that container port is exposed to the entire internet, completely ignoring your carefully crafted ufw default deny incoming policy.

This happens because Docker needs to implement its port-forwarding via NAT, and it does so by writing directly to Netfilter - bypassing any frontend that manages the INPUT chain.

Mitigations:

# Option 1: Bind to localhost only (container accessible only from the host)
docker run -p 127.0.0.1:8080:80 nginx

# Option 2: Disable Docker's iptables manipulation entirely
# In /etc/docker/daemon.json:
{"iptables": false}
# WARNING: You are now responsible for all container networking rules.
# Containers will lose outbound internet access until you manually
# configure NAT masquerading for the docker0 bridge.

# Option 3: Use Docker's internal network and reverse-proxy through
# a host-level web server (Nginx, Caddy) that IS behind your firewall

The safest pattern for production: don’t publish ports directly with -p. Instead, put a reverse proxy on the host that listens on the public interface (behind your firewall), and have it forward traffic to containers on Docker’s internal bridge network. This way, your firewall rules actually apply to the traffic reaching your services.

Podman, notably, doesn’t have this problem in rootless mode - it uses slirp4netns or pasta for networking, which doesn’t touch the host’s iptables chains. One more reason to consider it for server workloads.

Conclusion

All four tools solve the same fundamental problem: controlling which packets get into your server and which don’t. The differences are in ergonomics, abstraction level, and ecosystem integration. iptables is the weathered veteran - it works everywhere but shows its age. nftables is the well-designed successor that hasn’t fully taken over yet because the old guard is entrenched. firewalld wraps everything in Red Hat’s zone model, which is either a helpful abstraction or an unnecessary layer depending on your perspective. ufw strips it all down to the bare essentials, which is either refreshingly simple or frustratingly limited.

Pick the tool that matches your distribution, your team, and your complexity requirements. Then configure it, test it, and - most importantly - actually turn it on. A perfectly designed ruleset sitting in a text file isn’t protecting anything.


Corrections

2026-03-14: An earlier version of this article stated that nftables has no programmatic API. This is incorrect - nftables exposes a Netlink interface (NETLINK_NETFILTER) that allows applications to manage rules directly without the nft command-line tool. The comparison table and nftables section have been updated to reflect this. Thanks to Joe Chilliams (@joew@hachyderm.io) for the correction and the excellent real-world examples.


References


Every Linux firewall tool on this page gets the job done. But if you really want to see packet filtering done with elegance - a single, clean configuration file, last-match-wins semantics, tables that scale to hundreds of thousands of entries without breaking a sweat, and a syntax that reads like it was designed by someone who actually enjoys writing firewall rules - well, you know where I stand. PF on FreeBSD remains the gold standard. The rest of us are just approximating greatness.

Comments

You can use your Mastodon or other ActivityPub account to comment on this article by replying to the associated post.

Search for the copied link on your Mastodon instance to reply.

Loading comments...