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)¶
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
}
}
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.
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¶
- On a test VM (not a host you're actively SSH'd into without console access), check the current ruleset with
sudo nft list rulesetorsudo iptables -L -n -v. - Add a rule allowing a port you're not using yet (e.g., 8080/tcp) using your distribution's front-end tool.
- Confirm with
ufw status verbose/firewall-cmd --list-all/nft list rulesetthat the rule is present, then remove it again.
How to verify you did it correctly¶
- Before adding the rule:
sudo ss -tulnp | grep 8080should 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 8080for 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--permanentvs. runtime split: applying a rule with--permanentand 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 apolicy DROPalready 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 scheduledat/cronjob 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
ufwwithdefault deny incomingwithout 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. Runsudo ufw allow 22/tcp(or your real SSH port) beforesudo ufw enable.
Related articles¶
- SSH Hardening and Key-Based Authentication
- TCP/IP Networking and the
ipCommand Suite - Understanding conntrack and Connection Tracking
Review questions¶
- Why is
ct state established,related accept(or its iptables equivalent) essential in a default-deny ruleset? - What's the practical risk of running
iptables -Fon a remote host with policyDROPalready set? - What's the difference between a
firewalldrule applied with and without--permanent? - How would you verify that a specific firewall rule is actually being matched by real traffic, using only
iptables's own output?