🚫 fail2ban

How filters, jails and actions fit together, the sshd modes compared on a sample log (the default never sees a key-only scanner), log injection, proxies, false positives and a filter test script

fail2ban: ban brute-force attackers automatically, and prove your filters work first
fail2ban-regex /var/log/auth.log sshd

fail2ban watches log files (or the journal) for signs of an attack, such as repeated failed logins, and when one address crosses a threshold it adds a firewall rule that blocks that address for a while. It is the standard tool for taking the noise out of SSH, web logins, mail servers and anything else that writes "failed login from IP" to a log. It is also easy to get wrong: a filter that does not match what your service really logs protects nothing, a filter that matches too much bans innocent addresses, and a ban that hits your proxy or your own monitoring takes your site down.
This page shows how it works and how to test it. About the testing: fail2ban is not installed on the lab machine, so the fail2ban 1.0.2 package (the one in Ubuntu 24.04) was downloaded and unpacked into a scratch folder, nothing was installed, and ONE program from it was run: fail2ban-regex, the filter tester, which only matches text against sample log files. Those results are real. The daemon itself, the jails, the bans, the firewall actions and fail2ban-client were NOT run, and neither was sshd, so the sample SSH log lines were written by hand to match what OpenSSH 9.x logs (documentation addresses 203.0.113.x, 198.51.100.x and 192.0.2.x). Everything about running fail2ban as a service is marked "not run" and comes from its documentation and its shipped configuration files, which were read. Test it on a spare server before you trust it.

1. What fail2ban does, and what it does not do

It reads a log, matches lines against regular expressions (the FILTER), counts matches per address, and when an address reaches maxretry matches within findtime, it runs an ACTION (usually "add this address to a firewall set") for bantime. When the time is up, it removes the rule.
What it is good for: slowing down password guessing, cutting the log noise and CPU cost of automated scanners, protecting a login page that has no rate limiting (an nginx basic-auth admin page, a WordPress login, a mail server), and blocking address ranges that misbehave repeatedly.
What it is not: a replacement for strong authentication. If SSH accepts only keys (see the SSH Hardening page), a password guess can never succeed, and fail2ban is a tidy-up that keeps logs quiet, not the thing that keeps attackers out. It cannot stop a distributed attack from thousands of addresses, each trying once; it cannot help with a flood of traffic (it reacts after the lines are written); and because it works on text logs, it can be fooled or can ban the wrong address if a filter is careless (sections 7 and 8). Think of it as one layer, not the defence.

2. How it works: filters, jails and actions

