nftables Default-Drop Firewall on Debian 13

12 min read

Most guides for firewalling a Debian server tell you to install ufw or firewalld. Others paste in an nftables ruleset and run nft -f over SSH, hoping it works. This tutorial writes a small nftables firewall on Debian 13 by hand. Inbound traffic is dropped by default, with exceptions for SSH and the web ports. We cover IPv6 and ICMPv6, add a per-source rate limit on SSH, and apply the rules with a timed rollback, so a typo does not lock you out. Then we test the firewall from outside the server, and look at what changes when Docker is installed.

What we build and prerequisites

The result is one file, /etc/nftables.conf, with one table of type inet, so it filters IPv4 and IPv6 together. It contains:

  • an input chain with policy drop
  • accept rules for established/related traffic, and a drop rule for invalid packets
  • loopback accepted
  • the ICMP and ICMPv6 types that networking needs, with echo requests rate-limited
  • SSH and web ports kept in named sets
  • a per-source limit on new SSH connections
  • a rate-limited log rule just before the default drop

It does not touch forwarding or NAT. For a VPN gateway, see WireGuard on Debian 13 with nftables. To ban hosts after failed logins, add Fail2ban with the nftables backend on top of this ruleset.

ComponentDebian 13 (trixie)Notes
nftables1.1.3-1Provides nft, nftables.service and the default /etc/nftables.conf
docker.io26.1.5+dfsg1-9+deb13u1Depends on iptables; writes its rules through iptables
nmapanyInstall it on a different machine for outside-in tests

You need root, a second way into the box (a provider console, IPMI or KVM) in case something goes wrong, and an open SSH session that you keep open until the end.

Step 1: Install nftables and record the current state

apt update
apt install nftables
nft --version
nft list ruleset > /root/nft-before.nft
cp -a /etc/nftables.conf /root/nftables.conf.orig
ss -tulnp

The output of ss -tulnp lists every listening socket. Anything bound to 0.0.0.0, [::] or * can currently be reached from the network. Note which services must stay reachable from outside. Everything else gets blocked.

Also check whether iptables on this host is the nf_tables variant or the legacy one:

update-alternatives --display iptables
iptables -V

On Debian the default is iptables-nft, and iptables -V ends in (nf_tables). If it says (legacy), something has switched it. That matters later.

Step 2: Write the nftables firewall ruleset

The default file shipped by Debian begins with flush ruleset, which deletes every table in the kernel. This ruleset avoids that. Docker and anything else that uses iptables-nft keep their own tables, and on a reload we replace only ours. The table … / delete table … pair at the top is a standard nftables pattern. The first line creates the table if it is missing, so the delete never fails, and the full file is applied in one atomic transaction.

#!/usr/sbin/nft -f
# Host firewall. No "flush ruleset": other tables (Docker, iptables-nft)
# are left alone; only inet host_fw is replaced on every load.

table inet host_fw
delete table inet host_fw

table inet host_fw {
    set ssh_ports {
        type inet_service
        elements = { 22 }
    }

    set web_ports {
        type inet_service
        elements = { 80, 443 }
    }

    set ssh_rl_v4 {
        type ipv4_addr
        flags dynamic, timeout
        timeout 2m
    }

    set ssh_rl_v6 {
        type ipv6_addr
        flags dynamic, timeout
        timeout 2m
    }

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

        ct state vmap { established : accept, related : accept, invalid : drop }
        iifname "lo" accept

        # ICMPv4: errors always, echo rate-limited
        icmp type { destination-unreachable, time-exceeded, parameter-problem } accept
        icmp type echo-request limit rate 5/second burst 10 packets accept

        # ICMPv6: errors + neighbor discovery, or IPv6 breaks
        icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem } accept
        icmpv6 type { nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert, mld-listener-query } accept
        icmpv6 type echo-request limit rate 5/second burst 10 packets accept

        # SSH: max 10 new connections/minute per source address
        tcp dport @ssh_ports ct state new counter update @ssh_rl_v4 { ip saddr limit rate 10/minute burst 5 packets } accept
        tcp dport @ssh_ports ct state new counter update @ssh_rl_v6 { ip6 saddr limit rate 10/minute burst 5 packets } accept

        tcp dport @web_ports counter accept
        udp dport 443 counter accept

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

