Fail2ban on Debian 13: The Right Config for OpenSSH 9.x

· 7 min read
linux
Part of the Linux Server Administration Series

This post is a deep-dive companion to Linux Server Security Baseline. That post covers seven hardening steps including a simplified Fail2ban setup. This one covers everything that post intentionally left out.

A basic fail2ban set up on AWS EC2 - Debian 13, configure, and we will have a live test — using a wrong key to trigger a ban on a real EC2 instance.


How Fail2ban Works

Fail2ban watches a log source — a file or the systemd journal — for lines matching a filter regex. When the same IP matches the filter maxretry times within findtime, it triggers a ban action — typically a UFW or nftables rule that drops traffic from that IP for bantime.

SSH attempt (wrong key)


systemd journal logs "Failed publickey"


  Filter match? ───No──▶ ❌ Missed (silent failure)

       Yes


  Count failure for IP


  Failures ≥ maxretry? ───No──▶ Wait for next attempt

       Yes


  🚫 Ban IP (UFW DENY rule)

On Debian 13, the default config hits the “No” path for three different reasons. Each one silently drops the event — Fail2ban runs, the jail is active, and the count stays at zero.

Prerequisites

  • Debian 13 server hardened per the Linux Server Security Baseline
  • Custom SSH port configured (this guide uses 2233)
  • systemd is running — default on Debian 13

Step 1: Install Fail2ban

sudo apt install -y fail2ban

Do not start it yet. Configure it first.

Step 2: Create a Minimal jail.local

The common advice is to copy jail.conf to jail.local and edit it.

sudo nano /etc/fail2ban/jail.local
[DEFAULT]
ignoreip = 127.0.0.1/8 ::1 <your-ip>

[sshd]
enabled      = true
port         = 2233
backend      = systemd
journalmatch = _SYSTEMD_UNIT=ssh.service
filter       = sshd[mode=aggressive]
bantime      = 1h
findtime     = 10m
maxretry     = 5
SettingWhat It Does
ignoreipIPs that will never be banned — add your own IP or CIDR
portMust match your SSH port
backendsystemd — reads from the journal, not a file
journalmatchWhich journal entries to watch — see Step 3
filtersshd[mode=aggressive] — counts publickey failures, not just passwords
bantimeHow long a banned IP stays blocked
findtimeThe window for counting failures
maxretryFailed attempts within findtime that trigger a ban
💡 Whitelist Your Own IP

Add your IP or network to ignoreip so you can never accidentally ban yourself. Supports multiple entries space-separated: ignoreip = 127.0.0.1/8 ::1 203.0.113.10 10.0.0.0/8

Step 3: Enable LogLevel VERBOSE in sshd

This is the second non-obvious issue.

At the default INFO log level, a wrong-key attempt logs as:

Connection closed by authenticating user admin x.x.x.32 port 60869 [preauth]

Fail2ban’s sshd filter does not match Connection closed. The failure goes undetected.

With LogLevel VERBOSE, the same event produces:

Failed publickey for admin from x.x.x.32 port 27370 ssh2: ED25519 SHA256:pjC7...
Connection closed by authenticating user admin x.x.x.32 port 27370 [preauth]

The Failed publickey line is matched by the filter.

Add this to /etc/ssh/sshd_config:

LogLevel VERBOSE

Restart sshd:

sudo systemctl restart sshd
Keep Your Session Open

Keep your existing SSH session open while restarting sshd. Open a second terminal and verify you can still connect before closing anything.

Step 6: Start and Enable Fail2ban

sudo systemctl enable --now fail2ban

Verify the SSH jail is active:

sudo fail2ban-client status sshd
Status for the jail: sshd
|- Filter
|  |- Currently failed: 0
|  |- Total failed:     0
|  `- Journal matches:  _SYSTEMD_UNIT=ssh.service
`- Actions
   |- Currently banned: 0
   |- Total banned:     0
   `- Banned IP list:

Two things to confirm:

  • Journal matches shows _SYSTEMD_UNIT=ssh.service
  • Jail is active with 0 errors

Step 7: Test — Wrong Key Attempt

Use a different key pair (or generate a throwaway one) and try to connect repeatedly:

ssh -i wrong-key.pem admin@<ec2-public-ip> -p 2233

The first attempts return the expected auth failure:

admin@<ec2-public-ip>: Permission denied (publickey).

Check the journal — each attempt now produces a Failed publickey line from sshd-session:

sudo journalctl -u ssh -n 10
Mar 16 11:07:43 hostname sshd-session[10804]: Connection from 203.0.113.50 port 59262 on 10.0.1.100 port 2233 rdomain ""
Mar 16 11:07:44 hostname sshd-session[10804]: Failed publickey for admin from 203.0.113.50 port 59262 ssh2: ED25519 SHA256:pjC7...
Mar 16 11:07:44 hostname sshd-session[10804]: Connection closed by authenticating user admin 203.0.113.50 port 59262 [preauth]
Mar 16 11:07:44 hostname sshd[5634]: srclimit_penalise: ipv4: new 203.0.113.50/32 deferred penalty of 5 seconds
Mar 16 11:07:46 hostname sshd-session[10806]: Failed publickey for admin from 203.0.113.50 port 7397 ssh2: ED25519 SHA256:pjC7...
Mar 16 11:07:48 hostname sshd-session[10808]: Failed publickey for admin from 203.0.113.50 port 26010 ssh2: ED25519 SHA256:pjC7...
Mar 16 11:07:51 hostname sshd-session[10810]: Failed publickey for admin from 203.0.113.50 port 16599 ssh2: ED25519 SHA256:pjC7...

Keep trying. On the 5th attempt, the ban triggers. The 6th attempt looks different — SSH does not even respond with Permission denied:

Connection closed by 198.51.100.10 port 2233

That is the ban in action — Fail2ban dropped the connection before SSH could respond.

Step 8: Verify the Ban

sudo fail2ban-client status sshd
Status for the jail: sshd
|- Filter
|  |- Currently failed:	0
|  |- Total failed:	5
|  `- Journal matches:	_SYSTEMD_UNIT=ssh.service
`- Actions
   |- Currently banned:	1
   |- Total banned:	1
   `- Banned IP list:	203.0.113.50

Total failed: 5 — all five wrong-key attempts were counted. Currently banned: 1 — the ban triggered after the 5th failure. Currently failed: 0 — the counter resets after a ban.

Verify the UFW rule was added:

sudo ufw status
Anywhere                   DENY IN     203.0.113.50

To manually unban an IP (your own, if you locked yourself out):

sudo fail2ban-client set sshd unbanip <ip>

Tuning for Production

SettingDevelopmentProduction
bantime1h24h or 1w
findtime10m10m
maxretry53
ignoreipYour home IPYour team’s IPs or VPN CIDR

For production servers with real exposure, a 24-hour ban and 3 retries is more appropriate. Bots retry thousands of times — a short ban just means they come back in an hour.

💡 Recidive Jail

Fail2ban has a built-in recidive jail that permanently bans IPs that keep returning after their ban expires. Add it to jail.local for persistent abusers:

[recidive]
enabled  = true
bantime  = 1w
findtime = 1d
maxretry = 5

Useful Commands

# Check which jails are active
sudo fail2ban-client status

# Status of the SSH jail
sudo fail2ban-client status sshd

# Unban an IP
sudo fail2ban-client set sshd unbanip <ip>

# Reload config after editing jail.local
sudo fail2ban-client reload

# View Fail2ban logs
sudo journalctl -u fail2ban -n 50

# Test the sshd filter against the journal manually
sudo fail2ban-regex systemd-journal "/etc/fail2ban/filter.d/sshd.conf[mode=aggressive]" --journalmatch="_SYSTEMD_UNIT=ssh.service"

Wrapping Up

I spent a good while staring at a Fail2ban jail that looked perfectly healthy — running, no errors, zero failures — while wrong-key attempts piled up in the journal.

If you followed along, your server now has a working Fail2ban setup that actually bans wrong-key attempts. Test it yourself — use a wrong key, watch the counter go up, and see the ban drop the connection.

The full config is eight lines in jail.local and one line in sshd_config. Copy it, verify it, and move on.

What's Next

With SSH locked down and Fail2ban running, the next layer is UFW — controlling exactly which ports are reachable at the OS level. See Linux Server Security Baseline for the full setup.