Skip to content

Build your first nftables firewall

In this tutorial, we will build a host firewall on a Debian 13 server with nftables, one rule at a time. We'll keep SSH working throughout, watch the firewall block a small web server, open a port for it, and then make the rules load at every boot. By the end, you will have a server that accepts only the connections you chose, and you will know how to read and change its ruleset.

Before you begin

  • A fresh Debian 13 server that you reach over SSH on port 22, logged in as a user who can use sudo. The earlier lessons in this learning path set up exactly this; the previous one is Log in with SSH keys.
  • No other firewall on the server: a fresh Debian 13 installation has none.

You are about to firewall the connection you're using

In this tutorial you change the firewall of the server you're logged in to. Follow the steps in order: the rules that keep SSH working come before the step that blocks everything else. If you have a console for the server (for example your hypervisor's or cloud provider's web console), keep it open. If you lose your connection, log in on the console and run sudo nft flush ruleset to remove every rule.

Step 1: Install nftables

Install the nftables package, which provides the nft command:

sudo apt update
sudo apt install -y nftables

Check that it works:

$ sudo nft --version
nftables v1.1.3 (Commodore Bullmoose #4)

nft lives in /usr/sbin, so on Debian you always run it with sudo.

Step 2: Look at the empty ruleset

Ask the kernel for its current firewall rules:

sudo nft list ruleset

Notice that there is no output at all. The kernel has no rules, so every packet is allowed in and out. The nftables package doesn't load anything by itself: rules live in the kernel's memory, and you add them with nft.

Step 3: Create a table

Rules live in chains, and chains live in tables. Create a table called filter in the inet family, which handles IPv4 and IPv6 together:

$ sudo nft add table inet filter
$ sudo nft list ruleset
table inet filter {
}

The table exists, but it's empty, so it doesn't affect any traffic yet.

Step 4: Create an input chain

Now add a chain called input that sees every packet addressed to this server. Create it with the policy accept for now, so nothing changes while you add rules:

$ sudo nft add chain inet filter input '{ type filter hook input priority filter; policy accept; }'
$ sudo nft list ruleset
table inet filter {
    chain input {
        type filter hook input priority filter; policy accept;
    }
}

The part in braces makes this a base chain: hook input attaches it to the point where the kernel delivers packets to local programs, and the policy is what happens to a packet that no rule matches. The single quotes stop the shell from interpreting the braces and semicolons.

Step 5: Add rules that keep you connected

Add the rules every server needs, one at a time.

First, accept packets that belong to connections the server already knows about, including your SSH session, and drop packets that don't make sense for any connection:

sudo nft add rule inet filter input ct state established,related accept
sudo nft add rule inet filter input ct state invalid drop

Accept everything on the loopback interface, which programs on the server use to talk to each other:

sudo nft add rule inet filter input iif lo accept

Accept ICMP and ICMPv6, which ping uses and which IPv6 needs to work at all:

sudo nft add rule inet filter input meta l4proto '{ icmp, ipv6-icmp }' accept

Finally, accept new SSH connections on TCP port 22:

sudo nft add rule inet filter input tcp dport 22 accept

Look at the chain now:

$ sudo nft list chain inet filter input
table inet filter {
    chain input {
        type filter hook input priority filter; policy accept;
        ct state established,related accept
        ct state invalid drop
        iif "lo" accept
        meta l4proto { icmp, ipv6-icmp } accept
        tcp dport 22 accept
    }
}

Notice that the rules appear in the order you added them. The kernel checks them from top to bottom, and the first accept or drop decides.

Step 6: Drop everything else

So far every packet is still accepted, because the policy is accept. Change the policy to drop, so that anything your rules don't accept is discarded:

$ sudo nft add chain inet filter input '{ policy drop; }'
$ sudo nft list chain inet filter input
table inet filter {
    chain input {
        type filter hook input priority filter; policy drop;
        ct state established,related accept
        ct state invalid drop
        iif "lo" accept
        meta l4proto { icmp, ipv6-icmp } accept
        tcp dport 22 accept
    }
}

Your SSH session keeps working, because its packets match the first rule. Now check that new connections work too: open a second terminal on your own computer and log in to the server again with ssh. The login succeeds, thanks to the tcp dport 22 rule. Keep the first session open until you've seen this work.

If the second login fails, don't close the first session. In it, set the policy back to accept, which lets every connection in again, and check the rules from Step 5:

sudo nft add chain inet filter input '{ policy accept; }'

Step 7: Watch the firewall block a port

To see the firewall at work, you need a service to connect to and a client that is not on the server itself.

Start a small web server on port 8080 in the background:

python3 -m http.server 8080 --directory /tmp > /dev/null 2>&1 &

Check that it answers on the server itself:

$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/
200

That worked, because traffic on lo is accepted. Any connection from the server to itself goes over lo, even to its public address, so you need a separate client. Instead of a second computer, create one inside the server: a network namespace called client, with its own network stack, joined to the server by a virtual cable (a veth pair). The server's end gets the address 192.0.2.1 and the client's end 192.0.2.2:

sudo ip netns add client
sudo ip link add veth-server type veth peer name veth-client netns client
sudo ip address add 192.0.2.1/24 dev veth-server
sudo ip link set veth-server up
sudo ip -n client address add 192.0.2.2/24 dev veth-client
sudo ip -n client link set veth-client up

To the firewall, packets from 192.0.2.2 arrive on the interface veth-server, exactly like packets from another machine. Run ping from the client:

$ sudo ip netns exec client ping -c 2 192.0.2.1
PING 192.0.2.1 (192.0.2.1) 56(84) bytes of data.
64 bytes from 192.0.2.1: icmp_seq=1 ttl=64 time=0.045 ms
64 bytes from 192.0.2.1: icmp_seq=2 ttl=64 time=0.051 ms

--- 192.0.2.1 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1011ms
rtt min/avg/max/mdev = 0.045/0.048/0.051/0.003 ms

The ICMP rule lets it through. Now try the web server from the client, giving up after five seconds:

$ sudo ip netns exec client curl --max-time 5 http://192.0.2.1:8080/
curl: (28) Connection timed out after 5000 milliseconds

The connection times out. No rule accepts TCP port 8080, so the drop policy silently discards the client's connection request, and the client waits in vain for an answer.

Step 8: Open port 8080

Add a rule that accepts port 8080. This time, add a counter to the rule, so you can see it working:

sudo nft add rule inet filter input tcp dport 8080 counter accept

Try again from the client:

$ sudo ip netns exec client curl -s -o /dev/null -w '%{http_code}\n' --max-time 5 http://192.0.2.1:8080/
200

The web server answers. Look at the chain:

$ sudo nft list chain inet filter input
table inet filter {
    chain input {
        type filter hook input priority filter; policy drop;
        ct state established,related accept
        ct state invalid drop
        iif "lo" accept
        meta l4proto { icmp, ipv6-icmp } accept
        tcp dport 22 accept
        tcp dport 8080 counter packets 1 bytes 60 accept
    }
}

Notice the counter: exactly one packet matched the new rule. That was the client's first packet, the request to open the connection. Every later packet of the same connection matched the established rule at the top instead.

You've finished with the test client and the web server, so remove them:

sudo ip netns delete client
pkill -f 'http.server 8080'

Deleting the namespace also deletes the virtual cable.

Step 9: Save the ruleset

Your firewall exists only in the kernel's memory, so a reboot would erase it. Debian loads its firewall at boot from the file /etc/nftables.conf. Write your ruleset to that file, starting with two lines that make it replace any existing rules when it's loaded:

printf '#!/usr/sbin/nft -f\n\nflush ruleset\n\n' | sudo tee /etc/nftables.conf > /dev/null
sudo nft --stateless list ruleset | sudo tee -a /etc/nftables.conf > /dev/null

The --stateless option leaves out the counter's current values, so the saved file contains only rules. Look at the result:

$ cat /etc/nftables.conf
#!/usr/sbin/nft -f

flush ruleset

table inet filter {
    chain input {
        type filter hook input priority filter; policy drop;
        ct state established,related accept
        ct state invalid drop
        iif "lo" accept
        meta l4proto { icmp, ipv6-icmp } accept
        tcp dport 22 accept
        tcp dport 8080 counter accept
    }
}

Ask nft to check the file without loading it:

sudo nft --check --file /etc/nftables.conf

No output means the file is valid.

Step 10: Load the firewall at boot

The nftables service loads /etc/nftables.conf when it starts. Enable it so that it starts at every boot, and start it now:

$ sudo systemctl enable --now nftables
$ systemctl is-enabled nftables
enabled

Starting the service loaded /etc/nftables.conf. Its flush ruleset line and your rules are applied as one transaction, so the kernel's rules were replaced by the same rules from the file, with no moment in between without a firewall. Your SSH session carried on without a pause.

Now reboot the server to prove that the firewall comes back by itself:

sudo systemctl reboot

Wait a minute, log in again over SSH, and list the ruleset:

$ sudo nft list ruleset
table inet filter {
    chain input {
        type filter hook input priority filter; policy drop;
        ct state established,related accept
        ct state invalid drop
        iif "lo" accept
        meta l4proto { icmp, ipv6-icmp } accept
        tcp dport 22 accept
        tcp dport 8080 counter packets 0 bytes 0 accept
    }
}

Your firewall is back, with the counter starting again from zero. The fact that you could log in at all shows that the SSH rule works from a fresh boot.

What you have learned

  • nftables rules live in chains inside tables; an inet table handles IPv4 and IPv6 together.
  • A base chain attaches to a hook such as input, and its policy decides what happens to packets no rule matches.
  • A safe firewall accepts established connections, loopback, ICMP and SSH before the policy becomes drop.
  • The firewall can't be tested from the server itself, because local traffic uses lo; a network namespace makes a convenient test client.
  • A counter shows how many packets a rule matched.
  • Rules live in kernel memory: save them to /etc/nftables.conf and enable the nftables service to load them at boot.

Next steps