Firewalls & nftables - Practical Packet Filtering for Linux Servers
Status: Active
Last Updated: 2026-08-26
Category: Networking - Phase 2: Service Networking
Prerequisites: tcp-ip-fundamentals, ip-addressing-subnets
Time: 2 hours
Tags: nftables, firewall, netfilter, iptables, nat, port-forwarding, security, linux
Summary
Every Linux server has a packet filter built into the kernel โ the question is whether you configured it or left it wide open. This lesson covers what a firewall actually does, why nftables replaced iptables, the nftables object model (tables โ chains โ rules), and a complete copy-paste ruleset for an internet-facing server that allows only SSH, HTTP, and HTTPS. It finishes with NAT/port forwarding and how to make your ruleset survive reboots.
๐ฏ What You'll Learn
By the end of this article, you'll be able to:
- โ
Explain stateful filtering: connection tracking,
established/related, and default-drop - โ Describe how nftables relates to (and replaces) iptables
- โ Read and write nftables syntax: tables, base chains with hooks/priorities, rules, sets, verdict maps
- โ Deploy a hardened ruleset allowing SSH/HTTP/HTTPS for IPv4 and IPv6 in one table
- โ Configure DNAT port forwarding and masquerade (SNAT) on a router box
- โ Persist rules across reboots and safely test changes over SSH
- โ
Use
nft list ruleset, counters, and logging to debug blocked traffic
Table of Contents
- Firewall Concepts
- iptables vs nftables
- nftables Syntax: Tables, Chains, Rules
- A Practical Server Ruleset
- NAT and Port Forwarding
- Persistence
- Common Commands Cheat Sheet
- Troubleshooting & Common Pitfalls
- Key Takeaways
- Next Steps
Firewall Concepts
A network firewall inspects packets crossing a network interface and decides per packet: accept, drop, or reject.
- Accept โ packet continues to its destination.
- Drop โ silently discard; the sender times out (usually preferable โ reveals nothing).
- Reject โ actively reply with an ICMP error; sender fails fast (friendlier on LANs).
The single most important idea is stateful inspection. The kernel's connection tracker (conntrack) keeps a table of every flow it has seen. That lets you write two rules instead of thousands:
- Allow packets belonging to connections you initiated (
established,related). - Drop everything else inbound by default.
This is why outbound-initiated traffic (updates, DNS replies, your SSH session's return packets) works even under a strict "default drop" policy.
Other vocabulary you'll meet:
| Term | Meaning |
|---|---|
| Hook | Kernel checkpoint where filtering happens: prerouting, input, forward, output, postrouting |
| Policy | A base chain's default verdict when no rule matches (drop/accept) |
| Conntrack states | new, established, related, invalid, untracked |
| DNAT | Rewrite destination address/port (inbound port forwarding) |
| SNAT / masquerade | Rewrite source address (sharing one public IP among many hosts) |
iptables vs nftables
iptables dates from the kernel's 2.4 era. nftables arrived in kernel 3.13 as its replacement, and is what modern distributions ship.
| Aspect | iptables | nftables |
|---|---|---|
| Tables/chains | Predefined (filter, nat; INPUT/FORWARD/OUTPUT always exist) |
You create exactly what you need |
| Families | Separate tools per family (iptables vs ip6tables) |
One inet family handles IPv4+IPv6 together |
| Rule syntax | Per-match extensions compiled into the kernel | Unified expression language, most matches built-in |
| Sets | ipset bolted on separately |
Native sets and maps (verdict maps) |
| Atomic updates | Each rule change is a syscall | Full ruleset replace atomically via nft -f |
Compatibility notes:
- On current Debian/Ubuntu/RHEL,
iptablescommands are actually translated to nftables byiptables-nft. Don't mix rawnftrules and legacyiptables-legacyrules blindly โ you get two rule sources fighting over hooks. - ufw/firewalld are frontends layered on top. Learning nftables directly means you can read what they actually did.
nftables Syntax: Tables, Chains, Rules
Per the official wiki, unlike iptables there are no predefined chains โ you explicitly attach base chains of your own naming to Netfilter hooks:
# create a table (the container for everything else)
nft add table inet myfilter
# create a base chain hooked into input, priority 0, default drop
nft add chain inet myfilter input { type filter hook input priority 0 \; policy drop \; }
Anatomy of that line:
type filterโ chain type:filter(drop/accept),nat(address rewriting), orroute.hook inputโ which kernel checkpoint the chain sees.priority 0โ ordering when several chains share a hook (lower runs first).policy dropโ verdict if the packet falls through every rule.
Rules then match expressions and end in a verdict:
nft add rule inet myfilter input iifname "lo" accept
nft add rule inet myfilter input tcp dport 22 accept
nft add rule inet myfilter input ip saddr 192.168.1.0/24 accept
Power features worth knowing early:
# sets โ match many values without many rules
tcp dport { 22, 80, 443 } accept
# named sets โ updatable at runtime
nft add set inet myfilter blocklist { type ipv4_addr \; flags interval \; }
nft add element inet myfilter blocklist { 203.0.113.0/24 }
# verdict maps โ dispatch by value
ct state vmap { established : accept, related : accept, invalid : drop }
# counters on any rule
tcp dport 22 counter accept
A Practical Server Ruleset
This is essentially the nftables wiki's "simple ruleset for a server", annotated. Save as /etc/nftables.conf and load with nft -f:
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain inbound_ipv4 {
# rate-limited ping for diagnostics (commented = currently off)
# icmp type echo-request limit rate 5/second accept
}
chain inbound_ipv6 {
# REQUIRED for IPv6: neighbour discovery, otherwise connectivity breaks
icmpv6 type { nd-neighbor-solicit, nd-router-advert, nd-neighbor-advert } accept
# icmpv6 type echo-request limit rate 5/second accept
}
chain input {
type filter hook input priority 0; policy drop;
# allow replies to our own outgoing connections, drop garbage
ct state vmap { established : accept, related : accept, invalid : drop }
# loopback (local services talking to themselves)
iifname lo accept
# jump to protocol-specific helper chains
meta protocol vmap { ip : jump inbound_ipv4, ip6 : jump inbound_ipv6 }
# THE actual services: SSH + web, IPv4 and IPv6 in one rule
tcp dport { 22, 80, 443 } accept
# optional: see what's being dropped
# log prefix "[nftables] Inbound Denied: " counter drop
}
chain forward {
# this host is not a router
type filter hook forward priority 0; policy drop;
}
}
Design points to internalize:
- One
inettable filters both IP families โ no duplicated IPv4/IPv6 rulesets. - Default-drop input with explicit allowances is the secure baseline.
- Forward drops because a standalone server shouldn't route anything.
- No output chain defined โ output defaults to accept, so the server can reach out freely.
- The IPv6 neighbour-discovery lines are not optional โ omit them and IPv6 silently dies.
Harden SSH further once things work:
# rate-limit new SSH connections (allow bursts, stop brute force)
tcp dport 22 ct state new limit rate 6/minute accept
# or restrict management to your VPN/LAN subnet only
ip saddr 10.8.0.0/24 tcp dport 22 accept
Apply and verify:
sudo nft -f /etc/nftables.conf
sudo nft list ruleset
โ ๏ธ Never test firewall changes without a safety net. Schedule an auto-revert before applying:
sudo systemd-run --on-active=300 sh -c 'nft -f /etc/nftables.conf.known-good'If you lock yourself out of SSH, the known-good ruleset returns in five minutes.
NAT and Port Forwarding
Filtering happens in input/forward; rewriting happens in the nat type chains at prerouting (DNAT) and postrouting (SNAT). NAT tables see each connection only once (first packet), so there's no need for conntrack rules here.
Port forwarding (DNAT)
Forward public port 2222 to internal host 192.168.1.50:22 โ requires forward policy allowing it too:
table inet nat {
chain prerouting {
type nat hook prerouting priority -100;
iifname "eth0" tcp dport 2222 dnat to 192.168.1.50:22
iifname "eth0" tcp dport { 80, 443 } dnat to 192.168.1.60
}
}
table inet filter {
chain forward {
type filter hook forward priority 0; policy drop;
ct state established,related accept
ip daddr 192.168.1.50 tcp dport 22 accept
ip daddr 192.168.1.60 tcp dport { 80, 443 } accept
}
}
Note: DNAT'd traffic traverses forward, not input โ your input chain's allowed ports are irrelevant to forwarded flows. Also ensure kernel forwarding is on: sysctl -w net.ipv4.ip_forward=1.
Internet sharing (masquerade)
Let a private LAN reach the internet through this server:
table inet nat {
chain postrouting {
type nat hook postrouting priority 100;
oifname "eth0" ip saddr 192.168.1.0/24 masquerade
}
}
masquerade picks the outgoing interface's current IP automatically; use plain snat to <ip> when the address is static.
Persistence
Rules loaded with nft live in memory only. To survive reboot:
Debian/Ubuntu:
sudo systemctl enable nftables.service # reads /etc/nftables.conf
RHEL/Fedora: same service, config typically at /etc/sysconfig/nftables.conf (or include your file from it).
Test persistence honestly: reboot, then confirm sudo nft list ruleset | grep -c tcp shows your rules โ not just that the service started.
Common Commands Cheat Sheet
nft list ruleset # dump everything currently loaded
nft list tables # just table names
nft list table inet filter # one table
nft flush ruleset # wipe all rules (careful!)
nft delete table inet oldtable # remove one table
nft add rule inet filter input tcp dport 8080 counter accept # live append
nft insert rule inet filter input index 0 ip saddr 203.0.113.7 drop # prepend
nft monitor trace # live packet tracing (best debugging tool)
nft -i # interactive mode; avoids shell quoting issues
nft -c -f /etc/nftables.conf # check file syntax WITHOUT applying
Troubleshooting & Common Pitfalls
- Locked out over SSH right after applying โ you forgot
ct state established,related accept, or applied aflushwhile connected. Prevent with thesystemd-runrevert timer above; recover via console/VNC access. - IPv6 stopped working entirely โ missing the
nd-neighbor-solicit/nd-router-advertaccepts. IPv6 depends on ICMPv6; you cannot treat it like IPv4's optional ping. - Port forward doesn't work though the rule exists โ three usual suspects:
ip_forwardsysctl still 0, theforwardchain policy dropping the DNAT'd packets, or hairpin NAT (LAN clients using the public IP) needing a separate snat/dnat pair. - Rule added but nothing changed โ an earlier base chain on the same hook has lower priority and already dropped/accepted the packet.
nft list rulesetand read priorities top-down. - Docker containers reachable despite firewall โ Docker installs its own nftables/iptables chains with high-priority DOCKER chains that bypass your input policy. Filter published container ports via Docker's own mechanisms or the FORWARD chain, not input.
syntax errorwhen pasting rules into bash โ semicolons and braces must be escaped (\;) outside quotes; usenft -iinteractive mode to avoid shell metacharacters entirely.- Everything works until reboot โ rules were never persisted; enable
nftables.serviceand verify after a real restart.
Key Takeaways
- Default-drop input + conntrack established/related + loopback = the core of any sane server ruleset.
- nftables replaces iptables: unified
inetfamily, native sets/maps, atomic file-based loading, no predefined chains. - NAT lives in separate
natchains; forwarded traffic hitsforward, neverinput. - Always test with an auto-revert timer, and always verify persistence after a real reboot.
Next Steps
- Rate-limit SSH and add fail2ban-style dynamic blocklists using nftables sets
- Combine with Traefik: expose only 80/443 publicly and let the reverse proxy route everything else โ see Traefik v3 Reverse Proxy
- Wire the firewall into your monitoring so drops are visible
Sources & Related
Sources consulted during research:
- https://wiki.nftables.org/wiki-nftables/index.php/Simple_ruleset_for_a_server
- https://wiki.nftables.org/wiki-nftables/index.php/Configuring_chains
- https://wiki.nftables.org/wiki-nftables/index.php/Main_Page (Netfilter hooks, families, NAT chapters)
- https://netfilter.org/projects/nftables/ (official project page)
Related KB articles:
- TCP/IP Fundamentals โ ports, flags, and the handshake conntrack tracks
- IP Addressing & Subnets โ reading
192.168.1.0/24style matches - DNS Fundamentals โ why firewalls rarely need to filter DNS itself
- Firewall Basics โ concepts-first companion lesson
- TLS Configuration โ what happens on those allowed 443 packets
- Docker Networking โ how Docker's chains interact with yours
Change Log
- 2026-08-26: Initial draft created via headless-browser web research session.