Three kinds of configuration object, each in its own folder under /etc/fail2ban:
  filter.d/NAME.conf     a FILTER: the regular expressions (failregex) that recognise a failure in a log line, and optionally ignoreregex for lines to skip. Contains the tag <HOST>, which fail2ban replaces with an address pattern
  action.d/NAME.conf     an ACTION: the commands to run on start, ban, unban and stop (add a firewall rule, send mail, call an API)
  jail.conf / jail.local / jail.d/*.conf    the JAILS: each one connects a filter to a log (logpath or the systemd journal), the limits (maxretry, findtime, bantime) and the actions. A jail is "a service you want protected"
The package unpacked in the lab contains:
$ ls /etc/fail2ban
action.d fail2ban.conf fail2ban.d filter.d jail.conf jail.d paths-arch.conf paths-common.conf paths-debian.conf paths-opensuse.conf
$ ls filter.d | wc -l; ls action.d | wc -l
95
64
$ grep -c '^\[' jail.conf
93
So 95 ready-made filters, 64 actions and 93 sections in jail.conf (the DEFAULT section, includes and one jail for each service: sshd, dropbear, apache-auth, nginx-http-auth, nginx-limit-req, nginx-botsearch, postfix, dovecot, named-refused, mysqld-auth, recidive, wordpress-style apps, gitlab, grafana, traefik-auth and many more). They are all DISABLED unless a jail says enabled = true.
The rule that saves you from trouble on upgrades: never edit the .conf files. Put your changes in a .local file with the same name (jail.local, filter.d/sshd.local, fail2ban.local), or in jail.d/*.local. A .local file only needs the settings you change; they are layered over the .conf, and a package upgrade replaces the .conf files without touching yours. paths-*.conf tell fail2ban where each distribution keeps its logs; on Debian and Ubuntu:
$ grep -E '^(syslog_authpriv|sshd_log|nginx_error_log|nginx_access_log|apache_error_log|syslog_mail)' paths-debian.conf
syslog_authpriv = /var/log/auth.log
sshd_log = %(syslog_authpriv)s
apache_error_log = /var/log/apache2/*error.log
nginx_error_log = /var/log/nginx/*error.log
nginx_access_log = /var/log/nginx/*access.log
syslog_mail = /var/log/mail.log

3. Install and the first jail

Not run (needs root and a real server):
  sudo apt install fail2ban                       Debian, Ubuntu (the package starts the service)
  sudo dnf install fail2ban                       Fedora; on RHEL and derivatives enable EPEL first
  sudo systemctl enable --now fail2ban
  sudo fail2ban-client status                     which jails are running
On Debian and Ubuntu the package already switches ONE jail on. This is the file it ships, read from the package:
$ cat /etc/fail2ban/jail.d/defaults-debian.conf
[DEFAULT]
banaction = nftables
banaction_allports = nftables[type=allports]
backend = systemd

[sshd]
enabled = true
So on Ubuntu 24.04 the sshd jail is active out of the box, reads the systemd journal, and bans with nftables. Nothing else is. Your own settings go in /etc/fail2ban/jail.local, for example (not run):
  [DEFAULT]
  # never ban these: your admin networks, your monitoring, your VPN (see section 13)
  ignoreip = 127.0.0.1/8 ::1 192.0.2.0/24
  bantime  = 1h
  findtime = 10m
  maxretry = 5

  [sshd]
  enabled = true
  mode    = aggressive
  maxretry = 4
and then sudo fail2ban-client -t (test the configuration without starting anything) and sudo systemctl reload fail2ban, or sudo fail2ban-client reload. The line mode = aggressive is explained in section 6, which is where most people go wrong.

4. The settings that matter, and the shipped defaults

These are the real defaults from the [DEFAULT] section of the shipped jail.conf (only the ones that matter, with what they mean):
$ awk '/^\[DEFAULT\]/{f=1;next} /^\[/{f=0} f && $0 !~ /^#/ && NF' jail.conf | head
ignorecommand =
bantime  = 10m
findtime  = 10m
maxretry = 5
maxmatches = %(maxretry)s
backend = auto
usedns = warn
enabled = false
mode = normal
banaction = iptables-multiport
  bantime      how long an address stays banned: 10 minutes by default. Suffixes: s, m, h, d, w (and mo, y in recent versions). -1 means permanent (see section 13 before using it)
  findtime     the time window in which the failures are counted: 10 minutes by default
  maxretry     the number of failures within findtime that triggers a ban: 5 by default
  backend      how the log is read: auto (picks pyinotify, polling...), systemd (the journal), polling, pyinotify. Debian and Ubuntu set systemd, as above
  usedns       warn resolves host names found in log lines (and logs a warning about it); no ignores host names and matches IP addresses only, the safest and fastest choice
  enabled      false by default for every jail: switch each one on deliberately
  mode         selects a group of regular expressions inside some filters (sshd has four, next sections)
  banaction    the action that blocks: iptables-multiport in the upstream default; the Debian file above overrides it with nftables
  ignoreip     addresses never banned (by default only the machine's own addresses, through ignoreself, and the loopback addresses 127.0.0.1/8 and ::1 in the shipped example)
How they work together, with real numbers from the sample log in section 6: the address 203.0.113.5 produced 6 failures within five minutes. 6 is at least maxretry (5) inside findtime (10 minutes), so it would be banned for bantime (10 minutes by default). The address 192.0.2.44, a real user who mistyped a password once, produced 1 failure: far below 5, no ban. Raising maxretry makes mistakes safer; lowering it stops attackers sooner; a short findtime with a long maxretry catches only fast attacks; a LONG findtime catches slow ones (one guess an hour) but also real users who fail a few times over a day. Pick numbers you understand and write them down.

5. Test filters with fail2ban-regex

The most useful tool in the package is not the daemon but fail2ban-regex. It takes a log (a file, a single line or the journal) and a filter (a name like sshd, a name with options like sshd[mode=aggressive], a path to a filter file, or a bare regular expression) and tells you exactly which lines matched. You can use it on any machine, as any user, without touching the real fail2ban. Always test a filter before you put it in a jail, and again after every change. The sample log used below is auth.log, 25 lines: three simulated attackers, a monitoring check and normal activity by a user (the full file is in the next section). Result for the standard filter, shortened:
$ fail2ban-regex auth.log sshd
Running tests
=============
Use   failregex filter file : sshd, basedir: /etc/fail2ban
Use         maxlines : 1
Use      datepattern : {^LN-BEG} : Default Detectors
Use         log file : auth.log
Use         encoding : UTF-8

Results
=======
Prefregex: 25 total
...
Failregex: 17 total
|-  #) [# of hits] regular expression
|   4) [5] ^Failed (?:<F-NOFAIL>publickey</F-NOFAIL>|\S+) for (?P<cond_inv>invalid user )?<F-USER>(?P<cond_user>\S+)|...
|   6) [1] ^[iI](?:llegal|nvalid) user <F-USER>.*?</F-USER> from <HOST>...
|   7) [1] ^User <F-USER>\S+|.*?</F-USER> from <HOST> not allowed because not listed in AllowUsers...
|  15) [1] ^maximum authentication attempts exceeded for <F-USER>.*</F-USER> from <HOST>...
|  19) [1] ^<F-NOFAIL>Received <F-MLFFORGET>disconnect</F-MLFFORGET></F-NOFAIL> from <HOST>...
|  20) [6] ^<F-NOFAIL><F-MLFFORGET>(Connection (?:closed|reset)|Disconnected)</F-MLFFORGET></F-NOFAIL> (?:by|from)...
|  21) [2] ^<F-MLFFORGET><F-MLFGAINED>Accepted \w+</F-MLFGAINED></F-MLFFORGET> for <F-USER>\S+</F-USER> from <HOST>...
`-

Ignoreregex: 0 total

Date template hits:
|- [# of hits] date format
|  [25] {^LN-BEG}(?:DAY )?MON Day %k:Minute:Second(?:\.Microseconds)?(?: ExYear)?
`-

Lines: 25 lines, 9 ignored, 8 matched, 8 missed
[processed in 0.00 sec]
How to read it:
  Failregex: 17 total             the filter has 17 regular expressions; the numbers in brackets say how many lines each one matched in this log (the others matched none)
  Date template hits              the date format fail2ban recognised in the lines, and how many lines it found it in: [25] of 25. If this table is EMPTY, fail2ban could not find a date in your lines, and a real jail may mishandle them: fix the date pattern (a datepattern line in the filter, or -d on the command line)
  Lines: 25 lines, 9 ignored, 8 matched, 8 missed:
     matched   lines that count as a FAILURE (8 here)
     ignored   lines a regular expression recognised, but marked as "not a failure" (the F-NOFAIL tag): a normal disconnect, a successful login. They are understood and deliberately not counted
     missed    lines no regular expression matched at all. Often fine (the cron and sudo lines), but this list is where an attack you are NOT seeing hides
Add --print-all-matched, --print-all-missed and --print-all-ignored to list the lines in each group, and -o ip to print just the address of each failure, which makes counting easy:
$ fail2ban-regex -o ip auth.log sshd | grep -E '^[0-9]+\.' | sort | uniq -c
      1 192.0.2.44
      1 203.0.113.200
      6 203.0.113.5
Three practical warnings, all seen in the lab. First, give a filter FILE as a path (./myapp.conf or filter.d/myapp.conf). With a bare name that is not one of the shipped filters, the tool does not say "file not found": it treats the text as a REGULAR EXPRESSION and answers with something unrelated:
$ fail2ban-regex app.log myapp.conf
Use   failregex line : myapp.conf
ERROR: No failure-id group in 'myapp.conf'
Second, the exit status is useless for testing: it was 0 both when the filter matched and when it matched nothing, and 255 only for a broken argument:
$ fail2ban-regex -o ip auth.log sshd > /dev/null; echo "matches found:  exit $?"
matches found:  exit 0
$ fail2ban-regex -o ip app.log ./naive.conf > /dev/null; echo "no match:       exit $?"
no match:       exit 0
$ fail2ban-regex x ./nonexistent.conf > /dev/null 2>&1; echo "bad filter arg: exit $?"
bad filter arg: exit 255
So in a script, count the output lines (as in section 7), do not read the exit code. Third, the tool has no daemon behind it: it shows what a filter WOULD match, not what a running jail has banned (use fail2ban-client for that, section 12).

6. The sshd filter modes, compared on a sample log

The shipped sshd filter has four modes, chosen with the mode option of the jail (mode = aggressive) or on the command line (sshd[mode=aggressive]). Which one you pick decides whether the jail sees the attacks on your server at all. The sample log (hand-written in the format of OpenSSH 9.x, 25 lines):
$ cat auth.log
Oct  2 10:15:01 web1 CRON[2201]: pam_unix(cron:session): session opened for user root(uid=0) by (uid=0)
Oct  2 10:15:09 web1 sshd[2210]: Failed password for root from 203.0.113.5 port 51514 ssh2
Oct  2 10:15:11 web1 sshd[2210]: Failed password for root from 203.0.113.5 port 51514 ssh2
Oct  2 10:15:14 web1 sshd[2210]: Failed password for root from 203.0.113.5 port 51514 ssh2
Oct  2 10:15:14 web1 sshd[2210]: maximum authentication attempts exceeded for root from 203.0.113.5 port 51514 ssh2 [preauth]
Oct  2 10:15:14 web1 sshd[2210]: Connection closed by authenticating user root 203.0.113.5 port 51514 [preauth]
Oct  2 10:15:20 web1 sshd[2215]: Invalid user admin from 203.0.113.5 port 51520
Oct  2 10:15:22 web1 sshd[2215]: Failed password for invalid user admin from 203.0.113.5 port 51520 ssh2
Oct  2 10:15:25 web1 sshd[2215]: Connection closed by invalid user admin 203.0.113.5 port 51520 [preauth]
Oct  2 10:16:02 web1 sshd[2230]: Connection closed by authenticating user root 198.51.100.7 port 40001 [preauth]
Oct  2 10:16:03 web1 sshd[2231]: Connection closed by authenticating user root 198.51.100.7 port 40002 [preauth]
Oct  2 10:16:04 web1 sshd[2232]: Connection closed by authenticating user root 198.51.100.7 port 40003 [preauth]
Oct  2 10:16:10 web1 sshd[2240]: Unable to negotiate with 198.51.100.7 port 40010: no matching key exchange method found. Their offer: diffie-hellman-group1-sha1 [preauth]
Oct  2 10:16:15 web1 sshd[2245]: banner exchange: Connection from 198.51.100.7 port 40020: invalid format
Oct  2 10:16:20 web1 sshd[2250]: error: kex_exchange_identification: Connection closed by remote host
Oct  2 10:16:20 web1 sshd[2250]: Connection closed by 198.51.100.7 port 40030
Oct  2 10:16:40 web1 sshd[2260]: Timeout before authentication for connection from 198.51.100.7 to 10.0.0.5, pid = 2259
Oct  2 10:17:01 web1 sshd[2270]: Accepted publickey for alice from 192.0.2.44 port 50000 ssh2: ED25519 SHA256:mAqR26dYZ8oL7Or4HlOdmNse7DGUJmvFWbrMrM6cTow
Oct  2 10:17:01 web1 sshd[2270]: pam_unix(sshd:session): session opened for user alice(uid=1001) by (uid=0)
Oct  2 10:17:30 web1 sshd[2275]: Failed password for alice from 192.0.2.44 port 50010 ssh2
Oct  2 10:17:33 web1 sshd[2275]: Accepted password for alice from 192.0.2.44 port 50010 ssh2
Oct  2 10:18:02 web1 sshd[2280]: User bob from 203.0.113.200 not allowed because not listed in AllowUsers
Oct  2 10:18:02 web1 sshd[2280]: Received disconnect from 203.0.113.200 port 50100:11: Bye Bye [preauth]
Oct  2 10:18:40 web1 sshd[2290]: Disconnected from user alice 192.0.2.44 port 50000
Oct  2 10:19:00 web1 sudo:    alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/apt update
The cast: 203.0.113.5 is a classic password guesser (it tries root and admin), 198.51.100.7 is a scanner that never gets as far as a password (it connects, is disconnected before authenticating, offers an old key exchange, sends garbage as its banner, and times out: what you see against a server that accepts only keys), 192.0.2.44 is alice, who typed one password wrong and then got in, and 203.0.113.200 is bob, who is not in AllowUsers. The totals for the four modes:
$ for m in normal ddos extra aggressive; do printf '%-11s ' "$m"; fail2ban-regex auth.log "sshd[mode=$m]" | grep '^Lines:'; done
normal      Lines: 25 lines, 9 ignored, 8 matched, 8 missed
ddos        Lines: 25 lines, 3 ignored, 16 matched, 6 missed
extra       Lines: 25 lines, 3 ignored, 9 matched, 13 missed
aggressive  Lines: 25 lines, 3 ignored, 17 matched, 5 missed
And what matters, the failures counted per address in each mode:
$ for m in normal ddos extra aggressive; do echo "-- mode=$m"; fail2ban-regex -o ip auth.log "sshd[mode=$m]" | grep -E '^[0-9]+\.' | sort | uniq -c | sort -rn; done
-- mode=normal
   6 203.0.113.5
   1 203.0.113.200
   1 192.0.2.44
-- mode=ddos
   8 203.0.113.5
   5 198.51.100.7
   1 203.0.113.200
   1 192.0.2.44
-- mode=extra
   6 203.0.113.5
   1 203.0.113.200
   1 198.51.100.7
   1 192.0.2.44
-- mode=aggressive
   8 203.0.113.5
   6 198.51.100.7
   1 203.0.113.200
   1 192.0.2.44
Read this carefully, because it is the most important result on this page. With the DEFAULT mode, normal, the scanner 198.51.100.7 has ZERO counted failures: it is never banned, however often it comes back. Normal mode only counts password and invalid-user lines. The nine lines it does recognise but ignores ("ignored" above) are the ones that say "Connection closed ... [preauth]", plus the successful logins and a clean disconnect:
$ fail2ban-regex --print-all-ignored auth.log sshd      (shortened)
Oct  2 10:15:14 web1 sshd[2210]: Connection closed by authenticating user root 203.0.113.5 port 51514 [preauth]
Oct  2 10:16:02 web1 sshd[2230]: Connection closed by authenticating user root 198.51.100.7 port 40001 [preauth]
Oct  2 10:16:20 web1 sshd[2250]: Connection closed by 198.51.100.7 port 40030
Oct  2 10:17:01 web1 sshd[2270]: Accepted publickey for alice from 192.0.2.44 port 50000 ssh2: ED25519 SHA256:mAqR26dY...
Oct  2 10:17:33 web1 sshd[2275]: Accepted password for alice from 192.0.2.44 port 50010 ssh2
Oct  2 10:18:02 web1 sshd[2280]: Received disconnect from 203.0.113.200 port 50100:11: Bye Bye [preauth]
(9 lines in all.) And these are the lines normal mode does not recognise at all, the "missed" group, which includes the scanner's key-exchange, banner and timeout lines:
$ fail2ban-regex --print-all-missed auth.log sshd
Oct  2 10:15:01 web1 CRON[2201]: pam_unix(cron:session): session opened for user root(uid=0) by (uid=0)
Oct  2 10:16:10 web1 sshd[2240]: Unable to negotiate with 198.51.100.7 port 40010: no matching key exchange method found. Their offer: diffi...
Oct  2 10:16:15 web1 sshd[2245]: banner exchange: Connection from 198.51.100.7 port 40020: invalid format
Oct  2 10:16:20 web1 sshd[2250]: error: kex_exchange_identification: Connection closed by remote host
Oct  2 10:16:40 web1 sshd[2260]: Timeout before authentication for connection from 198.51.100.7 to 10.0.0.5, pid = 2259
Oct  2 10:17:01 web1 sshd[2270]: pam_unix(sshd:session): session opened for user alice(uid=1001) by (uid=0)
Oct  2 10:18:40 web1 sshd[2290]: Disconnected from user alice 192.0.2.44 port 50000
Oct  2 10:19:00 web1 sudo:    alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/apt update
This has a very practical consequence. On a server where password logins are switched off (the hardened setup), there are no "Failed password" lines at all: attackers show up as disconnects before authentication, bad negotiations and bad banners. The default jail sees none of it, and fail2ban looks as if it were doing nothing, because for those attackers, in normal mode, it is doing nothing. The modes, from the filter file in the package:
  normal      password failures, invalid users, users refused by AllowUsers/DenyUsers, "maximum authentication attempts exceeded"
  ddos        normal plus the connection-level lines: "Connection closed by ... [preauth]", "Did not receive identification string", bad banners, failed key exchange identification (the mdre-ddos groups in the filter file): catches scanners and floods of half-open connections
  extra       normal plus negotiation failures: "Unable to negotiate ... no matching key exchange method found" and "Received disconnect ... 14: No supported authentication methods" (in the sample log it adds exactly one line, the Unable to negotiate line from 198.51.100.7)
  aggressive  everything: normal + ddos + extra
The same diff, run for real, shows exactly which lines ddos mode adds on top of normal:
+ Oct  2 10:15:14 web1 sshd[2210]: Connection closed by authenticating user root 203.0.113.5 port 51514 [preauth]
+ Oct  2 10:15:25 web1 sshd[2215]: Connection closed by invalid user admin 203.0.113.5 port 51520 [preauth]
+ Oct  2 10:16:02 web1 sshd[2230]: Connection closed by authenticating user root 198.51.100.7 port 40001 [preauth]
+ Oct  2 10:16:03 web1 sshd[2231]: Connection closed by authenticating user root 198.51.100.7 port 40002 [preauth]
+ Oct  2 10:16:04 web1 sshd[2232]: Connection closed by authenticating user root 198.51.100.7 port 40003 [preauth]
+ Oct  2 10:16:15 web1 sshd[2245]: banner exchange: Connection from 198.51.100.7 port 40020: invalid format
+ Oct  2 10:16:20 web1 sshd[2250]: error: kex_exchange_identification: Connection closed by remote host
+ Oct  2 10:16:20 web1 sshd[2250]: Connection closed by 198.51.100.7 port 40030
With ddos or aggressive the scanner has 5 or 6 counted failures, which reaches the default maxretry of 5, so it is banned. The price is more false positives. A monitoring system or load balancer that checks whether SSH is alive by opening the port and closing it again produces exactly the line "Connection closed by ADDRESS port N [preauth]". Six such checks from a monitoring host, tested for real:
$ head -1 monitor2.log
Oct  2 11:01:00 web1 sshd[301]: Connection closed by 192.0.2.250 port 41001 [preauth]
$ for m in normal ddos aggressive; do printf '%-11s failures counted for 192.0.2.250: ' "$m"; fail2ban-regex -o ip monitor2.log "sshd[mode=$m]" | grep -c '^192.0.2.250'; done
normal      failures counted for 192.0.2.250: 0
ddos        failures counted for 192.0.2.250: 6
aggressive  failures counted for 192.0.2.250: 6
In ddos and aggressive mode six harmless health checks are six failures: above maxretry, so your own monitoring host gets banned and your alerts go red (whether a line has [preauth] depends on whether the check reads the banner; a check that closes straight after connecting writes "Connection closed by ADDRESS port N" without it, which the same test showed is not counted). The fix is not to avoid the mode but to list the monitoring addresses in ignoreip (section 13). Choose the mode on purpose: normal if you only want to slow down password guessing on a server that allows passwords; aggressive (or ddos) on a key-only server, with ignoreip set for everything that is allowed to knock.

7. Write your own filter, and test it like code

You can protect any application that logs a failure with a address in it. A filter file has a [Definition] section with at least a failregex that contains <HOST>. This one is for a small application whose log looks like this (the sample file app.log):
$ cat app.log
2026-10-02 10:15:01 INFO  login ok for user "alice" from 192.0.2.44
2026-10-02 10:15:09 WARN  login failed for user "root" from 203.0.113.5
2026-10-02 10:15:11 WARN  login failed for user "admin" from 203.0.113.5
2026-10-02 10:15:14 WARN  login failed for user "bob" from 203.0.113.5
2026-10-02 10:15:20 WARN  login failed for user "carol" from 192.0.2.250
2026-10-02 10:15:30 ERROR database timeout
$ cat filter.d/myapp.conf
[Definition]
failregex = ^.*login failed for user ".*" from <HOST>\s*$
ignoreregex = from 192\.0\.2\.250
$ fail2ban-regex app.log ./myapp.conf | sed -n '/^Failregex/,/^Date template/p;/^Lines:/p'
Failregex: 3 total
|-  #) [# of hits] regular expression
|   1) [3] ^.*login failed for user ".*" from <HOST>\s*$
`-

Ignoreregex: 1 total
|-  #) [# of hits] regular expression
|   1) [1] from 192\.0\.2\.250
`-

Date template hits:
Lines: 6 lines, 1 ignored, 3 matched, 2 missed
$ fail2ban-regex -o ip app.log ./myapp.conf | grep -E '^[0-9]+\.' | sort | uniq -c
      3 203.0.113.5
The three failures from 203.0.113.5 are counted; the failure from 192.0.2.250 matches but is dropped by ignoreregex (a known internal address); the INFO line and the database error are missed, which is what we want. The date format was found automatically; the "Date template hits" table of the full output reads:
Date template hits:
|- [# of hits] date format
|  [6] {^LN-BEG}ExYear(?P<_sep>[-/.])Month(?P=_sep)Day(?:T|  ?)24hour:Minute:Second(?:[.,]Microseconds)?(?:\s*Zone offset)?
`-
All six lines had a date of the ISO type (2026-10-02 10:15:01), so findtime counting will work. The pieces: ^ and $ anchor the expression to the whole line (see the next section for why this matters), .* is "anything", <HOST> is replaced by the pattern for an IP address, \s*$ allows trailing spaces. Optional tags help: <F-USER>...</F-USER> captures the user name for the log message, and <F-NOFAIL> marks a line that should be recognised but not counted. To use the filter you save it as /etc/fail2ban/filter.d/myapp.conf and add a jail (not run):
  [myapp]
  enabled  = true
  filter   = myapp
  logpath  = /var/log/myapp/app.log
  maxretry = 5
  findtime = 10m
  bantime  = 1h
  port     = 8080
Test it like code. A filter is a small program, and the way to keep one correct is a repeatable test with sample logs: lines that must count, lines that must not, and the address each failure must be assigned to. This 20-line script runs fail2ban-regex and compares the counted addresses with what you expect (the first lines set the path of the tester; on a real machine just call fail2ban-regex):
$ cat filtertest.sh
#!/bin/bash
# filtertest.sh: check that a fail2ban filter counts the right addresses on a sample log
# usage: expect FILTER LOG "ip=count ip=count ..."   (exact match of the sorted result)
S=${F2B_ROOT:?set F2B_ROOT}
f2br() { PYTHONPATH=$S/root/usr/lib/python3/dist-packages python3 $S/root/usr/bin/fail2ban-regex -c $S/root/etc/fail2ban "$@"; }
pass=0; fail=0
expect() {
  local filter=$1 log=$2 want=$3 got
  got=$(f2br -o ip "$log" "$filter" 2>&1 | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | sort | uniq -c | awk '{print $2"="$1}' | tr '\n' ' ' | sed 's/ $//')
  if [[ $got == "$want" ]]; then pass=$((pass+1)); printf 'ok    %s on %s: %s\n' "$filter" "${log##*/}" "${got:-nothing}"
  else fail=$((fail+1)); printf 'FAIL  %s on %s\n        expected [%s]\n        got      [%s]\n' "$filter" "${log##*/}" "$want" "$got"; fi
}
expect "sshd[mode=normal]" auth.log "192.0.2.44=1 203.0.113.200=1 203.0.113.5=6"
expect "sshd[mode=aggressive]" auth.log "192.0.2.44=1 198.51.100.7=6 203.0.113.200=1 203.0.113.5=8"
expect ./myapp.conf app.log "203.0.113.5=3"
expect ./myapp.conf inject.log "203.0.113.9=2"
expect ./naive.conf inject.log "203.0.113.9=2"
expect "sshd[mode=normal]" monitor2.log ""
expect "sshd[mode=ddos]" monitor2.log ""
printf '\n%d passed, %d failed\n' "$pass" "$fail"; (( fail == 0 ))
$ ./filtertest.sh; echo "[exit $?]"
ok    sshd[mode=normal] on auth.log: 192.0.2.44=1 203.0.113.200=1 203.0.113.5=6
ok    sshd[mode=aggressive] on auth.log: 192.0.2.44=1 198.51.100.7=6 203.0.113.200=1 203.0.113.5=8
ok    ./myapp.conf on app.log: 203.0.113.5=3
ok    ./myapp.conf on inject.log: 203.0.113.9=2
FAIL  ./naive.conf on inject.log
        expected [203.0.113.9=2]
        got      [192.0.2.44=2]