Some notes on the choices:

  • No forward or output chain. Without a forward hook in our table, forwarding is left to the kernel sysctls and to whatever Docker installs. A forward chain with policy drop here would break container networking (more on that below). Output stays open, as on most servers.
  • ICMPv6. RFC 4890 lists the ICMPv6 types a firewall must not drop for traffic addressed to itself. These include Packet Too Big (type 2) and the neighbor discovery messages (types 133–136). If you drop neighbor solicitations or advertisements, IPv6 keeps working for a while and then fails once the neighbor cache entries expire. That makes the problem hard to trace.
  • SSH rate limit. This follows the dynamic-set pattern from the nftables wiki. Each source address gets its own entry with its own limit. If a source exceeds 10 new connections per minute, its packets match neither SSH rule and fall through to the logged drop. IPv4 and IPv6 need separate sets because the key types differ.
  • udp dport 443 is only needed if your web server speaks HTTP/3 (QUIC). Delete the line if it doesn't.
  • The log rule has its own rate limit, so a port scan cannot fill the journal.

To restrict SSH to admin networks, add an ipv4_addr set with flags interval and match ip saddr @admin_v4 in the SSH rules. Settings inside sshd are covered in Hardening SSH on Debian 13 with OpenSSH 10.

Step 3: Check the syntax without applying it

nft -c -f /etc/nftables.conf
echo $?

-c (--check) validates the commands without changing the running ruleset. If it prints nothing and the exit code is 0, the file parses and the kernel accepts it. An error points to the line and column. Fix it and run the check again. Do not move on until it passes.

Step 4: Apply with a timed rollback

Warning: the next command activates a default-drop policy on a live server. If SSH is not in ssh_ports, or sshd listens on a different port, your session can freeze. Schedule the rollback before you apply.

The rollback file deletes only our table. The server is then back to its previous state, and Docker's tables are not touched:

cat > /root/nft-rollback.nft <<'EOF'
table inet host_fw
delete table inet host_fw
EOF
nft -c -f /root/nft-rollback.nft

Schedule it as a transient systemd timer, then load the ruleset:

systemd-run --on-active=5min --unit=nft-rollback /usr/sbin/nft -f /root/nft-rollback.nft
systemctl list-timers nft-rollback.timer
nft -f /etc/nftables.conf

systemd-run --on-active starts only the timer at first. When the timer fires, it runs the transient nft-rollback.service. If you prefer at (not installed by default: apt install at), this does the same:

echo "/usr/sbin/nft -f /root/nft-rollback.nft" | at now + 5 minutes

Now open a new SSH connection from your workstation. The existing session passes through the established-connection rule, so it proves nothing about new logins. If the new login works, cancel the rollback:

systemctl stop nft-rollback.timer
systemctl list-timers --all | grep nft-rollback

If you can't log in, wait up to five minutes. The timer removes the table, and you can fix the file and try again.

Step 5: Enable nftables.service

Look at the unit Debian ships before you rely on it:

systemctl cat nftables.service

The relevant lines in the Debian packaging are:

ExecStart=/usr/sbin/nft -f /etc/nftables.conf
ExecReload=/usr/sbin/nft -f /etc/nftables.conf
ExecStop=/usr/sbin/nft flush ruleset

The service runs before network-pre.target, so the rules are in place before any interface comes up. Enable it:

systemctl enable nftables.service
systemctl start nftables.service
systemctl status nftables.service --no-pager

Warning: because of ExecStop, both systemctl stop nftables and systemctl restart nftables run nft flush ruleset. That removes every table, Docker's and fail2ban's included, and with no tables loaded the host accepts all traffic. After editing the file, use nft -c -f and then systemctl reload nftables. Do not restart it.

If the old Debian default config was loaded before, an empty table inet filter with accept policies may still exist. It does no harm, but it adds noise. Check for it and delete it:

nft list tables
nft list table inet filter
nft delete table inet filter

Run the delete only if the listing shows the empty default chains, and not rules that some other tool installed.

How to verify the nftables firewall works

From the server

nft list ruleset
nft -a list chain inet host_fw input
nft list set inet host_fw ssh_rl_v4
ss -tlnp
ss -ulnp

