nftables is the current packet filtering framework of the Linux kernel and the successor to iptables, ip6tables, arptables and ebtables. A single nft command manages IPv4 and IPv6 together, rules can match against sets of addresses or ports in one lookup, and a whole ruleset is loaded atomically from one file. In this tutorial you will write a dual-stack firewall for a web server in /etc/nftables.conf on Ubuntu 24.04, apply it with an automatic rollback, manage it at runtime with sets, add port forwarding and translate existing iptables rules.

Prerequisites

To follow this guide you need:

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS. Debian 12 and Rocky Linux 9 ship the same nftables.service, although Rocky loads its configuration from /etc/sysconfig/nftables.conf and uses firewalld by default.
  • A non-root user with sudo privileges.
  • Access to the server console from your provider's panel, in case a mistake blocks SSH.

Key nftables concepts

ConceptWhat it is
FamilyThe traffic a table sees: ip (IPv4), ip6 (IPv6), inet (both), plus arp, bridge and netdev
TableA container for chains, sets and maps. Unlike iptables, there are no predefined tables
Base chainA chain attached to a netfilter hook (input, forward, output, prerouting, postrouting) with a priority and a policy
RuleA list of match expressions followed by a verdict such as accept or drop
SetA named collection of addresses, ports or other values that rules can match in one lookup and that you can change at runtime

Using the inet family means one table and one set of rules filter both IPv4 and IPv6, instead of maintaining iptables and ip6tables separately.

Step 1 - Installing nftables and disabling UFW

Install the package, which provides the nft command and the nftables.service unit that loads /etc/nftables.conf at boot:

sudo apt update
sudo apt install nftables

Check the version:

sudo nft --version
nftables v1.0.9 (Old Doc Yak #3)

If UFW is active, disable it so it does not add its own rules alongside yours:

sudo ufw disable

Look at what is loaded right now:

sudo nft list ruleset

On a clean server with UFW disabled the output is empty or contains only leftover tables. They will be removed when you load your own configuration.

Step 2 - Writing the firewall configuration

Keep the whole firewall in /etc/nftables.conf, the file nftables.service loads. Open it:

sudo nano /etc/nftables.conf

Replace its contents with this ruleset for a server that offers SSH, HTTP and HTTPS:

#!/usr/sbin/nft -f

flush ruleset

define SSH_PORT = 22

table inet filter {
    set web_ports {
        type inet_service
        elements = { 80, 443 }
    }

    set blocklist_v4 {
        type ipv4_addr
        flags interval
    }

    set blocklist_v6 {
        type ipv6_addr
        flags interval
    }

    set ssh_ratelimit_v4 {
        type ipv4_addr
        flags dynamic
        timeout 1m
    }

    set ssh_ratelimit_v6 {
        type ipv6_addr
        flags dynamic
        timeout 1m
    }

    chain input {
        type filter hook input priority filter; policy drop;

        iif "lo" accept
        ct state established,related accept
        ct state invalid drop

        ip saddr @blocklist_v4 drop
        ip6 saddr @blocklist_v6 drop

        # ICMPv6 is required for neighbour discovery and path MTU discovery
        meta l4proto ipv6-icmp accept
        icmp type echo-request limit rate 5/second burst 10 packets accept

        # SSH: drop sources that open more than 10 new connections per minute
        tcp dport $SSH_PORT ct state new add @ssh_ratelimit_v4 { ip saddr limit rate over 10/minute } drop
        tcp dport $SSH_PORT ct state new add @ssh_ratelimit_v6 { ip6 saddr limit rate over 10/minute } drop
        tcp dport $SSH_PORT ct state new accept

        tcp dport @web_ports ct state new accept
        udp dport 443 accept comment "HTTP/3 (QUIC)"

        limit rate 5/minute burst 10 packets log prefix "nft-drop: " level info
    }

    chain forward {
        type filter hook forward priority filter; policy drop;
    }

    chain output {
        type filter hook output priority filter; policy accept;
    }
}

How the ruleset works:

  • flush ruleset clears everything before loading, so running the file twice always gives the same result. The whole file is applied as one transaction: if any line has an error, nothing changes.
  • define creates a variable. Change SSH_PORT once if SSH listens on another port.
  • Policies: input and forward drop by default, output accepts, so the server can still reach update mirrors, DNS and APIs.
  • Connection tracking: packets of established connections are accepted early, which keeps the rest of the chain short. related also admits ICMP errors that belong to existing connections.
  • Sets: web_ports holds the ports to open, and the two blocklist sets are empty for now. The interval flag lets them hold whole networks such as 192.0.2.0/24.
  • SSH rate limiting: each new SSH connection adds the source address to a dynamic set with its own rate counter. Once a source exceeds 10 new connections per minute, further attempts are dropped. Entries expire after one minute of inactivity.
  • Logging: the last rule logs packets that are about to hit the drop policy, limited so a flood cannot fill the logs.

Remove the UDP 443 rule if your web server does not use HTTP/3. Check the syntax without applying it:

sudo nft -c -f /etc/nftables.conf

The command prints nothing when the file is valid. Otherwise it points to the line and column of the error.

Step 3 - Applying the ruleset with a rollback timer

nft has no built-in confirmation step, so schedule a rollback before loading the new rules. This transient systemd timer flushes the ruleset in three minutes unless you cancel it, which reopens access if the new rules lock you out:

sudo systemd-run --on-active=3m --unit=nft-rollback /usr/sbin/nft flush ruleset
Running timer as unit: nft-rollback.timer
Will run service as unit: nft-rollback.service

Load the configuration:

sudo nft -f /etc/nftables.conf

Open a new SSH session from another terminal. Your current session is already established and does not prove the rules are correct. If the new session works, cancel the rollback:

sudo systemctl stop nft-rollback.timer

Now enable the service so the file is loaded at every boot:

sudo systemctl enable nftables.service

List the active ruleset to confirm it matches the file:

sudo nft list chain inet filter input

From another machine, check that a port you did not open is now filtered, which shows up as a timeout:

nc -zv -w 3 your_server_ip 3306
nc: connect to your_server_ip port 3306 (tcp) timed out: Operation now in progress

Step 4 - Managing sets and rules at runtime

Sets let you change what the firewall allows without touching the rules. Open an extra port for everyone:

sudo nft add element inet filter web_ports { 8443 }

Block an address or a whole network:

sudo nft add element inet filter blocklist_v4 { 198.51.100.66, 192.0.2.0/24 }
sudo nft add element inet filter blocklist_v6 { 2001:db8:bad::/48 }

Check the contents of a set and remove an entry:

sudo nft list set inet filter blocklist_v4
sudo nft delete element inet filter blocklist_v4 { 198.51.100.66 }

You can also see which addresses the SSH rate limiter is currently tracking:

sudo nft list set inet filter ssh_ratelimit_v4

To delete a single rule you need its handle. List the chain with -a:

sudo nft -a list chain inet filter input
table inet filter {
	chain input { # handle 6
		type filter hook input priority filter; policy drop;
		iif "lo" accept # handle 12
		ct state established,related accept # handle 13
...
		udp dport 443 accept comment "HTTP/3 (QUIC)" # handle 23
sudo nft delete rule inet filter input handle 23

Step 5 - Reading logs and counters

Log entries from the last rule go to the kernel log:

sudo journalctl -k --grep 'nft-drop' -n 20
Sep 24 12:40:03 web01 kernel: nft-drop: IN=eth0 OUT= MAC=... SRC=192.0.2.200 DST=198.51.100.20 LEN=44 TOS=0x00 PREC=0x00 TTL=241 ID=50211 PROTO=TCP SPT=51544 DPT=23 WINDOW=1024 RES=0x00 SYN URGP=0

To see how much traffic a rule matches, add the counter keyword to it in the configuration file, for example:

        tcp dport @web_ports ct state new counter accept

After reloading, sudo nft list chain inet filter input shows counter packets N bytes M next to that rule. To reset all counters, run sudo nft reset counters.

Step 6 - Forwarding a port to another host (optional)

nftables handles NAT in a table with nat type chains. This example forwards TCP port 8080 arriving on eth0 to a web server at 10.0.0.5:80 on a private network.

Enable IP forwarding in the kernel:

sudo nano /etc/sysctl.d/99-ip-forward.conf
net.ipv4.ip_forward = 1
sudo sysctl --system

In /etc/nftables.conf, replace the empty forward chain so forwarded traffic to the backend is allowed:

    chain forward {
        type filter hook forward priority filter; policy drop;
        ct state established,related accept
        iif "eth0" ip daddr 10.0.0.5 tcp dport 80 ct state new accept
    }

Then append a NAT table at the end of the file:

table ip nat {
    chain prerouting {
        type nat hook prerouting priority dstnat; policy accept;
        iif "eth0" tcp dport 8080 dnat to 10.0.0.5:80
    }

    chain postrouting {
        type nat hook postrouting priority srcnat; policy accept;
        ip daddr 10.0.0.5 tcp dport 80 masquerade
    }
}

The dnat rule rewrites the destination of incoming packets, and masquerade rewrites their source to the gateway's address so the backend's replies return through it. Check and reload, then test from outside with curl -I http://your_server_ip:8080:

sudo nft -c -f /etc/nftables.conf && sudo nft -f /etc/nftables.conf

Step 7 - Translating existing iptables rules

The iptables package includes translators that print the nftables equivalent of iptables syntax. Translate one rule:

iptables-translate -A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW -j ACCEPT
nft 'add rule ip filter INPUT tcp dport 22 ct state new counter accept'

Or a complete file saved with iptables-save:

sudo iptables-save > iptables-backup.rules
iptables-restore-translate -f iptables-backup.rules > translated.nft

Use the output as a starting point, not as a finished configuration. The translation keeps the iptables layout (separate ip and ip6 tables, one rule per port), and extensions without an nftables equivalent are left as comments. Rewriting the result with an inet table and sets, as in Step 2, gives you a shorter and faster ruleset.

Troubleshooting

  • Locked out after loading rules: wait for the rollback timer, or log in through the provider's console and run sudo nft flush ruleset, then fix the file.
  • Error: Could not process rule: No such file or directory: the table, chain or set you reference does not exist. Check the names with sudo nft list ruleset.
  • Rules are gone after reboot: nftables.service is not enabled, or the runtime changes were never written to /etc/nftables.conf. Run systemctl is-enabled nftables.
  • IPv6 stops working: ICMPv6 is dropped. Keep meta l4proto ipv6-icmp accept before any rule that drops traffic.
  • UFW or Docker rules reappear: another tool manages the firewall too. Run sudo nft list tables to see who created which table, and keep one manager.

Conclusion

You wrote a dual-stack nftables firewall with sets, SSH rate limiting and logging, applied it with a rollback timer, managed it at runtime and added optional port forwarding. As next steps, keep /etc/nftables.conf under version control, connect Fail2ban to nftables with its nftables-multiport action, and move any remaining iptables scripts over with iptables-restore-translate.