Skip to content

guide

A default-drop nftables firewall you can read in one screen

nftables replaced iptables as the kernel's packet filter years ago. Its config is a single text file with sets, counters and comments, which makes it the easiest firewall to review in a pull request.

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 ruleset at 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 localhost proves nothing about external access.

Questions

Can I use nftables and ufw together?

Pick one. On current Ubuntu, ufw writes rules through the iptables-nft layer into the same kernel tables, and two tools editing the same chains is how rules go missing. If you want a plain text ruleset, remove ufw and use nftables directly.

Does Docker work with this ruleset?

Docker inserts its own chains for published ports and those are evaluated in separate tables, so a container port published on 0.0.0.0 can bypass your input chain. Publish container ports on 127.0.0.1 and proxy them, or see the Docker Compose guide for the details.

How does this interact with the portal firewall?

The portal firewall filters traffic before it reaches the VM. nftables filters inside the guest. Use the portal for broad allow-lists that should survive a reinstall, and nftables for fine-grained rules you keep in version control.