ok    sshd[mode=normal] on monitor2.log: nothing
FAIL  sshd[mode=ddos] on monitor2.log
        expected []
        got      [192.0.2.250=6]

5 passed, 2 failed
[exit 1]
Five passed and two failed, and both failures are the point of the exercise: the naive filter assigns the failure to the WRONG address (192.0.2.44, an innocent one), and the ddos mode bans a monitoring host. The tests found two real problems in two seconds, with no server and no attacker. Keep these test logs in version control next to the filter, and run the script after every change to a filter or an upgrade of fail2ban (an upgrade can change a shipped filter).

8. Filters that go wrong: log injection, proxies and false positives

Three ways a correct-looking filter does damage. All three were reproduced with fail2ban-regex.
Log injection. Part of every log line is chosen by the attacker (a user name, a URL, a header). If a regular expression takes the FIRST thing that looks like "from ADDRESS" instead of anchoring on the real structure of the line, the attacker can name himself so that fail2ban bans somebody else. Here the attacker used the user name x from 192.0.2.44 (an innocent address), so the log line holds two addresses; the real one is last:
$ cat inject.log
2026-10-02 10:20:01 WARN  login failed for user "x from 192.0.2.44" from 203.0.113.9
2026-10-02 10:20:02 WARN  login failed for user "x from 192.0.2.44" from 203.0.113.9
$ cat naive.conf
[Definition]
failregex = ^.*login failed for user .*?from <HOST>
$ fail2ban-regex -o ip inject.log ./naive.conf | grep -E '^[0-9]+\.' | sort | uniq -c
      2 192.0.2.44