Compare the ss output with the sets. Every socket that listens on a public address and is not in ssh_ports or web_ports should now be unreachable. Counters show which rules match traffic. Run nft list chain inet host_fw input twice, a minute apart, and check that the web and SSH counters go up.

From outside

Run these from a machine you control on a different network, against your own server only:

nmap -Pn -p- 203.0.113.10
nmap -6 -Pn -p 22,80,443,3306,6379 2001:db8::10
nmap -Pn -sU --top-ports 50 203.0.113.10

Allowed ports should show open and everything else filtered. Because the policy drops instead of rejecting, a TCP port that shows closed means a RST came back, so a packet got through the firewall somehow. Run the IPv6 scan too. Services such as Redis or MariaDB on [::] are often exposed over IPv6 only, which a v4-only firewall would miss.

Logs

journalctl -k --grep 'nft-drop' --since '10 min ago'

Log lines go to the kernel log, so they show up in journalctl -k. You should see the nmap probes there. To send them to a separate file or ship them elsewhere, see the auditing setup in auditd on Debian 13 for the general approach to low-noise logging.

Docker and iptables-nft: common pitfalls

Two front ends, one kernel

On Debian 13, iptables is iptables-nft. It translates rules into nftables tables named ip filter, ip nat and so on, which appear in nft list tables next to inet host_fw. Every base chain on the same hook sees the packet. The nft(8) man page notes that an accept in one base chain does not stop another base chain from dropping the packet later. A drop, on the other hand, ends processing. So an accept added with iptables does not open a port that our table drops. If the host has been switched to iptables-legacy, its rules sit in a separate kernel framework, are invisible to nft list ruleset, and still apply. Use one front end per host if you can, and check the alternative as in Step 1.

Published container ports bypass the input chain

The docker.io package in trixie (26.1.5) writes its rules through iptables. Docker's documentation explains that published ports are DNATed before the packets reach INPUT. The packets then go through the forward hook, so our input chain never sees them. A docker run -p 6379:6379 redis is open to the internet even with policy drop in place. The nmap scan above will show it as open. You can fix this in two ways:

  • Publish to loopback only: -p 127.0.0.1:8080:80 -p '[::1]:8080:80', and put a reverse proxy in front. Docker's documentation notes that before Docker 28.0.0, hosts on the same L2 segment could still reach ports published to localhost. Check whether your Debian build carries a fix for this before relying on it on a shared LAN.
  • Filter in Docker's DOCKER-USER chain, which Docker evaluates before its own forwarding rules. An example from Docker's documentation: iptables -I DOCKER-USER -i eth0 ! -s 192.0.2.2 -j DROP. These rules do not survive a reboot by themselves.

Docker also sets the iptables FORWARD policy to DROP and adds its own accept rules. That is why our table has no forward chain: a drop there would be final and would cut off containers. Docker 29.0.0 added an experimental firewall-backend: nftables mode that creates ip docker-bridges and ip6 docker-bridges tables. Docker says not to edit those tables and to use separate tables with matching hooks instead. The Debian package does not yet ship a release with this mode.

Other ways people lock themselves out or break things

  • flush ruleset in the config: every reload wipes Docker's NAT rules, and containers lose connectivity until systemctl restart docker.
  • Using table ip instead of table inet: IPv6 is left completely open.
  • SSH on a non-standard port: add it to ssh_ports before you apply, not afterwards.
  • Rate limit too low: Ansible or scripts that open many SSH connections in parallel will hit 10/minute. Raise the limit, or put automation hosts in a trusted set that is matched first.

Takeaways

  • Use one inet table so IPv4 and IPv6 are filtered together, with policy drop on input.
  • Accept established/related, loopback, ICMP errors and ICMPv6 neighbor discovery. Rate-limit pings.
  • Keep ports in named sets and limit new SSH connections per source with dynamic sets.
  • Replace your own table with table … / delete table … instead of flush ruleset.
  • Always run nft -c -f first, then schedule a systemd-run --on-active rollback, then apply, then test with a new SSH session.
  • Change the rules with systemctl reload nftables. Stop and restart run flush ruleset.
  • Test from outside with nmap over IPv4 and IPv6, and watch counters and journalctl -k.
  • On Docker hosts, published ports skip your input chain. Bind them to loopback or filter them in DOCKER-USER.

Sources

Comments