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:
Check that it works:
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:
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:
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:
Accept ICMP and ICMPv6, which ping uses and which IPv6 needs to work at all:
Finally, accept new SSH connections on TCP port 22:
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:
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:
Check that it answers on the server itself:
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:
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:
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:
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:
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:
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
inettable 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
countershows how many packets a rule matched. - Rules live in kernel memory: save them to
/etc/nftables.confand enable thenftablesservice to load them at boot.
Next steps¶
- How to open a port in the firewall: open a port on Debian, Ubuntu, RHEL, Arch and Gentoo with each distribution's usual firewall tool.
- Netfilter architecture: what hooks and connection tracking are, and why the rules in this tutorial are in this order.
Related¶
- How-to: How to open a port in the firewall
- Reference: nftables syntax
- Explanation: Netfilter architecture