$ fail2ban-regex -o ip inject.log ./myapp.conf | grep -E '^[0-9]+\.' | sort | uniq -c
      2 203.0.113.9
The naive filter (non-greedy, no anchor at the end) took the FIRST "from" and banned the innocent 192.0.2.44, which could be your admin, your monitoring or your office; the anchored filter from the previous section, with ".*" before the closing quote and \s*$ at the end, found the real attacker. An attacker who can write to the log can have you ban your own gateway. Rules: anchor the expression at both ends (^ and $), make the address the LAST thing on the line or tie it to a fixed structure, and never use patterns that let user-controlled text contain the marker you search for. The shipped sshd filter is built with this attack in mind: it has guards such as (?:(?! from ).)*? in its user-name part. The same hostile user name sent to it (two sshd lines with the user x from 192.0.2.44, the real address last) is assigned to the real address:
$ fail2ban-regex -o ip inject_ssh.log sshd | grep -E '^[0-9]+\.' | sort | uniq -c
      2 203.0.113.9
(When you write a filter for a network daemon that logs user names, add this kind of line to the test file.)
The proxy problem. A web server behind a load balancer, CDN or reverse proxy logs the address of the PROXY, not of the client, unless it is configured to log the real one (for nginx: the realip module with set_real_ip_from and real_ip_header, or log the X-Forwarded-For value). The filter then sees the same address for everybody:
$ cat nginx_proxied_error.log
2026/10/02 10:40:01 [error] 801#801: *5 user "admin": password mismatch, client: 10.0.0.1, server: example.test, ...
2026/10/02 10:40:02 [error] 801#801: *6 user "bob": password mismatch, client: 10.0.0.1, server: example.test, ...
2026/10/02 10:40:03 [error] 801#801: *7 user "eve": password mismatch, client: 10.0.0.1, server: example.test, ...
$ fail2ban-regex -o ip nginx_proxied_error.log nginx-http-auth | grep -E '^[0-9]+\.' | sort | uniq -c
      3 10.0.0.1
