How to Set Up a Default-Deny Firewall with nftables on Linux
A default-deny firewall has a pleasantly simple rule: traffic is blocked unless you explicitly permit it. The difficult bit is applying that rule without disabling IPv6, upsetting container networking, or discovering that your SSH exception contains the wrong address just after pressing Enter.
This guide builds a stateful firewall for an ordinary dual-stack Linux host. Incoming and forwarded traffic default to drop. Outbound traffic remains permitted, because default-deny egress filtering is a separate job requiring an accurate inventory of DNS, DHCP, NTP, package repositories and application destinations.
Check what already owns the firewall
Install the userspace tools using your distribution's package manager:
sudo apt install nftables
sudo dnf install nftablesDo not immediately flush anything. First inspect the active rules and the services which may be managing them:
sudo nft list ruleset
systemctl is-active nftables firewalld ufw
sudo iptables-saveIf firewalld, UFW, Docker, Podman, libvirt or Kubernetes manages rules on this machine, replacing the complete ruleset is likely to break something. Docker rules are especially easy to erase and surprisingly tedious to reconstruct from memory. Either integrate your policy with that system or give your table a distinct name and avoid flush ruleset.
The configuration below is intended for a host where nftables is the sole firewall manager. The nft manual confirms that flush ruleset removes every table, leaving no filtering until replacement rules are installed.
Write the ruleset
Save the following as /etc/nftables.conf. Replace the example SSH source addresses with the public addresses or networks from which you administer the machine. The documentation ranges used here will not match real clients.
flush ruleset
table inet firewall {
chain input {
type filter hook input priority filter; policy drop;
ct state invalid counter drop
ct state { established, related } counter accept
iifname "lo" counter accept
ip protocol icmp counter accept
meta l4proto ipv6-icmp counter accept
ip saddr 203.0.113.10 tcp dport 22 ct state new counter accept
ip6 saddr 2001:db8:1234::/48 tcp dport 22 ct state new counter accept
tcp dport { 80, 443 } ct state new counter accept
limit rate 5/second burst 10 packets counter log prefix "nft input drop: "
counter
}
chain forward {
type filter hook forward priority filter; policy drop;
}
chain output {
type filter hook output priority filter; policy accept;
}
}The inet family handles IPv4 and IPv6 in one table. This avoids maintaining two nearly identical firewalls which inevitably become two subtly different firewalls six months later.
Connection tracking makes the ruleset stateful. Replies belonging to connections initiated by the host match established; associated flows can match related. Invalid packets are discarded early. The Netfilter connection-tracking documentation shows the same pattern for a simple stateful firewall.
Loopback traffic must be accepted explicitly. Dropping it produces failures which look nothing like firewall problems: local databases disappear, monitoring agents fail, and applications start reporting connection errors against 127.0.0.1.
The ICMP rules deliberately accept more than ping. ICMP carries error reporting and path MTU information, while ICMPv6 also supports essential IPv6 control traffic. Blocking it wholesale is not hardening; it is breaking the network protocol and waiting for an awkward packet size to reveal the damage. Using meta l4proto ipv6-icmp also handles packets containing IPv6 extension headers, unlike a naive immediate-next-header test. This distinction is covered in the nftables packet-header documentation.
The final two rules rate-limit log messages and count everything which reaches the default policy. There is no explicit drop because the chain policy already supplies it. Rate limiting matters: an unbounded log rule lets somebody on the network turn a port scan into disk and CPU consumption.
Remove services you do not run
The example permits HTTP and HTTPS from any source. If this is an SSH-only server, remove that rule. For a workstation, remove all three service rules and retain the state, loopback and ICMP rules.
Before opening a port, see what is actually listening:
sudo ss -lntupOpening TCP port 443 in nftables does not publish a service by itself. Conversely, binding a daemon to 0.0.0.0 or :: does not mean the firewall permits access. The listening socket and firewall rule are separate controls, and both should agree with your intended exposure.
Validate before touching the live rules
Ask nft to parse and validate the file without applying it:
sudo nft --check --file /etc/nftables.confThis catches syntax errors and invalid expressions. It cannot tell you that 203.0.113.10 is not your real administration address, or that SSH actually listens on port 2222. Check those details yourself.
For a remote machine, preserve the current rules and arrange an automatic rollback. First create a restorable file containing an initial flush followed by the current ruleset:
sudo sh -c 'printf "flush ruleset\n" > /root/nftables.before'
sudo sh -c 'nft list ruleset >> /root/nftables.before'
command -v nftUse the path printed by command -v in the rollback command if it differs from /usr/sbin/nft:
sudo systemd-run --unit=nft-rollback --on-active=2m /usr/sbin/nft --file /root/nftables.before
sudo nft --file /etc/nftables.confKeep the existing SSH session open. Its packets will normally continue through the established rule even if the new SSH admission rule is wrong, so that session proves less than it appears to. Open a second SSH connection from a fresh terminal. Test the web service too, if applicable.
Once the new connection works, cancel the rollback timer:
sudo systemctl stop nft-rollback.timerIf access fails, wait for the timer to restore the previous rules. A provider console or other out-of-band access method is still worth having. Automatic rollback reduces risk; it does not make remote firewall changes magically consequence-free.
Make the rules survive reboot
Distribution service units are not completely uniform. Inspect yours before enabling it:
systemctl cat nftables.serviceOn systems whose unit loads /etc/nftables.conf, enable the service and restart it:
sudo systemctl enable nftables.service
sudo systemctl restart nftables.serviceDo not assume persistence merely because nft list ruleset looks correct now. Reboot while console access is available, then inspect the loaded rules again.
Verify the effective policy
List the kernel's active ruleset, including counters:
sudo nft list ruleset
sudo journalctl --dmesg --grep='nft input drop:'From another machine, scan the host rather than scanning localhost:
nmap -Pn -p 22,80,443,8080 SERVER_ADDRESSThe permitted ports should appear open when their services are listening. An unpermitted port will commonly appear filtered because packets are silently dropped. Try both the IPv4 and IPv6 addresses; a firewall tested over only one protocol is half tested.
One subtle nftables detail is that an accept verdict is not necessarily final when another base chain is registered later on the same hook. A later chain can still drop the packet, while a drop takes effect immediately. The base-chain documentation explains the ordering rules. This is another reason to identify other firewall managers before declaring the job finished.
At this point the useful question is no longer "which ports did I remember to block?" It is "which narrowly described traffic did I choose to permit?" That inversion is the whole value of default deny.