Skip to content
← All articles

fail2ban Setup Guide: Jails, SSH Brute-Force Protection and Troubleshooting

In short. fail2ban watches service logs, counts failed login attempts per IP address and temporarily adds that address to your firewall rules. Install it with apt, configure it in jail.local rather than jail.conf, and tune four parameters: maxretry, findtime, bantime and ignoreip. On modern Ubuntu systems sshd logs live in journald, so the jail needs backend systemd or it silently does nothing.

What fail2ban does and what it does not do

fail2ban is a daemon that tails plain-text logs (or the systemd journal), matches lines against a filter regular expression, and when a single IP produces maxretry matches inside the findtime window, it triggers an action — normally inserting a DROP or REJECT rule into the firewall for bantime seconds. When bantime expires, the rule is removed. That is the whole mechanism.

Those mechanics imply hard limits that you should understand before rolling it out.

  • It is not a firewall. fail2ban never closes ports and never defines an access policy. It only injects temporary rules into a firewall you already run — iptables, nftables, ufw or firewalld. If your database port is exposed to the internet, fail2ban will not save you; you need a packet filter and a proper review of open ports.
  • It is not protection against distributed brute force. A modern botnet makes two or three attempts from each of tens of thousands of addresses. The maxretry threshold is never reached, and when it is, you ban one address out of a huge pool. The real countermeasure is disabling password authentication and applying rate limiting at the application layer.
  • It is not a WAF. fail2ban does not parse request bodies, does not understand SQL injection and never sees the payload. It only sees what the service itself wrote into a log.
  • It is reactive by design. The first maxretry attempts always go through. If the password is guessed on attempt three while maxretry is five, the ban is pointless.
  • It is useless without logs. If a service does not record failed logins, or records them in a format your filter does not match, the jail sits with a zero counter and creates a false sense of safety.

fail2ban removes background noise and reduces load on sshd. It does not replace sound authentication. Keys instead of passwords, PasswordAuthentication no, and closing internal services to the outside world buy you far more than any jail tuning.

Diagram of the fail2ban pipeline: service log, filter regex, attempt counter and a blocking rule in the firewall
The fail2ban chain: log line to filter, counter inside the findtime window, then a firewall action for bantime

Installing fail2ban on Debian and Ubuntu

The package ships in the main repositories of both distributions, so no extra repository is needed.

sudo apt update
sudo apt install fail2ban

# version and daemon state
fail2ban-client version
systemctl status fail2ban

# what is already enabled out of the box
sudo fail2ban-client status

On Debian and Ubuntu the package drops /etc/fail2ban/jail.d/defaults-debian.conf, which enables the sshd jail. In other words, minimal SSH protection usually starts working right after installation — but verify it by hand instead of trusting the default, for reasons explained in the backend section below.

If your logs live in the systemd journal, install the Python journald bindings as well; without them the systemd backend may fail to start:

sudo apt install python3-systemd
sudo systemctl restart fail2ban
sudo journalctl -u fail2ban -n 50 --no-pager