Three different people from three different places, all counted against 10.0.0.1, the proxy. Banning it blocks every visitor, so the site goes down. Fix the logging first, and put the proxy addresses in ignoreip as a safety net. With a CDN you can ban at the CDN (the shipped cloudflare action calls its API), because blocking on the origin server's firewall does nothing for traffic that arrives from the CDN's addresses. Also remember NAT: a whole office or a mobile network can share one public address, and one person's mistakes then ban the colleagues.
False positives from healthy traffic. Shown in section 6: six health checks counted as six failures in ddos mode. The same goes for automation that opens many connections (backup jobs, configuration management, CI), and for users who forget their password. List your own systems in ignoreip and test with a log sample of normal behaviour as well as attacks.

9. Jails for web servers and other services

The package ships filters for most services; you enable a jail by name. Three examples, tested on a sample nginx error log with seven lines (two failed basic-auth attempts, a missing credential header, a user not in the password file, two rate-limit hits and one harmless missing image). The shipped jail sections are short because they inherit the defaults:
[nginx-http-auth]
port    = http,https
logpath = %(nginx_error_log)s
[nginx-limit-req]
port    = http,https
logpath = %(nginx_error_log)s
[nginx-botsearch]
port     = http,https
logpath  = %(nginx_error_log)s
$ for flt in nginx-http-auth nginx-limit-req; do echo "-- filter $flt"; fail2ban-regex -o ip nginx_error.log $flt | grep -E '^[0-9]+\.' | sort | uniq -c; fail2ban-regex nginx_error.log $flt | grep '^Lines:'; done
-- filter nginx-http-auth
      3 203.0.113.5
