Fail2ban on Debian 13 with nftables

8 min read

Any Debian box with SSH open to the internet collects failed logins within minutes of going online. Fail2ban is still the standard fix. It reads logs, counts failures per IP and adds a firewall ban when an IP crosses a threshold. On Debian 13 (trixie) the package works differently from the old iptables and /var/log/auth.log setup: it reads the systemd journal and bans through nftables. This article covers what the stock fail2ban package on Debian 13 does, how to tune it without editing vendor files, and how to check that bans are really reaching the nftables ruleset.

What Debian 13 ships

Trixie ships fail2ban 1.1.0-8. Upstream released 1.1.0 in April 2024, mainly to support Python 3.12 and 3.13. The Debian package depends on python3 and python3-systemd. It only recommends nftables | iptables, python3-pyinotify, python3-setuptools and whois, so if you install with --no-install-recommends, the firewall tool may be missing.

The Debian-specific defaults live in one small file, /etc/fail2ban/jail.d/defaults-debian.conf:

[DEFAULT]
banaction = nftables
banaction_allports = nftables[type=allports]

[sshd]
backend = systemd
journalmatch = _SYSTEMD_UNIT=ssh.service + _COMM=sshd
enabled = true

This file does three things:

  • Every jail bans through the nftables action by default, and not through iptables-multiport.
  • The sshd jail reads the journal directly instead of a log file. The match uses ssh.service, which is Debian's unit name. It does not use sshd.service.
  • The sshd jail is enabled as soon as the package is installed.
SettingUpstream jail.confDebian 13 package
banactioniptables-multiportnftables
backend (sshd)%(sshd_backend)s (systemd on Debian, via paths-debian.conf)systemd
bantime10munchanged
findtime10munchanged
maxretry5unchanged
sshd enablednoyes

A 10-minute ban after 5 failures is a gentle default. It cuts down log noise but barely slows a botnet. The tuning section below fixes that.

Installing fail2ban on Debian 13 with nftables

apt update
apt install fail2ban nftables
systemctl enable --now fail2ban
systemctl status fail2ban

You do not need nftables.service running for fail2ban to work. The action loads its own table with nft the first time it bans. You do need the nft binary, which comes in the nftables package.

Check that the sshd jail is running and reading the right journal source:

fail2ban-client status
fail2ban-client status sshd

The sshd status output should list Journal matches: _SYSTEMD_UNIT=ssh.service + _COMM=sshd. If Total failed stays at 0 on a server that you know gets attacked, the journal match is wrong. This often happens after someone copies an old jail.local that switches the sshd jail back to backend = auto with logpath = /var/log/auth.log. Recent Debian releases no longer install rsyslog by default, so that file may not exist at all.

How the nftables action works

The upstream action.d/nftables.conf doesn't modify your own ruleset. It builds a separate one:

  • a table named f2b-table in the inet family, so one table covers IPv4 and IPv6
  • a chain named f2b-chain on the input hook with priority -1
  • one set per jail: addr-set-<jail> for IPv4 and addr6-set-<jail> for IPv6
  • blocktype = reject by default
  • a multiport mode that matches only the jail's ports, and an allports mode that matches every port for the protocol

Inspect it after the first ban:

nft list table inet f2b-table
nft list set inet f2b-table addr-set-sshd

Because the chain runs at priority -1, fail2ban's rules are evaluated before a normal filter chain at priority 0. A reject there is final. Your own later chains cannot let a banned IP back in, which is the behaviour you want.

The "flush ruleset" trap

Debian's stock /etc/nftables.conf begins with flush ruleset. Restarting or reloading nftables.service therefore deletes every table, including f2b-table. Fail2ban does not notice this right away: the IPs stay listed as banned in fail2ban-client status but are no longer blocked. The nftables action has an actioncheck, so the next time a banned IP shows up again or a new ban fails, fail2ban logs Invariant check failed. Trying to restore a sane environment, recreates f2b-table and re-bans. Until that happens, the old bans are gone.

There are two ways to handle it. You can restart fail2ban after every firewall reload:

systemctl restart nftables
systemctl restart fail2ban

Or you can flush only your own table in /etc/nftables.conf instead of the whole ruleset:

-flush ruleset
+table inet filter
+delete table inet filter
 table inet filter {
     ...
 }

With the second approach, reloading your firewall leaves fail2ban's table untouched. If you use an allports action and drop instead of reject, set it per jail and leave the global default alone.

Tuning jails without touching vendor files