File layout after installation:

  • /etc/fail2ban/fail2ban.conf — daemon settings: log level, socket, ban database, dbpurgeage.
  • /etc/fail2ban/jail.conf — the reference description of every jail. Do not edit it.
  • /etc/fail2ban/jail.d/*.conf — configuration fragments, loaded after jail.conf.
  • /etc/fail2ban/filter.d/*.conf — filters, meaning regular expressions.
  • /etc/fail2ban/action.d/*.conf — actions: how to ban (iptables, nftables, e-mail, external API).
  • /var/lib/fail2ban/fail2ban.sqlite3 — the ban database, which is why bans survive a daemon restart.
  • /var/log/fail2ban.log — its own log; the recidive jail reads exactly this file.

jail.local, jail.d and the parameters that matter

Why you must not edit jail.conf

jail.conf belongs to the package. On any upgrade the package manager sees a modified conf file and will either overwrite it, prompt you about a configuration conflict, or leave a .dpkg-dist file next to it. In every scenario you lose either your changes or the new upstream defaults.

fail2ban reads configuration in layers: jail.conf first, then jail.local, then everything inside jail.d/ in alphabetical order. A later layer overrides an earlier one per parameter, not per section, so jail.local only needs to list what you actually change.

sudo tee /etc/fail2ban/jail.local > /dev/null <<'EOF'
[DEFAULT]
# trusted networks: LAN, office, VPN, monitoring probes
ignoreip = 127.0.0.1/8 ::1 203.0.113.0/24
bantime  = 1h
findtime = 10m
maxretry = 5
# escalating bans: repeat offenders stay out longer
bantime.increment = true
bantime.factor    = 2
bantime.maxtime   = 7d
banaction = nftables-multiport

[sshd]
enabled  = true
mode     = aggressive
maxretry = 4
bantime  = 1d
EOF

sudo fail2ban-client -t          # syntax check without applying
sudo systemctl reload fail2ban

The [DEFAULT] section sets values for every jail; a jail section overrides them. Separate files in jail.d/ are convenient when configuration is deployed by Ansible or Puppet: one service, one file, no merge conflicts.

Choosing bantime, findtime, maxretry and ignoreip

The logic is simple: if one IP produces maxretry filter matches within findtime, it is blocked for bantime.

  • maxretry. For key-based SSH, 3 to 4 is right — a legitimate user with a valid key produces no failures at all. For a web login page, where people genuinely forget passwords, use 5 to 10 or you will flood your support queue. Remember that a single browser attempt can emit several log lines.
  • findtime. The observation window. Too short (one or two minutes) misses slow "one attempt per minute" scans. A sane range is 10 to 60 minutes. The wider the window, the more memory is used and the higher the chance of catching a human who keeps mistyping.
  • bantime. One hour is a reasonable start. A permanent ban (bantime = -1) is tempting but inflates the firewall rule set to tens of thousands of entries and eventually traps a legitimate address you will forget to release. The correct answer is not permanent bans but bantime.increment: each subsequent ban of the same address lasts longer.
  • ignoreip. Addresses and CIDR ranges that are never banned. Your workstation network, VPN, internal probes and load balancers belong here. Values are space-separated; CIDR is supported and recent versions accept DNS names. The ignoreself = true default additionally protects the server's own addresses.

Tuning rule: start with permissive thresholds and watch the statistics for a week. An aggressive maxretry = 2 on a public site is not security, it is an incident generator titled "customers cannot log in".

Backend: systemd, auto and the journald trap

The backend parameter defines where a jail takes events from: a text file or the systemd journal.

  • auto — fail2ban picks a mechanism for watching a file: pyinotify when available, otherwise periodic polling. The key word is file. auto does not mean "it will find the logs wherever they are".
  • polling — forced timer-based polling. Slower and heavier, but works where inotify is unavailable (some containers, network filesystems).
  • systemd — reads journald directly through its API. In this mode logpath is ignored and record selection is set by the journalmatch directive.

Here is the single most common reason behind "I installed fail2ban and it bans nothing". Historically sshd logged to /var/log/auth.log through rsyslog, and the default jail is written for exactly that. On recent Ubuntu installations rsyslog may be absent: logging is fully delegated to systemd-journald and /var/log/auth.log is never created. The jail then either fails with an unreachable-log error or, if an empty leftover file exists after an upgrade, quietly runs with a zero counter.

# does a text auth log exist at all
ls -l /var/log/auth.log 2>/dev/null || echo "no auth.log — logs are in journald"

# does journald actually see login failures
journalctl -u ssh.service --since "-1h" | grep -i "failed password" | tail

# what fail2ban itself reports at startup
sudo journalctl -u fail2ban --since "-10m" --no-pager | grep -iE "error|warn|found"

If the logs are in the journal, switch the jail to the systemd backend:

sudo tee /etc/fail2ban/jail.d/sshd-systemd.local > /dev/null <<'EOF'
[sshd]
enabled      = true
backend      = systemd
journalmatch = _SYSTEMD_UNIT=ssh.service + _COMM=sshd
maxretry     = 4
bantime      = 1d
EOF

sudo fail2ban-client -t
sudo systemctl restart fail2ban
sudo fail2ban-client status sshd

Watch the unit name: on Debian and Ubuntu it is ssh.service, while many RPM-based distributions use sshd.service. A wrong journalmatch does not raise an error — the jail simply sees no lines. Verify with fail2ban-regex, described below, rather than by the absence of errors.

The signature of a silent jail: fail2ban-client status sshd reports "Total failed" as zero while journalctl shows dozens of "Failed password" entries in the last hour. That is not "the attacks stopped", that is the wrong log source.

Comparison of two event sources for fail2ban: the auth.log text file and the systemd journal
The auto backend follows a file, the systemd backend reads the journal; without rsyslog the first option sees nothing

The sshd jail and other common jails

The sshd jail is the baseline and often all you need. Its filter supports several mode values that change the regex set: normal catches failed passwords and authentication errors, ddos catches dropped connections and empty sessions without a login attempt, and aggressive combines both, including invalid users and unsupported authentication methods. On a public server aggressive is usually justified; on a host where custom scripts frequently tear down SSH sessions it is risky.

JailWhat it catchesLog sourcemaxretrybantime
sshdpassword guessing, logins with non-existent users, abnormal disconnects/var/log/auth.log or journald (ssh.service)3–51 hour – 1 day
nginx-http-authcredential guessing against HTTP Basic Auth/var/log/nginx/error.log3–51–6 hours
nginx-botsearchpath scanning: admin panels, .env, backups, foreign CMS pathserror.log, optionally access.log5–101 day
nginx-limit-reqlimit_req triggers, meaning request-rate violations/var/log/nginx/error.log1010 minutes – 1 hour
postfix, postfix-saslSMTP password guessing, relay attempts/var/log/mail.log or journald3–51 hour – 1 day
recidiveaddresses already banned several times by other jails/var/log/fail2ban.log3–51 week and longer

The recidive jail deserves special attention: it reads fail2ban's own log rather than a service log and bans addresses that were banned several times within a day. It is a cheap way to push persistent bots into a long block without lowering thresholds for ordinary users.

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

The banaction_allports action blocks the address on every port instead of a specific one, which is a sensible policy for repeat offenders.

fail2ban and nginx: ready-made jails plus your own filter

There is a subtlety with web servers: most stock nginx filters read error.log, because that is where nginx records Basic Auth rejections and limit_req triggers. Plain response codes 401, 403 and 429 live in access.log, and catching them requires a custom filter.

[nginx-http-auth]
enabled  = true
port     = http,https
logpath  = /var/log/nginx/error.log
maxretry = 4
bantime  = 6h

[nginx-botsearch]
enabled  = true
port     = http,https
logpath  = /var/log/nginx/error.log
maxretry = 6
bantime  = 1d

[nginx-limit-req]
enabled  = true
port     = http,https
logpath  = /var/log/nginx/error.log
findtime = 5m
maxretry = 20
bantime  = 1h

The nginx-limit-req jail is meaningful only together with limit_req_zone and limit_req directives in the nginx configuration; without them error.log has nothing to offer. The pairing — nginx throttles, fail2ban bans the most persistent — is a solid two-stage design, covered further in the articles on rate limiting strategies and DDoS protection methods.

Writing a filter in filter.d and testing it with fail2ban-regex

Suppose you want to ban addresses that generate a burst of 401 and 429 responses on an API. Here is a filter for the standard combined access.log format.

sudo tee /etc/fail2ban/filter.d/nginx-4xx-api.conf > /dev/null <<'EOF'
[Definition]
failregex = ^<HOST> \S+ \S+ \[[^]]+\] "(GET|POST|PUT|PATCH|DELETE) /api/[^"]*" (401|403|429)
ignoreregex =
datepattern = ^[^\[]*\[({DATE})
EOF

Rules for writing a filter:

  • <HOST> is a mandatory marker: fail2ban extracts the IP address from exactly this group. Use one per expression.
  • The regex must be anchored (^) and as narrow as possible. A loose pattern such as .*401.* will match a line where 401 is part of the response size or the User-Agent string and will ban a real customer.
  • ignoreregex holds exceptions evaluated after failregex. Useful to exclude service paths such as /api/health.
  • All filters live in filter.d/, and a jail references them by file name without the extension.

Now the important part — verification. fail2ban-regex runs a filter against a real log and reports how many lines matched and how the timestamp was parsed.

# filter against a file
fail2ban-regex /var/log/nginx/access.log /etc/fail2ban/filter.d/nginx-4xx-api.conf

# stock sshd filter against the systemd journal
fail2ban-regex systemd-journal /etc/fail2ban/filter.d/sshd.conf

# test a single line for quick regex debugging
fail2ban-regex '198.51.100.7 - - [12/Mar/2026:10:00:01 +0300] "POST /api/login HTTP/1.1" 401 12' \
  /etc/fail2ban/filter.d/nginx-4xx-api.conf

# verbose breakdown: which lines did not match
fail2ban-regex --print-all-missed /var/log/nginx/access.log \
  /etc/fail2ban/filter.d/nginx-4xx-api.conf

Two numbers matter in the output: Lines: ... matched and the missed-lines counter. Zero matches while attacks are clearly present means the regex or the date pattern is wrong. A frequent separate problem is datepattern: if fail2ban cannot parse the timestamp, it either treats the event as stale or ignores it. The fail2ban-regex output shows this in the "ignored by ... date" counter.

Wire the filter into a jail:

[nginx-4xx-api]
enabled  = true
filter   = nginx-4xx-api
port     = http,https
logpath  = /var/log/nginx/access.log
findtime = 5m
maxretry = 25
bantime  = 2h
Diagram of testing a custom fail2ban filter: log line, regex with the HOST marker, date parsing and the match result
fail2ban-regex verifies both failregex matches and timestamp parsing — both must be correct

fail2ban-client: status, unbanning and not locking yourself out

All day-to-day control goes through fail2ban-client, which talks to the daemon over a unix socket.

# list active jails
sudo fail2ban-client status

# details of one jail: counters and the banned list
sudo fail2ban-client status sshd

# release a specific address
sudo fail2ban-client set sshd unbanip 198.51.100.7

# release it across every jail
sudo fail2ban-client unban 198.51.100.7
sudo fail2ban-client unban --all

# ban manually, for example from an external feed
sudo fail2ban-client set sshd banip 198.51.100.7

# add an address to the ignore list on the fly, no restart
sudo fail2ban-client set sshd addignoreip 203.0.113.10

# reload configuration without losing active bans
sudo fail2ban-client reload
sudo fail2ban-client reload sshd

Now the most popular self-inflicted outage: banning the administrator. The scenario is always the same — you edit a jail, make a mistake in the filter, it starts matching ordinary successful connections, and a minute later your own IP is dropped. The existing SSH session usually survives, because the connection is already established, but no new session can be opened.

  • Always put your address into ignoreip before enabling new jails. If your address is dynamic, add the provider range or your VPN address.
  • Keep a second SSH session open while experimenting. It is free insurance: even if new authentication is blocked, the old session can run the unban command.
  • Have an out-of-band access path: a hosting panel console, KVM or serial console. Cloud web consoles bypass the network path and the firewall entirely.
  • Test every new filter with fail2ban-regex before enabling the jail, not after.

If access is already lost and only a console is left, clear the bans manually:

# find where the rule actually lives
sudo nft list ruleset | grep -A5 f2b
sudo iptables -S | grep f2b

# emergency: release everything
sudo fail2ban-client unban --all

# last resort: stop the daemon and let it flush its chains
sudo systemctl stop fail2ban

Pitfalls: firewalls, reverse proxies, log rotation and IPv6

iptables, nftables and ufw

fail2ban does not replace the firewall, it writes into it. The banaction parameter decides how. The main options are iptables-multiport (classic), nftables-multiport (for systems whose native firewall is nftables), ufw and firewallcmd-ipset. Mixing rule sets blindly is a bad idea: if the system runs nftables while fail2ban writes through the iptables compatibility layer, rules land in separate tables and evaluation order may not be what you expect.

ufw adds its own twist: it creates dedicated chains, and fail2ban rules must be inserted before ufw's allow rules or the ban has no effect. The stock ufw action handles that; homemade combinations often do not. After configuring, always inspect the actual rule state:

sudo nft list table inet f2b-table 2>/dev/null
sudo iptables -L -n --line-numbers | grep -i f2b
sudo ufw status numbered

General principles for hardening the packet filter and the services behind it are covered in the guide to web server hardening.

Reverse proxies and CDNs: the wrong address gets banned

If a CDN, a load balancer or another nginx sits in front of the server, backend logs contain the proxy IP rather than the client IP. fail2ban will dutifully ban it, cutting off all inbound traffic at once.

The fix belongs to the web server, not to fail2ban: $remote_addr must be restored from the header the proxy sets. The realip module rewrites the client address for both access.log and error.log entries.

http {
    # networks allowed to set the header
    set_real_ip_from 192.0.2.0/24;      # your load balancer
    # for a CDN, list the provider's published ranges
    real_ip_header   X-Forwarded-For;
    real_ip_recursive on;
}

Trust X-Forwarded-For only from networks explicitly listed in set_real_ip_from. If you trust everyone, any client can forge the header and make fail2ban ban an arbitrary IP — from your own address to search engine crawlers. The header mechanics and trust model are covered separately: the X-Forwarded-For header.

A second caveat: even with correct realip, a firewall ban on the origin server is useless when traffic always arrives from the CDN. The block has to happen on the CDN side through its API. fail2ban ships actions for external APIs, but their reliability depends on that API's rate limits and availability.

Log rotation and reset counters

logrotate renames access.log to access.log.1 and creates an empty file. fail2ban reopens the file when the inode changes and keeps working, but historical lines move into the archive. Practical consequences:

  • If fail2ban restarts (package upgrade, service reload, reboot), it starts reading the current file. Right after rotation that file is empty, recent context is lost, and an attacker gets a fresh maxretry budget.
  • Active bans are not lost: they live in the sqlite database, and record retention is governed by dbpurgeage in fail2ban.conf.
  • Do not set findtime longer than the rotation period. A one-day window with daily rotation is half blind.
  • With journald the problem disappears: the journal is read by cursor and does not rotate as a plain file.

IPv6 limitations

Current fail2ban versions can ban IPv6, with caveats. First, you need a banaction that supports both protocol versions: nftables works with an inet table and covers both stacks, while classic iptables requires parallel ip6tables rules. Second, an IPv6 client usually holds an entire /64 or larger prefix, so banning one address achieves nothing — the attacker moves to the next address in the same prefix. Some actions let you set a ban prefix length, but enabling aggressive /64 blocking on a public service is a deliberate trade-off: many unrelated users can sit behind one prefix.

The simplest way to confirm that a jail sees IPv6 at all is the actual banned list in fail2ban-client status and the rules present in nftables.

Diagram of a request passing through a CDN and reverse proxy where fail2ban on the origin sees the proxy address instead of the client address
Behind a proxy fail2ban bans the proxy; the real client IP must be restored by the realip module before it reaches the log

How to verify your setup and server exposure

A fail2ban deployment is verified on two levels: inside the server with the commands above, and from the outside by what an attacker actually sees.

  • Which ports are exposed. fail2ban protects only the services you put in jails; everything else must be closed by the firewall. An external scan shows the real picture: open port scanner. If a database, control panel or debug port answers from the internet, that matters far more than any jail tuning.
  • Overall security scoring. Headers, TLS, version leaks and typical misconfigurations are collected into one report by the website security check. Run it after every web server configuration change.
  • Spikes in errors and downtime. Overly aggressive jails show up as a rise in 4xx responses, dropped connections and partial unavailability. External uptime monitoring with alerts catches both that and the opposite case, where the server goes down under a brute-force wave.
  • Reaction to triggers. Configure a notification action (the stock action.d entries that send e-mail) or ship events from /var/log/fail2ban.log into your log platform. A silent fail2ban is a fail2ban whose state you know nothing about.

If the checks reveal signs of an existing compromise, the response sequence is described separately: what to do when a website is hacked.

Frequently asked questions

Do I need fail2ban if SSH accepts keys only?

Strictly speaking, no: with PasswordAuthentication no a password cannot be guessed. The jail is still useful because it removes background bot noise, reduces the number of sshd processes and shrinks the logs. Use permissive thresholds — the goal here is hygiene, not defence.

Why does fail2ban report "Currently banned: 0" while attacks continue?

Three causes, in order of frequency: the jail reads the wrong source (a file instead of journald), the filter does not match the actual line format, or the attacking addresses fall into ignoreip. Diagnose with fail2ban-client status <jail> and fail2ban-regex against the real log.

Can I ban addresses permanently?

Technically yes, with bantime = -1. In practice bantime.increment = true capped by bantime.maxtime works better: the firewall rule set does not grow without bound, while repeat offenders still end up blocked for weeks. Keep permanent bans in a separate list you deliberately review.

Will fail2ban stop a DDoS attack?

No. In a volumetric attack the packets reach the server and consume bandwidth and CPU before anything is written to a log. fail2ban acts after the log entry appears, meaning after the request was already processed. DDoS requires upstream or CDN-level filtering plus nginx rate limits.

How do I move a fail2ban configuration to another server?

Copy only your own files: jail.local, the contents of jail.d/, custom filters from filter.d/ and custom actions from action.d/. Do not copy jail.conf or the ban database: the former ships with the package, the latter is specific to one machine.

How much load does fail2ban add?

On a typical VPS the overhead is negligible while the jail count is small and logs are moderate. Problems appear with a wide findtime on very busy access logs and with tens of thousands of firewall rules. Fix it by narrowing the window, moving to ipset or nftables sets, and using the recidive jail instead of permanent bans.

Deployment checklist

  • Package installed, daemon enabled at boot, fail2ban-client status responds.
  • All edits live in jail.local and jail.d/; jail.conf untouched.
  • Your own address, VPN and monitoring probes are listed in ignoreip.
  • You verified where sshd actually logs; with journald the jail uses backend = systemd and a correct journalmatch.
  • fail2ban-client status sshd shows a non-zero "Total failed" counter on a public server.
  • Every custom filter was run through fail2ban-regex, checking both matches and date parsing.
  • banaction matches the firewall actually in use (iptables, nftables or ufw), and the rules are visible in the firewall output.
  • Behind a reverse proxy or CDN, realip is configured with an explicit list of trusted networks.
  • bantime.increment or the recidive jail is used instead of permanent bans.
  • An out-of-band access path exists in case you ban yourself.
  • An external port scan and security check were run, and external uptime monitoring is in place.
  • SSH password authentication is disabled — the primary control that makes fail2ban a secondary measure.

Check your website right now

Check your site's security →
More articles: Security
Security
How to Check a Website for Malware: 4 Layers of Detection and a Cleanup Plan
01.04.2026 · 959 views
Security
Web Server Security Hardening Checklist: Nginx and Apache
16.03.2026 · 457 views
Security
HSTS and Preload List: Complete Implementation Guide
16.03.2026 · 365 views
Security
How to Check a Website for Fraud: 12 Signs of a Phishing Site
18.07.2026 · 303 views