Lines: 7 lines, 0 ignored, 3 matched, 4 missed
-- filter nginx-limit-req
      2 198.51.100.7
Lines: 7 lines, 0 ignored, 2 matched, 5 missed
The http-auth filter counted three failures by 203.0.113.5 (two wrong passwords and one user that is not in the password file) and did NOT count the line "no user/password was provided for basic authentication" from 198.51.100.7. That is deliberate: a browser sends the first request without credentials and gets a 401 before it asks for the password, so counting that line would ban every visitor. The limit-req filter counted the two lines "limiting requests ... by zone login" for 198.51.100.7: it bans clients who keep hitting a rate limit you configured in nginx (limit_req_zone), which makes fail2ban the second line of defence behind the nginx limit. A jail for an nginx basic-auth page (a dashboard, an admin area) is one of the most useful: it turns an unlimited password-guessing surface into five tries. Switch it on in jail.local with enabled = true (not run), and remember the lockout warning in section 13: a real administrator who forgets a password can ban themselves.
Other shipped jails worth knowing (all in the real jail.conf): apache-auth, apache-badbots, apache-noscript, nginx-botsearch (probes for admin pages that do not exist), postfix and postfix-sasl (SMTP login attacks), dovecot (IMAP and POP3), named-refused (DNS abuse), mysqld-auth, vsftpd and proftpd, roundcube-auth, gitlab, grafana, haproxy-http-auth, traefik-auth, phpmyadmin-syslog, and recidive (section 11). For a service that is not in the list, write a filter as in section 7. Container note: bans on traffic that reaches a Docker-published port must go in the right chain; the jail option chain controls this (DOCKER-USER for iptables), otherwise the rule is never consulted (not run).

10. Actions: how a ban is applied

A jail's action decides what "ban" means. The default for each jail is banaction with the ports of the jail. The actions shipped for the common firewalls, listed from the real package:
nftables.conf nftables-allports.conf nftables-multiport.conf iptables-multiport.conf iptables-multiport-log.conf ufw.conf firewallcmd-ipset.conf firewallcmd-multiport.conf firewallcmd-allports.conf firewallcmd-rich-rules.conf route.conf cloudflare.conf nginx-block-map.conf abuseipdb.conf sendmail-whois-lines.conf
With nftables (the Debian and Ubuntu default shown in section 3) the action creates a table and a set, and bans and unbans are single commands. These are the real lines of the action file:
$ grep -n -E '^(table|table_family|chain|addr_set|actionban|actionunban) ' action.d/nftables.conf
126:table = f2b-table
131:table_family = inet
136:chain = f2b-chain
193:addr_set = addr-set-<name>
111:actionban = <nftables> add element <table_family> <table> <addr_set> \{ <ip> \}
119:actionunban = <nftables> delete element <table_family> <table> <addr_set> \{ <ip> \}
(<name> is the jail name; the line numbers are those of the file in the package.) So a ban of 203.0.113.5 by the sshd jail runs nft add element inet f2b-table addr-set-sshd { 203.0.113.5 } and an unban deletes the element. On the server you can look at the current bans straight from the firewall (not run): sudo nft list set inet f2b-table addr-set-sshd. With the older iptables-multiport action each jail gets its own chain f2b-NAME, listed with sudo iptables -L f2b-sshd -n. Notes on actions:
  multiport vs allports: the multiport actions block only the jail's ports (SSH for the sshd jail); an allports action (banaction_allports) blocks the address on every port, which is what the recidive jail uses
  Several actions per jail: action = %(action_)s and then more lines, for example %(action_mwl)s (ban and send an e-mail with the whois data and the matching log lines). It needs a working mail system (sendmail by default) and can send one mail per ban, which on an internet-facing server means hundreds a day: use it for rare, important jails only
  The cloudflare action, firewallcmd and ufw actions exist for other environments: ban at the CDN, in firewalld (ipsets), or in ufw
  The default blocktype for nftables is reject (the client gets an immediate refusal); drop makes the attacker wait for a timeout (slower for them, but also fills your connection table)
  An action runs commands as root; actions from the Internet or third-party actions deserve the same review as any root script

