How to Install Fail2Ban on Debian 13, 12 and 11

Install Fail2Ban on Debian 13, 12 and 11 to block brute force attacks. Configure jails and firewall backends, troubleshoot.

Last updatedAuthorJoshua JamesRead time8 minGuide typeDebianDiscussion14 comments

SSH password probes and noisy web login attempts are easier to handle when Debian can block repeat offenders automatically instead of waiting for a manual firewall rule. You can install Fail2Ban on Debian from the default APT repositories, keep SSH protection active, and tune ban thresholds without adding a third-party package source.

Debian 13 (Trixie), Debian 12 (Bookworm), and Debian 11 (Bullseye) all package Fail2Ban, but their defaults differ. Debian 13 sets Fail2Ban to use the systemd journal and nftables, while Debian 12 and Debian 11 keep the older iptables action and may still expect /var/log/auth.log unless you set the systemd backend yourself.

Install Fail2Ban on Debian

Refresh APT metadata, apply available package updates, then install the Debian fail2ban package:

sudo apt update
sudo apt upgrade
sudo apt install fail2ban

Review the upgrade transaction before confirming on remote servers, especially when OpenSSH, kernel, networking, or firewall packages are included.

These commands use sudo for package and service changes. If the account cannot use sudo yet, add the account with the Debian sudoers workflow before changing security services.

Debian installs Fail2Ban and its recommended helpers from the standard main repository. The exact dependency set varies by release, but common helper packages include python3-systemd, python3-pyinotify, and whois.

Debian releasePackage versionDefault log backendDefault ban action
Debian 13 (Trixie)1.1.0systemd journalnftables
Debian 12 (Bookworm)1.0.2file log unless overriddeniptables-multiport
Debian 11 (Bullseye)0.11.2file log unless overriddeniptables-multiport

Check the installed version with fail2ban-client:

fail2ban-client --version
Fail2Ban v1.1.0

On Debian 12, the same command reports Fail2Ban v1.0.2. On Debian 11, it reports Fail2Ban v0.11.2. Confirm the package source when you need to prove the system is using Debian’s archive package:

apt-cache policy fail2ban
fail2ban:
  Installed: 1.1.0-8
  Candidate: 1.1.0-8
  Version table:
 *** 1.1.0-8 500
        500 http://deb.debian.org/debian trixie/main amd64 Packages
        100 /var/lib/dpkg/status

Create a Debian Fail2Ban Baseline

Keep local settings in a separate file under /etc/fail2ban/jail.d/ instead of editing /etc/fail2ban/jail.conf or /etc/fail2ban/jail.d/defaults-debian.conf. Package updates can replace the default files, while a local drop-in keeps only the settings you intentionally changed.

The following baseline keeps the SSH jail enabled, makes the log backend explicit, adds localhost to the allowlist, and tightens SSH failures to three attempts in ten minutes. The 99- prefix keeps this file loaded after Debian’s packaged defaults. Keep the localhost entries in ignoreip, and add your real management IP addresses or CIDR networks after them before saving if you administer the server remotely.

sudo install -d -m 0755 /etc/fail2ban/jail.d
sudo tee /etc/fail2ban/jail.d/99-local-baseline.conf > /dev/null <<'EOF'
[DEFAULT]
backend = systemd
ignoreip = 127.0.0.1/8 ::1
bantime = 10m
findtime = 10m
maxretry = 5

[sshd]
enabled = true
maxretry = 3
bantime = 1h
findtime = 10m
EOF

The backend = systemd line is especially useful on Debian 12 and Debian 11 minimal systems where /var/log/auth.log may be missing or incomplete. Debian 13 already sets the SSH jail to the systemd journal, so the line makes the behavior explicit without changing the default ban action.

Validate the configuration before restarting Fail2Ban:

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

Restart the service, give the client socket a moment to appear, then confirm Fail2Ban is active and enabled:

sudo systemctl restart fail2ban
sleep 2
systemctl is-active fail2ban
systemctl is-enabled fail2ban
sudo fail2ban-client ping
active
enabled
Server replied: pong

List enabled jails and inspect the SSH jail:

sudo fail2ban-client status
sudo fail2ban-client status sshd
Status
|- Number of jail:      1
`- Jail list:   sshd

The detailed jail output should show either a Journal matches line or a File list line. With the systemd backend, Debian systems commonly show a journal match similar to this:

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

Debian 12 can show sshd.service instead of ssh.service in the journal match. The important result is that the sshd jail exists, Fail2Ban is active, and the jail is reading from the expected log source.

Understand Fail2Ban Jails, Filters, and Actions

Fail2Ban has a few moving parts, and knowing the names makes troubleshooting much easier. A jail ties together the service to watch, the filter that identifies failures, and the action that blocks a source address.

TermMeaningDebian example
JailService-specific rule set that counts failures and triggers bans[sshd]
FilterRegex file that decides which log lines count as failures/etc/fail2ban/filter.d/sshd.conf
BackendWhere Fail2Ban reads events fromsystemd or /var/log/auth.log
ActionFirewall or notification action triggered after a bannftables, iptables-multiport, or ufw
AllowlistTrusted addresses that should not be bannedignoreip

Do not enable every bundled jail. Enable only jails for services that are installed, running, and writing logs in the format the filter expects. A jail for Apache, Nginx, Postfix, or Dovecot will not help unless that service exists and the corresponding log source contains real failure lines.

Choose a Firewall Action on Debian

Most Debian systems should keep the packaged action unless they already standardize on another firewall manager. The log backend controls how Fail2Ban reads events; the action controls how Fail2Ban blocks addresses.

ActionUse it whenDebian note
nftablesYou use Debian 13 defaults or direct nftables rulesDebian 13 sets this in defaults-debian.conf.
iptables-multiportYou use Debian 12 or Debian 11 defaultsOlder Fail2Ban packages use this action from jail.conf.
ufwYou already manage host firewall policy with UFWInstall and allow SSH with UFW on Debian before switching actions.
firewalld actionsYou intentionally use firewalld on DebianInstall and verify Firewalld on Debian first.

If UFW is already installed, enabled, and allowing your SSH access path, set Fail2Ban’s default action to UFW in your local drop-in:

[DEFAULT]
banaction = ufw
banaction_allports = ufw

Do not enable UFW over SSH until the SSH service is allowed through the firewall and a second session is available for recovery. A firewall change can lock out the same connection you are using to administer the server.

Manage Fail2Ban Bans and Logs

The fail2ban-client command is the normal control tool for jail status, manual bans, and manual unbans.

sudo fail2ban-client status
sudo fail2ban-client status sshd

To test manual ban handling without targeting a real client, use a documentation-only address and unban it immediately after checking the jail:

sudo fail2ban-client set sshd banip 203.0.113.7
sudo fail2ban-client status sshd
sudo fail2ban-client set sshd unbanip 203.0.113.7

The ban command returns 1 when it adds the address. The status output then lists the address under Banned IP list; after the unban command, that list should no longer include the test address.

Fail2Ban writes its own log to /var/log/fail2ban.log. Use the tail command to read recent activity or follow new entries:

sudo tail -n 30 /var/log/fail2ban.log
sudo tail -f /var/log/fail2ban.log

Use the grep command guide when you need narrower filtering. This example searches for one source address in the Fail2Ban log:

sudo grep '203.0.113.7' /var/log/fail2ban.log

For SSH authentication failures on systems using the systemd backend, compare the active jail with recent SSH journal entries:

sudo journalctl -u ssh --since "1 hour ago" --no-pager | grep -Ei 'failed|invalid'

Some Debian systems log SSH under sshd instead of ssh. If the command prints nothing but you know failed logins occurred, repeat the check with sudo journalctl -u sshd --since "1 hour ago" --no-pager.

Enable Jails for Other Debian Services

SSH protection is the normal first jail because SSH is often exposed on servers. If the OpenSSH server is not installed or enabled yet, configure SSH on Debian before depending on the sshd jail. Add service jails only after the protected service is installed and its logs are available.

For Apache, install and verify Apache on Debian first, then enable a matching jail such as apache-auth or apache-badbots when that log source exists:

[apache-auth]
enabled = true
port = http,https
logpath = %(apache_error_log)s
maxretry = 3
findtime = 10m
bantime = 1h

For Nginx, install and verify Nginx on Debian first, then enable only jails whose log paths match the site configuration. After adding any service jail, run sudo fail2ban-client -t, restart Fail2Ban, and check the named jail with sudo fail2ban-client status jail-name.

Mail and Cloudflare actions are advanced paths, not base Fail2Ban behavior. Mail actions need a sendmail-compatible mail service and often whois; Cloudflare actions need a scoped API token and a separately tested edge-blocking policy.

Test SSH Protection Safely

A real SSH ban test should come from a second machine or disposable client, not from your only current admin session. Keep the existing SSH session open, trigger failed logins against a non-existent test user from the second client, then stop before locking out a legitimate account or automation source.

ssh testuser@your-server-ip

After enough failures to cross maxretry, check the jail and log:

sudo fail2ban-client status sshd
sudo tail -n 20 /var/log/fail2ban.log

If a real client address was banned during testing, unban it before closing your recovery session:

sudo fail2ban-client set sshd unbanip YOUR_TEST_IP

Create a Custom SSH Pre-Auth Filter

Some SSH scans disconnect during key exchange before any password failure occurs. Logs for that pattern often include kex_exchange_identification followed by Connection closed by IP_ADDRESS port PORT. The standard sshd filter may count the first line as context but ignore the second line as a non-failure helper, so no ban is applied.

Create a separate filter only when you actually see repeated pre-auth connection drops from the same sources. The filter catches connection-closed events, not only malicious attempts, so use conservative thresholds.

sudo tee /etc/fail2ban/filter.d/sshd-kex.local > /dev/null <<'EOF'
[INCLUDES]
before = common.conf

[Definition]
_daemon = sshd
failregex = ^%(__prefix_line)sConnection closed by <HOST> port \d+\s*$
ignoreregex =
EOF

Test the filter against a representative sample before attaching it to a jail:

printf '%s\n' \
'Jan 12 10:00:01 debian sshd[1234]: error: kex_exchange_identification: Connection closed by remote host' \
'Jan 12 10:00:01 debian sshd[1234]: Connection closed by 192.0.2.55 port 41258' | sudo tee /tmp/sshd-kex-test.log > /dev/null

fail2ban-regex -v /tmp/sshd-kex-test.log /etc/fail2ban/filter.d/sshd-kex.local

Relevant output should show one matched line with the captured host:

Failregex: 1 total
|   1) [1] ... Connection closed by <HOST> port \d+\s*$
|      192.0.2.55  Mon Jan 12 10:00:01 2026

Lines: 2 lines, 0 ignored, 1 matched, 1 missed

Remove the temporary sample after the regex test:

sudo rm -f /tmp/sshd-kex-test.log

Add a separate jail for the filter so it runs beside the standard SSH jail. Place the section in the same local drop-in used earlier, or in another file under /etc/fail2ban/jail.d/ that loads after Debian’s defaults.

[sshd-kex]
enabled = true
filter = sshd-kex
port = ssh
backend = systemd
maxretry = 5
bantime = 1h
findtime = 10m

Validate, restart, and inspect the custom jail:

sudo fail2ban-client -t
sudo systemctl restart fail2ban
sleep 2
sudo fail2ban-client ping
sudo fail2ban-client status sshd-kex

Increase maxretry if legitimate clients sometimes disconnect during key exchange. Remove the custom jail if it creates false positives for unstable networks, load balancers, or monitoring systems.

Update or Remove Fail2Ban on Debian

Fail2Ban updates through APT with the rest of the Debian package set. To update only Fail2Ban after refreshing metadata, run:

sudo apt update
sudo apt install --only-upgrade fail2ban

To remove the package while keeping local configuration files for a possible reinstall, stop the service and remove the package:

sudo systemctl disable --now fail2ban
sudo apt remove fail2ban

APT may mark helper packages installed only for Fail2Ban as autoremovable. Review the proposed transaction before confirming:

sudo apt autoremove

Use purge when you want APT to remove package-managed configuration files as well:

sudo apt purge fail2ban

Remove the local files created in this setup when you no longer need them:

sudo rm -f /etc/fail2ban/jail.d/99-local-baseline.conf
sudo rm -f /etc/fail2ban/filter.d/sshd-kex.local

The next cleanup commands delete local Fail2Ban configuration, runtime database state, and Fail2Ban logs. Skip them if you want to keep ban history or reinstall with the same settings later.

Review the candidate paths before deleting them:

sudo find /etc/fail2ban /var/lib/fail2ban -maxdepth 2 -print 2>/dev/null || true
sudo ls -1 /var/log/fail2ban.log* 2>/dev/null || true

After confirming the paths are safe to remove, delete them:

sudo rm -rf /etc/fail2ban
sudo rm -rf /var/lib/fail2ban
sudo rm -f /var/log/fail2ban.log /var/log/fail2ban.log-* /var/log/fail2ban.log.*

Confirm the package state after removal or purge:

dpkg-query -W -f='${db:Status-Abbrev} ${binary:Package}\n' fail2ban 2>/dev/null | grep -E '^(ii|rc)' || echo "fail2ban package not installed"

The message fail2ban package not installed means the package is absent. An ii status means the package is still installed, and an rc status means the package was removed but residual configuration remains. Run sudo apt purge fail2ban if you want APT to remove that package-managed configuration.

Troubleshoot Fail2Ban on Debian

Fail2Ban Fails to Start Because the SSH Log Is Missing

On Debian 12 and Debian 11 minimal systems, a fresh install can fail with this error when the enabled sshd jail expects a file log that does not exist:

ERROR   Failed during configuration: Have not found any log file for sshd jail
ERROR   Async configuration of server failed

Add backend = systemd to the [DEFAULT] section in your local Fail2Ban drop-in, then retest and restart:

sudo fail2ban-client -t
sudo systemctl restart fail2ban
sleep 2
sudo fail2ban-client ping
systemctl is-active fail2ban
sudo fail2ban-client status sshd

fail2ban-regex Shows 0 Matched Lines

A 0 matched result usually means the filter is reading the wrong source, the log format differs from the filter, or the matched lines do not include a captured host. First confirm where SSH failures are logged:

sudo journalctl -u ssh --since "1 hour ago" --no-pager | grep -Ei 'failed|invalid'
sudo tail -n 50 /var/log/auth.log 2>/dev/null

If the failures appear in the journal but not in /var/log/auth.log, use the systemd backend. If failures appear in the file but not the journal, remove the override or set that jail’s backend and logpath to the file source.

Bans Are Logged but Firewall Rules Are Missing

Check which action the active SSH jail loaded:

sudo fail2ban-client get sshd actions

Debian 13 should normally report nftables. Debian 12 and Debian 11 normally use iptables-multiport unless you changed banaction. For nftables, inspect Fail2Ban’s table after a ban exists:

sudo nft list table inet f2b-table

For iptables-based actions, look for Fail2Ban chains after a ban exists:

sudo iptables -S | grep f2b

If the jail shows banned addresses but the firewall has no matching rule, confirm that the selected firewall package is installed, the action file exists under /etc/fail2ban/action.d/, and the action matches the firewall you actually use.

The Client Socket Is Missing

The message Failed to access socket path: /var/run/fail2ban/fail2ban.sock can appear for a few seconds immediately after a restart while Fail2Ban creates its client socket. If it persists, the service is not running or failed during startup. Start with the parser and journal instead of rerunning random ban commands:

sudo fail2ban-client -t
sudo journalctl -u fail2ban -n 50 --no-pager

Fix the reported file, option, filter, or log source, then restart Fail2Ban and recheck sudo fail2ban-client status.

Conclusion

Fail2Ban is installed from Debian’s default repositories with SSH monitoring, a tested local override, and a clear recovery path for journal-based systems. Keep the log checks close while tuning thresholds, then expand protection only to services that are installed, exposed, and writing logs Fail2Ban can parse.

Share this guide

Help another Linux user troubleshoot faster

Share this guide with someone troubleshooting Linux systems or saving it for later.

Follow LinuxCapable

Want more LinuxCapable guides in Google?

Add LinuxCapable as a preferred source so Google can show more of our fresh Linux tutorials in Top Stories and From your sources when relevant.

Add LinuxCapable as a preferred source on Google
Search LinuxCapable

Need another guide?

Search LinuxCapable for package installs, commands, troubleshooting, and follow-up guides related to what you just read.

Found this guide useful?

Support LinuxCapable to keep tutorials free and up to date.

Buy me a coffeeBuy me a coffee

14 thoughts on “How to Install Fail2Ban on Debian 13, 12 and 11”

  1. When i do a test i get this result. I see every 30 seconds someone try to login with random ports on SSH. The test result looks like it ignore it and not block the IP numbers

    [sshd]
    enabled = true
    port = ssh
    logpath = %(sshd_log)s
    backend = %(sshd_backend)s
    maxretry = 3
    bantime = 1h
    findtime = 10m
    root@mail:/var/log# sudo fail2ban-regex /var/log/auth.log /etc/fail2ban/filter.d/sshd.conf
    
    Running tests
    =============
    
    Use   failregex filter file : sshd, basedir: /etc/fail2ban
    Use         maxlines : 1
    Use      datepattern : {^LN-BEG} : Default Detectors
    Use         log file : /var/log/auth.log
    Use         encoding : utf-8
    
    
    Results
    =======
    
    Prefregex: 5716 total
    |  ^(?P<mlfid>(?:\[\])?\s*(?:<[^.]+\.[^.]+>\s+)?(?:\S+\s+)?(?:kernel:\s?\[ *\d+\.\d+\]:?\s+)?(?:@vserver_\S+\s+)?(?:(?:(?:\[\d+\])?:\s+[\[\(]?sshd(?:\(\S+\))?[\]\)]?:?|[\[\(]?sshd(?:\(\S+\))?[\]\)]?:?(?:\[\d+\])?:?)\s+)?(?:\[ID \d+ \S+\]\s+)?)(?:(?:error|fatal): (?:PAM: )?)?(?P<content>.+)$
    `-
    
    Failregex: 1308 total
    |-  #) [# of hits] regular expression
    |  19) [2] ^<F-NOFAIL>Received <F-MLFFORGET>disconnect</F-MLFFORGET></F-NOFAIL> from <HOST>(?: (?:port \d+|on \S+)){0,2}:\s*11:
    |  20) [1304] ^<F-NOFAIL><F-MLFFORGET>(Connection (?:closed|reset)|Disconnected)</F-MLFFORGET></F-NOFAIL> (?:by|from)(?: (?:invalid|authenticating) user <F-USER>\S+|.*?</F-USER>)? <HOST>(?:(?: (?:port \d+|on \S+|\[preauth\])){0,3}\s*|\s*)$
    |  21) [2] ^<F-MLFFORGET><F-MLFGAINED>Accepted \w+</F-MLFGAINED></F-MLFFORGET> for <F-USER>\S+</F-USER> from <HOST>(?:\s|$)
    `-
    
    Ignoreregex: 0 total
    
    Date template hits:
    |- [# of hits] date format
    |  [5716] {^LN-BEG}ExYear(?P<_sep>[-/.])Month(?P=_sep)Day(?:T|  ?)24hour:Minute:Second(?:[.,]Microseconds)?(?:\s*Zone offset)?
    `-
    
    Lines: 5716 lines, 1308 ignored, 0 matched, 4408 missed
    [processed in 0.36 sec]
    
    Ignored line(s): too many to print.  Use --print-all-ignored to print all 1308 lines
    Missed line(s): too many to print.  Use --print-all-missed to print all 4408 lines
    Reply
    • Thanks for the detailed output, Peter. The fail2ban-regex results explain exactly what is happening. Your output shows 0 matched lines for actual failed login attempts, while the 1308 “ignored” lines are connection disconnects and successful logins (patterns marked with <F-NOFAIL> flags that Fail2Ban tracks but does not count as failures).

      The most likely cause is that your system uses systemd-journald as the primary logging mechanism, and /var/log/auth.log contains stale or incomplete data. On Debian 12 and newer, SSH authentication events often go directly to the systemd journal rather than traditional log files.

      First, verify where your SSH failures are actually logged:

      journalctl -u ssh --since "1 hour ago" | grep -i "failed\|invalid"

      If you see failed login attempts there but not in /var/log/auth.log, add the systemd backend to your local Fail2Ban drop-in, such as /etc/fail2ban/jail.d/99-local-baseline.conf:

      [DEFAULT]
      backend = systemd

      Then restart Fail2Ban:

      sudo systemctl restart fail2ban
      sleep 2
      sudo fail2ban-client ping

      After restarting, check that the sshd jail is detecting failures:

      sudo fail2ban-client status sshd

      The “Currently failed” and “Total failed” counts should start increasing as the systemd backend reads directly from journald. The article now covers this in the baseline local drop-in setup and the troubleshooting item for missing SSH logs, so Debian 12 and Debian 11 readers can switch to the systemd backend without hunting through older section names. Thanks for reporting this, as your feedback directly improved the guide for other readers.

      Reply
      • Here is the result: changed my domain and ops IP in this log to 192.168.121.121 my domain.com

        root@mail:/var/log# journalctl -u ssh --since "6 hour ago" | grep -i "failed\|invalid"                                           root@mail:/var/log#  
        root@mail:/var/log# 

        /var/log/auth.log:

        2026-01-12T06:11:19.712096+01:00 mail sshd[856295]: error: kex_exchange_identification: Connection closed by remote host         
        2026-01-12T06:11:19.712387+01:00 mail sshd[856295]: Connection closed by 192.168.121.121 port 41258                                 
        2026-01-12T06:11:49.724749+01:00 mail sshd[856296]: error: kex_exchange_identification: Connection closed by remote host         
        2026-01-12T06:11:49.725249+01:00 mail sshd[856296]: Connection closed by 192.168.121.121 port 41462                                 
        2026-01-12T06:12:01.729961+01:00 mail CRON[856341]: pam_unix(cron:session): session opened for user root(uid=0) by (uid=0)       
        2026-01-12T06:12:01.730568+01:00 mail CRON[856340]: pam_unix(cron:session): session opened for user root(uid=0) by (uid=0)       
        2026-01-12T06:12:01.862403+01:00 mail CRON[856340]: pam_unix(cron:session): session closed for user root                         
        2026-01-12T06:12:01.943010+01:00 mail CRON[856341]: pam_unix(cron:session): session closed for user root                         
        2026-01-12T06:12:19.736173+01:00 mail sshd[856387]: error: kex_exchange_identification: Connection closed by remote host         
        2026-01-12T06:12:19.736755+01:00 mail sshd[856387]: Connection closed by 192.168.121.121 port 41636                                 
        2026-01-12T06:12:49.745398+01:00 mail sshd[856388]: error: kex_exchange_identification: Connection closed by remote host         
        2026-01-12T06:12:49.745690+01:00 mail sshd[856388]: Connection closed by 192.168.121.121 port 41818                                 
        2026-01-12T06:13:01.945836+01:00 mail CRON[856432]: pam_unix(cron:session): session opened for user root(uid=0) by (uid=0)       
        2026-01-12T06:13:01.946303+01:00 mail CRON[856433]: pam_unix(cron:session): session opened for user root(uid=0) by (uid=0)       
        2026-01-12T06:13:02.088229+01:00 mail CRON[856432]: pam_unix(cron:session): session closed for user root                         
        2026-01-12T06:13:02.139967+01:00 mail CRON[856433]: pam_unix(cron:session): session closed for user root                         
        2026-01-12T06:13:19.754416+01:00 mail sshd[856459]: error: kex_exchange_identification: Connection closed by remote host         
        2026-01-12T06:13:19.754780+01:00 mail sshd[856459]: Connection closed by 192.168.121.121 port 42012                                 
        2026-01-12T06:13:49.765671+01:00 mail sshd[856490]: error: kex_exchange_identification: Connection closed by remote host         
        2026-01-12T06:13:49.765967+01:00 mail sshd[856490]: Connection closed by 192.168.121.121 port 42182                                 
        2026-01-12T06:14:01.142930+01:00 mail CRON[856534]: pam_unix(cron:session): session opened for user root(uid=0) by (uid=0)       
        2026-01-12T06:14:01.143321+01:00 mail CRON[856535]: pam_unix(cron:session): session opened for user root(uid=0) by (uid=0)       
        2026-01-12T06:14:01.262757+01:00 mail CRON[856534]: pam_unix(cron:session): session closed for user root                         
        2026-01-12T06:14:01.309416+01:00 mail CRON[856535]: pam_unix(cron:session): session closed for user root                         
        2026-01-12T06:14:19.773466+01:00 mail sshd[856565]: error: kex_exchange_identification: Connection closed by remote host         
        2026-01-12T06:14:19.773952+01:00 mail sshd[856565]: Connection closed by 192.168.121.121 port 42374                                 
        2026-01-12T06:14:49.786706+01:00 mail sshd[856569]: error: kex_exchange_identification: Connection closed by remote host         
        2026-01-12T06:14:49.787287+01:00 mail sshd[856569]: Connection closed by 192.168.121.121 port 42548                                 
        2026-01-12T06:15:01.312134+01:00 mail CRON[856616]: pam_unix(cron:session): session opened for user root(uid=0) by (uid=0)       
        2026-01-12T06:15:01.312536+01:00 mail CRON[856614]: pam_unix(cron:session): session opened for user getmail(uid=5001) by (uid=0) 
        2026-01-12T06:15:01.313082+01:00 mail CRON[856615]: pam_unix(cron:session): session opened for user root(uid=0) by (uid=0)       
        2026-01-12T06:15:01.316279+01:00 mail CRON[856614]: pam_unix(cron:session): session closed for user getmail                      
        2026-01-12T06:15:01.464621+01:00 mail CRON[856616]: pam_unix(cron:session): session closed for user root                         
        2026-01-12T06:15:01.644401+01:00 mail CRON[856615]: pam_unix(cron:session): session closed for user root 

        When changeing the grep:

        journalctl -u ssh --since "6 hour ago" | grep -i "failed\|error: kex"

        output:

        Jan 12 06:11:49 mail.mydomain.comsshd[856296]: error: kex_exchange_identification: Connection closed by remote host
        Jan 12 06:12:19 mail.mydomain.com sshd[856387]: error: kex_exchange_identification: Connection closed by remote host
        Jan 12 06:12:49 mail.mydomain.com sshd[856388]: error: kex_exchange_identification: Connection closed by remote host
        Jan 12 06:13:19 mail.mydomain.com sshd[856459]: error: kex_exchange_identification: Connection closed by remote host
        Jan 12 06:13:49 mail.mydomain.com sshd[856490]: error: kex_exchange_identification: Connection closed by remote host
        Jan 12 06:14:19 mail.mydomain.com sshd[856565]: error: kex_exchange_identification: Connection closed by remote host
        Jan 12 06:14:49 mail.mydomain.com sshd[856569]: error: kex_exchange_identification: Connection closed by remote host
        Jan 12 06:15:19 mail.mydomain.com sshd[856751]: error: kex_exchange_identification: Connection closed by remote host
        Jan 12 06:15:49 mail.mydomain.com sshd[856752]: error: kex_exchange_identification: Connection closed by remote host
        Jan 12 06:16:19 mail.mydomain.com sshd[856827]: error: kex_exchange_identification: Connection closed by remote host
        Jan 12 06:16:49 mail.mydomain.com sshd[856856]: error: kex_exchange_identification: Connection closed by remote host
        Jan 12 06:17:19 mail.mydomain.com sshd[856927]: error: kex_exchange_identification: Connection closed by remote host
        Jan 12 06:17:49 mail.mydomain.com sshd[856928]: error: kex_exchange_identification: Connection closed by remote host
        Jan 12 06:18:19 mail.mydomain.com sshd[856999]: error: kex_exchange_identification: Connection closed by remote host

        So the regex isn’t correct in my situation then?

        Reply
        • Your logs tell the whole story, Peter. Those kex_exchange_identification: Connection closed by remote host messages are pre-authentication connection failures, not login failures. The remote client disconnects during the SSH key exchange phase before any password attempt occurs.

          The standard sshd filter (even in aggressive mode) will not ban these IPs. Looking at your logs, each event produces two lines:

          error: kex_exchange_identification: Connection closed by remote host
          Connection closed by 192.168.121.121 port 41258

          The first line triggers a failure match but contains no IP. The second line contains the IP but is marked as a non-failure helper in the default filter. You need a custom filter to catch these. Create it at /etc/fail2ban/filter.d/sshd-kex.local:

          sudo tee /etc/fail2ban/filter.d/sshd-kex.local > /dev/null <<'EOF'
          [INCLUDES]
          before = common.conf
          
          [Definition]
          _daemon = sshd
          failregex = ^%(__prefix_line)sConnection closed by <HOST> port \d+\s*$
          ignoreregex =
          EOF

          Then add a new jail in your local Fail2Ban drop-in, such as /etc/fail2ban/jail.d/99-local-baseline.conf:

          [sshd-kex]
          enabled = true
          filter = sshd-kex
          port = ssh
          backend = systemd
          maxretry = 5
          bantime = 1h
          findtime = 10m

          Restart Fail2Ban and verify the new jail is active:

          sudo systemctl restart fail2ban
          sleep 2
          sudo fail2ban-client ping
          sudo fail2ban-client status sshd-kex

          This runs alongside your existing sshd jail. The standard jail catches password failures while sshd-kex catches the pre-auth connection drops you are seeing. All settings are customizable based on your environment: maxretry controls how many connection drops trigger a ban (start with 5 since legitimate clients occasionally disconnect during key exchange), bantime sets how long IPs stay blocked, and findtime defines the window for counting failures. The every-30-seconds pattern from a single IP is clearly automated probing, so even conservative thresholds will catch it quickly.

          Reply
          • I get a error when I add the files and update jail.local

            Jan 12 15:58:27 mill.domain.com systemd[1]: fail2ban.service: Main process exited, code=exited, status=255/EXCEPTION    
            Jan 12 15:58:27 mail.domain.com systemd[1]: fail2ban.service: Failed with result 'exit-code'.                                                                                                                                                 Jan 12 15:58:27 mail.hassio.nl systemd[1]: fail2ban.service: Failed with result 'exit-code'.
          • I checked the internet and this filter

            [INCLUDES]
            before = common.conf
            
            [Definition]
            _daemon = sshd
            failregex = ^%(__prefix_line)sConnection closed by <HOST>.*?
            ignoreregex =

            gives me this result. Looks good right?

            root@mail:/etc/fail2ban/filter.d# sudo fail2ban-regex /var/log/auth.log /etc/fail2ban/filter.d/sshd-kex.local                                                                                                              
                                                                                                                                                                                                                                       
            Running tests                                                                                                                                                                                                              
            =============                                                                                                                                                                                                              
                                                                                                                                                                                                                                       
            Use   failregex filter file : sshd-kex, basedir: /etc/fail2ban                                                                                                                                                             
            Use      datepattern : {^LN-BEG} : Default Detectors                                                                                                                                                                       
            Use         log file : /var/log/auth.log                                                                                                                                                                                   
            Use         encoding : utf-8                                                                                                                                                                                               
                                                                                                                                                                                                                                       
                                                                                                                                                                                                                                       
            Results                                                                                                                                                                                                                    
            =======                                                                                                                                                                                                                    
                                                                                                                                                                                                                                       
            Failregex: 1710 total                                                                                                                                                                                                      
            |-  #) [# of hits] regular expression                                                                                                                                                                                      
            |   2) [1710] ^(?:\[\])?\s*(?:<[^.]+\.[^.]+>\s+)?(?:\S+\s+)?(?:kernel:\s?\[ *\d+\.\d+\]:?\s+)?(?:@vserver_\S+\s+)?(?:(?:(?:\[\d+\])?:\s+[\[\(]?sshd(?:\(\S+\))?[\]\)]?:?|[\[\(]?sshd(?:\(\S+\))?[\]\)]?:?(?:\[\d+\])?:?)\s+
            )?(?:\[ID \d+ \S+\]\s+)?Connection closed by .*?                                                                                                                                                                     
            `-                                                                                                                                                                                                                         
                                                                                                                                                                                                                                       
            Ignoreregex: 0 total                                                                                                                                                                                                       
                                                                                                                                                                                                                                       
            Date template hits:                                                                                                                                                                                                        
            |- [# of hits] date format                                                                                                                                                                                                 
            |  [7543] {^LN-BEG}ExYear(?P<_sep>[-/.])Month(?P=_sep)Day(?:T|  ?)24hour:Minute:Second(?:[.,]Microseconds)?(?:\s*Zone offset)?                                                                                             
            `-                                                                                                                                                                                                                         
                                                                                                                                                                                                                                       
            Lines: 7543 lines, 0 ignored, 1710 matched, 5833 missed                                                                                                                                                                    
            [processed in 0.29 sec]                                                                                                                                                                                                    
                                                                                                                                                                                                                                       
            Missed line(s): too many to print.  Use --print-all-missed to print all 5833 lines  
            
            

            This is fail2ban log:

            2026-01-12 18:43:30,596 fail2ban.filter         [1079173]: INFO    [sshd-kex] Found 188.90.50.51 - 2026-01-12 18:43:30
            2026-01-12 18:44:00,524 fail2ban.filter         [1079173]: INFO    [sshd-kex] Found 188.90.50.51 - 2026-01-12 18:44:00
            2026-01-12 18:44:30,525 fail2ban.filter         [1079173]: INFO    [sshd-kex] Found 188.90.50.51 - 2026-01-12 18:44:30
            2026-01-12 18:45:00,524 fail2ban.filter         [1079173]: INFO    [sshd-kex] Found 188.90.50.51 - 2026-01-12 18:45:00
            2026-01-12 18:45:30,297 fail2ban.filter         [1079173]: INFO    [sshd-kex] Found 188.90.50.51 - 2026-01-12 18:45:30
            2026-01-12 18:45:30,518 fail2ban.actions        [1079173]: NOTICE  [sshd-kex] Ban 188.90.50.51
            
  2. Great tutorial, however your step to check if the fail2ban install went ok,”fail2ban --version”, throws a command not found error. “sudo fail2ban-client version” seems to be the correct form.

    Checking the “systemctl status fail2ban” does work, once one has the jail.local correctly configured.

    Again, great tutorial.

    Reply
    • Thanks for catching that, RDK. You’re absolutely right, the version check command I provided was incorrect. The proper command is:

      fail2ban-client --version

      I’ve updated the install verification step to use the correct syntax. The sudo prefix isn’t required for version checks since you’re just querying the client, not modifying system configuration. Thanks for testing the steps thoroughly and reporting the issue. Feedback like this helps keep the guide accurate for everyone.

      Reply
      • Thanks for sharing this, Steve. You’re correct that on Debian 12, explicitly setting the backend to systemd in a local Fail2Ban drop-in can resolve startup issues. Add this line to the [DEFAULT] section:

        backend = systemd

        The systemd backend reads logs directly from the systemd journal instead of polling log files, which works more reliably on systems using journald. After adding this setting, validate the configuration with sudo fail2ban-client -t, then restart Fail2Ban with sudo systemctl restart fail2ban. Thanks for pointing this out for Debian 12 users.

        Reply
Before commenting, please review our Comments Policy.
Formatting tips for your comment

You can use basic HTML to format your comment. Useful tags currently allowed in published comments:

You type Result
<code>command</code> command
<strong>bold</strong> bold
<em>italic</em> italic
<blockquote>quote</blockquote> quote block

Got a Question or Feedback?

We read and reply to every comment - let us know how we can help or improve this guide.

Verify before posting: