Linux & Systems

fail2ban for SSH on Debian 13

Install fail2ban on Debian 13, understand the sshd jail the package already enables, tune bans in a local override, and check, test, and undo bans safely.

6 min read
securitysshdebianfirewallsystemd

Rows of waves breaking against a long rock breakwater, with calmer water on the near side

Any server with port 22 open gets a steady trickle of strangers trying root, admin, and test with passwords from someone else’s list. fail2ban watches for those failures. After a few from the same address, it blocks that address in the firewall for a while. Think of it as a bouncer with a short memory: ten minutes, by default.

It helps to know what it’s for. If SSH only accepts keys, those guesses can’t succeed anyway, and fail2ban mostly cuts down log noise and wasted connections. If passwords are still allowed, turning them off matters more than anything in this post.

Debian 13 (trixie) ships fail2ban 1.1.0, and the Debian package configuration matters more than the upstream defaults many older guides describe.

What Debian already turned on

sudo apt update
sudo apt install fail2ban
cat /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

That short file answers three questions:

  • Is the SSH jail on? Yes. A jail is one log source, one filter, and one ban action working together, and installing the package is enough to start this one.
  • Where does it read logs? From the systemd journal (backend = systemd). That fixes the problem behind many broken setups: Debian hasn’t installed rsyslog by default since Debian 12, so there’s often no /var/log/auth.log for fail2ban to watch. You don’t need to add backend = systemd yourself on Debian 13, because the package already does. Ubuntu’s packages also use the journal: 26.04 ships this same file, and 24.04 sets backend = systemd under [DEFAULT].
  • How does it ban? With nftables, in a table of its own. More on that below.

Leave jail.conf alone

jail.conf belongs to the package and can be replaced on upgrade. The file says so near the top, in capital letters. fail2ban reads jail.conf, then jail.d/*.conf, then jail.local, then jail.d/*.local, and a later setting overrides an earlier one. So a file such as /etc/fail2ban/jail.d/sshd.local loads last and changes only the settings it names:

[sshd]
bantime  = 1h
findtime = 10m
maxretry = 5

The three settings work together. If an address fails maxretry times within findtime, it’s banned for bantime. The defaults are 5 failures in 10 minutes for a 10-minute ban. The example keeps the same trigger and makes the ban last an hour.

Two more settings are worth knowing:

bantime.increment = true
bantime.maxtime   = 1w
ignoreip = 127.0.0.1/8 ::1 198.51.100.7
  • bantime.increment makes each repeat ban longer than the last, doubling by default and stopping at bantime.maxtime. Persistent visitors get the hint.
  • ignoreip lists addresses that are never banned. ignoreself is already on, so the server’s own addresses are covered. Add your static home or office IP if you have one. Don’t add your provider’s whole range just in case, because the bots rent from the same providers.

If sshd listens on a port other than 22, add port = 2222 (or your port) to the [sshd] section too. The ban rule only rejects traffic to the jail’s configured port, which is ssh by default. Without that line, fail2ban would carefully ban attackers on a port nobody is using.

Test before you reload

sudo fail2ban-client -t
OK: configuration test is successful

Read the lines above it as well. A typo such as maxretry = five prints WARNING Wrong value for 'maxretry' in 'sshd'. Using default one: 'None' and still finishes with OK. Treat any warning as a failed test. When the output is clean, apply the change:

sudo systemctl reload fail2ban

Check the jail

sudo fail2ban-client status sshd
Status for the jail: sshd
|- Filter
|  |- Currently failed:	2
|  |- Total failed:	47
|  `- Journal matches:	_SYSTEMD_UNIT=ssh.service + _COMM=sshd
`- Actions
   |- Currently banned:	3
   |- Total banned:	9
   `- Banned IP list:	203.0.113.24 198.51.100.81 192.0.2.140

Currently failed counts addresses with recent failures that haven’t hit the limit yet. Journal matches confirms that the jail is reading the journal. If you see File list: /var/log/auth.log instead, something has overridden the backend, and that file may not exist.

To check that the filter recognizes your sshd’s messages without banning anyone:

sudo fail2ban-regex systemd-journal sshd

The Lines: summary at the end shows how many journal lines matched. On an internet-facing server, zero matches is suspicious, because the bots are not shy. To read the raw attempts yourself, journalctl -u ssh.service --since today is the place to start, and a gentle first pass with journalctl shows how to narrow it down.

Unban someone, including yourself

sudo fail2ban-client set sshd unbanip 203.0.113.24   # one jail
sudo fail2ban-client unban 203.0.113.24              # every jail
sudo fail2ban-client banned                          # who is banned where

If you’ve banned yourself, you can’t run those commands from the banned address. Wait out the bantime, which is one reason not to make it huge, connect from another network, or use your provider’s web console.

To confirm the ban action works without risking your own access, ban an address reserved for documentation and inspect the result:

sudo fail2ban-client set sshd banip 192.0.2.10
sudo nft list table inet f2b-table
sudo fail2ban-client set sshd unbanip 192.0.2.10

The address should appear in a set named addr-set-sshd. Nobody real uses 192.0.2.0/24, so nobody is inconvenienced.

How it fits with ufw and nftables

fail2ban keeps its bans in its own nftables table, inet f2b-table, with a chain hooked to incoming traffic at priority -1. That puts it just ahead of the standard filter priority, 0, used by ufw’s rules on Debian 13 and Ubuntu, because ufw drives iptables through the nftables backend. Netfilter runs every one of those chains in priority order. An accept in one chain doesn’t skip the others, but a drop or reject ends the packet’s trip. So a banned address is turned away even though ufw shows 22/tcp ALLOW or LIMIT, and neither tool needs to know about the other.

The table is created on demand at the first ban. If nft list table inet f2b-table complains that the table doesn’t exist, nothing has been banned since fail2ban last started.

If you’d rather see bans in ufw status, add banaction = ufw to your [sshd] override. fail2ban then puts reject rules at the top of ufw’s list, with a comment saying who added them. Either way works, and the default needs no extra setup.

ufw limit and fail2ban also work together. limit reacts to bursts of new connections within 30 seconds. fail2ban reacts to failed logins over a longer window. They watch different signals, so running both is fine. (Setting up ufw itself, without locking yourself out, is its own short post.)

References

Cover photo: Unsplash.