11. Repeat offenders: incremental bans and recidive

A fixed ten-minute ban does little against an attacker who comes back every eleven minutes. There are two answers.
Incremental bantime: each time the same address is banned again, the ban gets longer. The options are in the shipped jail.conf as commented examples (not enabled by default); the formula it uses is the real default:
#bantime.increment = true
#bantime.rndtime =
#bantime.maxtime =
#bantime.factor = 1
#bantime.formula = ban.Time * (1<<(ban.Count if ban.Count<20 else 20)) * banFactor
With increment on, the ban time roughly doubles with every repeat ban (the formula multiplies by a power of two that grows with the number of earlier bans), up to bantime.maxtime, which you should set, for example 4w. Work out the first few values with your own bantime before you rely on them. The memory of earlier bans lives in fail2ban's database, which is where the next setting matters. The database file and its retention, from the real fail2ban.conf:
dbfile = /var/lib/fail2ban/fail2ban.sqlite3
dbpurgeage = 1d
dbpurgeage = 1d means old ban records are deleted after one day, so a repeat offender that returns after two days starts again from the beginning. To remember offenders for longer, raise dbpurgeage in fail2ban.local to at least the longest period you care about (not run; check the documentation of your version for the exact behaviour).
The recidive jail: a jail that watches fail2ban's OWN log for bans. An address that was banned several times by any jail within a day gets a long ban on all ports. The filter and the shipped jail section, tested on a sample fail2ban.log:
$ cat f2b.log
2026-10-02 10:15:30,123 fail2ban.actions        [900]: NOTICE  [sshd] Ban 203.0.113.5
2026-10-02 10:25:31,224 fail2ban.actions        [900]: NOTICE  [sshd] Unban 203.0.113.5
2026-10-02 11:40:12,001 fail2ban.actions        [900]: NOTICE  [sshd] Ban 203.0.113.5
2026-10-02 11:50:13,310 fail2ban.actions        [900]: NOTICE  [sshd] Unban 203.0.113.5
2026-10-02 13:05:44,500 fail2ban.actions        [900]: NOTICE  [sshd] Ban 203.0.113.5
2026-10-02 13:06:00,100 fail2ban.actions        [900]: NOTICE  [nginx-http-auth] Ban 198.51.100.7
2026-10-02 13:20:00,100 fail2ban.filter         [900]: INFO    [sshd] Found 192.0.2.99 - 2026-10-02 13:20:00
$ fail2ban-regex -o ip f2b.log recidive | grep -E '^[0-9]+\.' | sort | uniq -c
      1 198.51.100.7
      3 203.0.113.5
$ fail2ban-regex f2b.log recidive | grep '^Lines:'
Lines: 7 lines, 0 ignored, 4 matched, 3 missed
[recidive]
logpath  = /var/log/fail2ban.log
banaction = %(banaction_allports)s
bantime  = 1w
findtime = 1d
Only the "Ban" lines count (4 matched), the "Unban" and "Found" lines do not, and the bans from different jails are counted together per address. 203.0.113.5 has three bans in the sample; with the default maxretry of 5 (the shipped section sets bantime = 1w, findtime = 1d and no maxretry of its own, so it inherits 5) it would need two more bans within a day before it is banned for a WEEK on every port. You enable it with enabled = true in jail.local, and often lower maxretry to 3. Make sure fail2ban's own log is written where the jail looks (logtarget in fail2ban.conf is /var/log/fail2ban.log in the shipped file, as the jail expects), and that logrotate keeps it long enough.

12. Operating it day to day

Not run (needs the running service). The commands:
  sudo fail2ban-client status                       list the running jails
  sudo fail2ban-client status sshd                  one jail: currently failed and banned counts and the banned addresses
  sudo fail2ban-client get sshd maxretry            read a setting of the running jail (also bantime, findtime, ignoreip, banaction)
  sudo fail2ban-client set sshd banip 203.0.113.5   ban an address by hand
  sudo fail2ban-client set sshd unbanip 192.0.2.44  lift a ban (the first thing to do when someone is locked out)
  sudo fail2ban-client unban --all                  lift all bans in all jails
  sudo fail2ban-client set sshd addignoreip 192.0.2.250    add an address to the whitelist of the running jail (also add it to jail.local, or it is lost on restart)
  sudo fail2ban-client -t                           test the configuration without starting anything
  sudo fail2ban-client reload                       re-read the configuration without restarting the service
  sudo fail2ban-client -d                           dump the configuration the server really uses, after all .local layers
  sudo tail -f /var/log/fail2ban.log                the activity log: "Found" for each counted failure, "Ban" and "Unban"