Never edit jail.conf or defaults-debian.conf. Package upgrades replace them. According to jail.conf(5), the load order is:

  1. jail.conf
  2. jail.d/*.conf (alphabetical)
  3. jail.local
  4. jail.d/*.local (alphabetical)

Later files win. A single jail.d/*.local file is enough:

[DEFAULT]
# never ban yourself: admin VPN and office range
ignoreip = 127.0.0.1/8 ::1 192.0.2.0/24 2001:db8:100::/48
bantime  = 1h
findtime = 10m
maxretry = 4

# grow bans for repeat offenders
bantime.increment = true
bantime.maxtime   = 4w
bantime.rndtime   = 10m

[sshd]
mode = aggressive
port = 22

Notes on those settings:

  • ignoreself is true by default, so the host's own addresses are never banned. ignoreip adds to that list. Put your management IPs in it before you tighten anything else.
  • bantime.increment looks up an IP's earlier bans in fail2ban's database and multiplies the next ban. With the default bantime.factor = 1 each repeat ban doubles: 1h, 2h, 4h, 8h and so on. A factor of 2 would double that again (1h, 4h, 8h, 16h). bantime.maxtime caps the growth. bantime.rndtime adds random jitter, so a botnet can't compute exactly when it will be unbanned.
  • mode = aggressive on the sshd filter combines the normal, ddos and extra patterns. It catches pre-auth disconnects and similar noise as well as wrong passwords.
  • If SSH runs on a non-standard port, set port to it. Otherwise the multiport rule protects the wrong port.

Apply and verify:

fail2ban-client -t
fail2ban-client reload
fail2ban-client get sshd bantime
fail2ban-client get sshd maxretry

-t checks the configuration before you reload, which matters most on a remote box.

Recidive jail for persistent attackers

The upstream recidive jail watches fail2ban's own log. IPs that get banned again and again are blocked on all ports for a week:

[recidive]
enabled  = true
logpath  = /var/log/fail2ban.log
banaction = %(banaction_allports)s
bantime  = 1w
findtime = 1d

On Debian this uses nftables[type=allports] through banaction_allports. This jail reads a file rather than the journal, so check that fail2ban actually writes to it:

grep -E '^logtarget' /etc/fail2ban/fail2ban.conf /etc/fail2ban/fail2ban.d/*.local 2>/dev/null
ls -l /var/log/fail2ban.log

Upstream's jail.conf also suggests raising dbpurgeage (default 86400 seconds, one day) when you rely on long history. This matters with bantime.increment: if the database forgets an IP after a day, the escalation starts from zero again.

[DEFINITION]
dbpurgeage = 30d

Testing and day-to-day operations

Test the filter against the journal

fail2ban-regex can read the journal directly. That shows what the sshd filter would match without waiting for a real attack. Without extra options it uses the filter's own journal match, which on Debian is _SYSTEMD_UNIT=ssh.service + _COMM=sshd + _COMM=sshd-session:

fail2ban-regex systemd-journal sshd

To test exactly the match the jail uses, pass it with -m:

fail2ban-regex -m '_SYSTEMD_UNIT=ssh.service + _COMM=sshd' systemd-journal sshd

Look at the Failregex hit count. A count of zero on a busy host usually means the journal match is wrong, not that nobody is attacking.

Ban, unban, inspect

fail2ban-client set sshd banip 203.0.113.10
nft list set inet f2b-table addr-set-sshd
fail2ban-client set sshd unbanip 203.0.113.10
fail2ban-client unban --all

The manual ban is the quickest end-to-end test. It proves that fail2ban can call nft and that the set is filled. Use a documentation address or a test host you control, never your own session IP.

Logs

journalctl -u fail2ban -e
tail -f /var/log/fail2ban.log

A ban looks like this:

fail2ban.actions [1234]: NOTICE  [sshd] Ban 203.0.113.10

Known issue: reload fails with "No data available"

An upstream bug report (fail2ban#4238) from a trixie system describes systemctl reload fail2ban failing with OSError(61, 'No data available') when zero-byte journal files are present under /var/log/journal. The reporter was running an upstream 1.1.1 build on a VPS image, not the stock 1.1.0-8. The issue is still open: the reporter got it working by deleting an old journal directory, and the maintainer called the empty files an unusual leftover of that image and posted a possible workaround that is not merged. It is unlikely to hit a normal install, but it is cheap to check:

find /var/log/journal -type f -name '*.journal' -size 0

Where fail2ban fits

Fail2ban reduces noise and slows down password guessing. It doesn't stop a determined attacker with many addresses, and it adds nothing if the service is already safe against guessing. Key-only SSH authentication does far more, as described in hardening SSH on Debian 13 with OpenSSH 10. Use fail2ban as one layer on top of that. The same applies to web services: jails for Nginx or Apache auth failures are useful, but they don't replace the basics in web server security for self-hosters.

On security status: when I checked the Debian security tracker, it listed no open CVEs against trixie's fail2ban 1.1.0-8. All the entries it shows are old issues, marked fixed. Check the tracker yourself before relying on that.

Takeaways

  • Debian 13 ships fail2ban 1.1.0-8. By default it bans through nftables and runs the sshd jail on the systemd journal (ssh.service).
  • Put overrides in /etc/fail2ban/jail.d/*.local. Never edit jail.conf or defaults-debian.conf.
  • Add your management IPs to ignoreip before you tighten anything.
  • Raise bantime, turn on bantime.increment and raise dbpurgeage so repeat offenders get longer bans.
  • Enable recidive after confirming that /var/log/fail2ban.log is being written.
  • Look at nft list table inet f2b-table after a test ban. Don't rely only on fail2ban's own status.
  • A flush ruleset in /etc/nftables.conf deletes fail2ban's table. Restart fail2ban after firewall reloads, or flush only your own table.
  • Run fail2ban-client -t before every reload.

Sources

Comments