Install and check the starting state
sudo apt install -y nftables
sudo nft list ruleset
On a clean Debian 12 or 13 image the ruleset is usually empty or contains an accept-everything skeleton. On Ubuntu, if ufw is active you will see a long list of ufw- chains; disable it with sudo ufw disable and sudo apt purge ufw before continuing. AlmaLinux and Rocky ship firewalld, which drives nftables for you; either keep firewalld or stop and mask it, but do not run both.
The ruleset
Write this to /etc/nftables.conf. It drops all inbound traffic except what is listed, allows everything outbound, and keeps forwarded traffic blocked unless you are building a router.
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
set admin_v4 {
type ipv4_addr
flags interval
elements = { 198.51.100.0/24, 192.0.2.44 }
}
chain input {
type filter hook input priority 0; policy drop;
ct state established,related accept
ct state invalid drop
iif "lo" accept
# ICMP: needed for path MTU discovery and sane diagnostics
ip protocol icmp icmp type { echo-request, destination-unreachable, time-exceeded } limit rate 10/second accept
# SSH from admin addresses only
tcp dport 22 ip saddr @admin_v4 ct state new accept
# Web
tcp dport { 80, 443 } ct state new limit rate over 200/second burst 400 packets drop
tcp dport { 80, 443 } accept
# WireGuard (remove if unused)
udp dport 51820 accept
counter comment "dropped by policy"
}
chain forward {
type filter hook forward priority 0; policy drop;
}
chain output {
type filter hook output priority 0; policy accept;
}
}
Replace the two addresses in admin_v4 with your own. If you have no fixed address, change the SSH line to tcp dport 22 ct state new limit rate 10/minute accept and rely on key-only SSH.
Why each line is there
- established,related first: replies to your own outbound connections, and the rest of already-accepted sessions, skip the remaining rules. This is most packets, so it keeps the ruleset fast.
- invalid drop: packets that belong to no known connection and do not start one. Dropping them early removes a class of scan noise.
- ICMP allowed, rate-limited: blocking all ICMP breaks path MTU discovery, which shows up later as connections that hang on large responses. Allow the useful types and cap the rate.
- inet family: one table that matches both address families, so you never maintain two parallel rulesets that slowly disagree with each other.
- counter at the end: anything reaching it is about to be dropped by the policy. The counter tells you how much.
Apply it safely
Load the file with a timed fallback so that a mistake undoes itself. Open the portal's browser console as a backup first.
sudo nft -c -f /etc/nftables.conf && echo "syntax ok"
sudo cp /etc/nftables.conf /root/nftables.new
sudo nft list ruleset > /root/nftables.old
sudo sh -c 'nft -f /root/nftables.new; sleep 60; nft -f /root/nftables.old' &
Within the next 60 seconds, open a new SSH session. If it connects, kill the background job with sudo pkill -f "sleep 60" before it fires, then enable persistence:
sudo systemctl enable --now nftables
sudo systemctl status nftables --no-pager
If the new session fails, do nothing. In a minute the old ruleset is restored and you can fix the file. Note that if /root/nftables.old was empty, restoring it leaves no rules loaded, which is the accept-all starting point.
Checking it works
sudo nft list chain inet filter input
sudo nft list ruleset -a | grep counter
From another machine, nc -vz 203.0.113.10 443 should connect, while nc -vz -w3 203.0.113.10 3306 should time out. A timeout rather than "connection refused" is what a drop policy looks like from outside.
Adding an address without reloading
sudo nft add element inet filter admin_v4 { 203.0.113.77 }
That change is live immediately but lost on reboot. Add the address to /etc/nftables.conf as well, or it vanishes at the next restart. Keeping the file in git, and deploying it with the rest of your configuration, avoids the drift.
Logging drops while you debug
Insert a temporary log rule just before the final counter:
log prefix "nft-drop: " level info limit rate 5/second
Then watch with sudo journalctl -kf | grep nft-drop. Remove the rule when you are done; logging every dropped scan packet on a public address fills the journal quickly.
Pitfalls
- Forgetting
flush rulesetat the top: reloading appends a second copy of every rule. - Putting the web rate limit after the accept line: the accept wins and the limit never runs. Order matters within a chain.
- Testing only from the server itself. Loopback is always allowed, so
curl localhostproves nothing about external access.