Skip to content

Linux Firewall with nftables and iptables

iptables is deprecated in favor of nftables on modern kernels, though it's still widely used (and ufw/firewalld both wrap one or the other under the hood). This article covers both the underlying mechanism and the distribution-level tools built on top of it.

How it works

The filtering model: tables, chains, rules

Both iptables and nftables share the same conceptual model: packets pass through chains (INPUT, OUTPUT, FORWARD being the standard ones) grouped into tables, each chain containing an ordered list of rules. The first matching rule decides the packet's fate (ACCEPT, DROP, REJECT), and rules are evaluated top to bottom.

nftables (the modern replacement)

sudo nft list ruleset                    # show everything currently configured

A minimal ruleset, written to a file and loaded:

# /etc/nftables.conf
flush ruleset

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

        ct state established,related accept
        iif lo accept
        tcp dport 22 accept
        ip protocol icmp accept
        ip6 nexthdr icmpv6 accept
    }
}
sudo nft -f /etc/nftables.conf
sudo systemctl enable --now nftables

Reading this: policy drop means anything not explicitly accepted is dropped — a default-deny posture, the only safe default for an internet-facing host. ct state established,related accept is critical and easy to forget: without it, return traffic for connections you initiated outbound (DNS lookups, package manager updates, outgoing API calls) would also be dropped, since the chain has no memory of "this reply belongs to a connection I allowed out." iif lo accept allows loopback traffic (many local services talk to each other over 127.0.0.1, e.g. a app server to a local database proxy) — without it, basic local inter-process communication breaks.

flush ruleset at the top makes loading idempotent: without it, nft -f adds to whatever is already loaded, so re-loading the file duplicates rules. Syntax-check a file without applying it: sudo nft -c -f /etc/nftables.conf.

iptables (still common, especially where nftables tooling isn't yet standard)

The equivalent policy in iptables syntax:

sudo iptables -A INPUT -i lo -j ACCEPT
sudo iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 22 -j ACCEPT
sudo iptables -P INPUT DROP    # last: switch to default-deny only once the accept rules exist

Order matters when you type these over SSH: setting -P INPUT DROP first would cut your own session before the ESTABLISHED rule exists. Rules added with iptables live only in memory — persist them with iptables-save and the iptables-persistent (Debian/Ubuntu) or iptables-services (RHEL-family) package.

sudo iptables -L -n -v --line-numbers    # list current rules with packet/byte counters

The -v packet/byte counters are genuinely useful for troubleshooting: a rule with a zero counter after you expect traffic to have matched it tells you the traffic isn't reaching that rule at all (wrong order, wrong interface, earlier rule already caught it).

ufw (Ubuntu's front-end, wraps iptables/nftables)

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow from 203.0.113.0/24 to any port 5432
sudo ufw enable
sudo ufw status verbose

ufw allow from <subnet> to any port <port> is the pattern for restricting a service (a database, an admin panel) to a specific source range instead of the whole internet — far more useful in practice than a blanket allow <port>.

firewalld (RHEL-family's front-end)

sudo firewall-cmd --permanent --add-service=ssh
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="203.0.113.0/24" port port="5432" protocol="tcp" accept'
sudo firewall-cmd --reload
sudo firewall-cmd --list-all

--permanent writes the rule to disk but does not apply it immediately — you need --reload (or --permanent + a later reload) for it to take effect, while a command without --permanent applies immediately but is lost on the next reload/reboot. A very common mistake is forgetting one half of this pair.

Ubuntu/Debian vs. RHEL-family differences

Ubuntu/Debian RHEL-family
Front-end ufw firewalld
Underlying engine (modern releases) nftables nftables (iptables-nft compatibility layer on older)
Zones concept No native zone concept firewalld organizes rules into named "zones" (public, internal, etc.) with different default policies

Practical exercise

  1. On a test VM (not a host you're actively SSH'd into without console access), check the current ruleset with sudo nft list ruleset or sudo iptables -L -n -v.
  2. Add a rule allowing a port you're not using yet (e.g., 8080/tcp) using your distribution's front-end tool.
  3. Confirm with ufw status verbose / firewall-cmd --list-all / nft list ruleset that the rule is present, then remove it again.

How to verify you did it correctly

  • Before adding the rule: sudo ss -tulnp | grep 8080 should show nothing listening, and attempting a connection to that port from another host should hang/refuse.
  • After adding the rule (with something actually listening, e.g. python3 -m http.server 8080 for a quick test): the connection should succeed from an allowed source and fail from a disallowed one.

Common mistakes

  • Forgetting the established,related / "allow outgoing" rule when switching to a default-deny policy, breaking all outbound-initiated return traffic (DNS, updates, API calls) — not just inbound connections.
  • Testing firewall changes over the very connection that could be cut off by them, with no console/out-of-band access as a fallback, on a remote host.
  • firewalld's --permanent vs. runtime split: applying a rule with --permanent and forgetting --reload, then being confused why it "didn't work" (it will, after the next reload or reboot — just not yet).

⚠ Dangerous commands

  • iptables -F (flush) with a policy DROP already in effect immediately locks out all traffic not re-added yet, including your own SSH session — on a remote host with no console access, this is an instant, hard lockout. Always have a rollback plan (a scheduled at/cron job that reverts the ruleset in 1-2 minutes is a common safety net for exactly this scenario) before testing a new default-deny policy remotely.
  • Enabling ufw with default deny incoming without first allowing your own SSH port. ufw usually keeps your already-established session alive, so it looks fine — then you cannot open a new connection, and a dropped session or a reboot locks you out. Run sudo ufw allow 22/tcp (or your real SSH port) before sudo ufw enable.

Review questions

  1. Why is ct state established,related accept (or its iptables equivalent) essential in a default-deny ruleset?
  2. What's the practical risk of running iptables -F on a remote host with policy DROP already set?
  3. What's the difference between a firewalld rule applied with and without --permanent?
  4. How would you verify that a specific firewall rule is actually being matched by real traffic, using only iptables's own output?