The output of status for a jail has this shape (typical, not run):
  Status for the jail: sshd
  |- Filter
  |  |- Currently failed: 2
  |  |- Total failed:     137
  |  `- Journal matches:  _SYSTEMD_UNIT=sshd.service + _COMM=sshd
  `- Actions
     |- Currently banned: 1
     |- Total banned:     12
     `- Banned IP list:   203.0.113.5
The line "Journal matches" is the journalmatch of the filter. The shipped sshd filter contains journalmatch = _SYSTEMD_UNIT=sshd.service + _COMM=sshd (read from the package): with the systemd backend the jail reads only the journal entries that match. The journal records the REAL unit name, and on Debian and Ubuntu the SSH service is ssh.service (with sshd.service only as an alias), so check on your own system that the match finds entries, with both names: journalctl _SYSTEMD_UNIT=sshd.service + _COMM=sshd -n 5 and journalctl _SYSTEMD_UNIT=ssh.service + _COMM=sshd -n 5. If only the second one prints lines, set journalmatch in a sshd.local filter override or in the jail (not run; this was not checked on a live system).
Check the whole chain after setup, the first time, from a SECOND address that is not whitelisted (a phone on mobile data): make maxretry failed logins, watch "Found" and then "Ban" appear in /var/log/fail2ban.log, confirm that you are really blocked (connection refused or timeout), then unban yourself from your other session. Use a short bantime (1m) and a low maxretry (2) while testing. And repeat the test after changes. Bans are stored in the database and restored when the service starts again. After a distribution upgrade, run the test logs again (section 7): shipped filters change between versions.
Resource use: the systemd and pyinotify backends are cheap; polling re-reads files and costs more; a very noisy log with thousands of matches per second can make the fail2ban process busy, which is itself a sign to rate-limit upstream (in nginx, or the firewall) and not to rely on parsing text.

13. Do not lock yourself out

Most fail2ban incidents are self-inflicted. Defences, in order of importance:
  1. ignoreip: list every address that must never be banned, in [DEFAULT] of jail.local: ignoreip = 127.0.0.1/8 ::1 192.0.2.0/24 followed by your office and VPN networks, your monitoring and load balancer addresses, and the addresses of other servers that talk to this one. CIDR ranges and (with care) host names are allowed. The default is only the machine itself.
  2. Have a way in that does not use the banned path: the cloud or hypervisor console, a second SSH port that is not in the jail (and a different jail), or a VPN.
  3. Start gentle: bantime = 10m, maxretry = 5, and raise the penalties after you have seen a week of logs. Avoid bantime = -1 (permanent): the ban set grows without limit, a mistake becomes permanent, and addresses are reused by other people (cloud and mobile addresses change hands within days).
  4. Test every filter with real samples, including your own legitimate behaviour (a mistyped password, a monitoring check, a script that retries), as in sections 7 and 8.
  5. Know how to unban from the console: fail2ban-client set JAIL unbanip ADDRESS, or restart the service, or remove the address from the firewall set.
  6. Watch the logs after changing anything: a sudden rise of "Ban" lines for your own networks is the sign.
A reminder about the nginx basic-auth jail: an administrator who has forgotten the password reaches maxretry in a few seconds, and is then banned from the whole web server, not just the admin page, with the multiport action covering ports 80 and 443. Whitelist the admin networks, or use an allports-free action for that jail.

14. Alternatives and complements

  Key-only SSH, no root login, MaxStartups, LoginGraceTime: reduce what a guess can achieve (SSH Hardening page). With these in place fail2ban mostly saves log noise.
  OpenSSH 9.8 and newer have PerSourcePenalties: sshd itself penalises addresses that fail to authenticate, a built-in cousin of fail2ban for SSH (the Ubuntu 24.04 version of sshd, 9.6, does not have it).
  Firewall rate limiting: ufw limit 22/tcp and nftables meters or limits throttle new connections from one address without reading any log (Firewall Basics page); it reacts instantly and has no parsing risk, but it does not know about failures, only about speed.
  A shared blocklist approach such as CrowdSec uses community reputation as well as local logs (not tested in the lab).
  A VPN, a bastion host or allowing SSH only from known addresses removes most of the problem; a CDN or web application firewall protects web logins better than banning at the origin.
  When fail2ban is the wrong tool: DDoS floods (the damage is done before the log line exists), attacks from thousands of addresses with one try each, and very busy sites where parsing logs costs more than it saves.

15. Common problems

The sshd jail never bans anyone, although the log shows attacks      normal mode ignores scanners and key-only attackers: test with fail2ban-regex (section 6), switch to mode = aggressive or ddos, and whitelist your monitoring
"missed" lines that should have matched                                the regular expression does not fit what the service really logs (a newer version changed the text): copy a real line, run fail2ban-regex with it as the log argument, and adjust
fail2ban-regex answers with ERROR: No failure-id group in '...'        the filter was given as a name or a bare filename and treated as a regular expression: give a path (./myfile.conf)
Date template hits is empty                                            the date in your log is in a format fail2ban does not recognise: set datepattern in the filter, or fix the log format
The jail starts but nothing is banned and nothing is logged             wrong logpath (check the file exists and is readable by fail2ban), or backend = systemd with a journalmatch that finds no entries (the unit name: sshd.service versus ssh.service, section 12), or the jail is not enabled (enabled = true)
The wrong address is banned (a proxy, a gateway, an innocent address)  the log has the proxy address (fix the real_ip logging), or the filter is not anchored (log injection): see section 8
You were banned right after a typo                                    ignoreip does not include your address, or maxretry is too low: fail2ban-client set JAIL unbanip YOUR_ADDRESS from the console, then fix jail.local
Bans do not work for a service in a Docker container                    the traffic does not go through the INPUT chain: set chain = DOCKER-USER (iptables actions) or use a firewall that sees the container traffic
Ban rules vanish after a firewall reload                              reloading a firewall can flush rules that fail2ban added to the same ruleset (an iptables chain, for example): use the action that cooperates with your firewall (ufw.conf, firewallcmd-*, or nftables in its own table), or restart fail2ban after reloading the firewall, and check with the commands in section 12
Many e-mails after enabling action_mwl                                 one mail per ban: use it only for rare jails, or switch to the plain action and read the log
Unban does not come at the expected time                               bantime counts from the time of the ban in the server's clock; a changed time zone, a clock jump or a long reload delays it; also check bantime.increment
Banned addresses reappear after the database purge                    dbpurgeage defaults to 1d: raise it if you rely on remembering repeat offenders (section 11)
fail2ban uses a lot of CPU                                             a noisy log, polling backend, or a regular expression that backtracks: use the systemd or pyinotify backend, rate-limit at the source, and simplify the regex
After an upgrade the jail behaves differently                          a shipped filter or default changed: re-run your filter tests (section 7) and read the changelog
"Failed to find a date" or odd times in the log                        the filter's datepattern does not match the log: the matching lines get the time of reading, which breaks findtime counting

16. Tips

fail2ban is a layer, not the defence: with key-only SSH it keeps the log quiet; for web logins and mail it adds real protection.
Test before you trust. fail2ban-regex works anywhere, needs no root and shows exactly which lines a filter counts: check matched, ignored and missed, and use -o ip to count per address.
Know your SSH mode: normal only sees password guessing. On a key-only server use aggressive or ddos, and whitelist everything that is allowed to connect and disconnect.
Anchor every regular expression you write, and put sample log lines of attacks and of legitimate behaviour (including hostile user names) into a test script.
Fix the logged address first: behind a proxy or CDN, log the real client address, or you will ban your own proxy.
Never edit the .conf files: use jail.local, filter.d/*.local and jail.d/*.local.
Start with ignoreip and a short bantime, use a console as your way back in, and raise the penalties only after you have watched the logs.
Use incremental bantime with a bantime.maxtime, and raise dbpurgeage, or use the recidive jail, for repeat offenders; avoid permanent bans.
Read /var/log/fail2ban.log and fail2ban-client status regularly, test the whole chain from a second address after changes, and re-run the filter tests after upgrades.