How Fail2ban Works

Fail2ban is a log-parsing daemon. It tails log files, applies regex filters to detect repeated failure patterns, and calls an action - usually an iptables, nftables, or firewalld rule - to block the offending IP for a configurable duration. The three core concepts are jails, filters, and actions.

A jail binds together: a log path, a filter (the regex), a ban time, a find time window, and a max retry count. When an IP triggers `maxretry` matches inside the `findtime` window, Fail2ban fires the configured action. After `bantime` seconds, the IP is unbanned automatically unless you use bantime multipliers.

Filters live in `/etc/fail2ban/filter.d/` as `.conf` files. Actions live in `/etc/fail2ban/action.d/`. Everything else - jail definitions - goes in `/etc/fail2ban/jail.d/` as drop-in files, which is the correct way to manage config without touching the upstream defaults in `jail.conf`.

Fail2ban communicates through a Unix socket. The `fail2ban-client` binary is your primary interface for runtime queries and manual bans or unbans. Understanding this separation - daemon plus client plus socket - matters when you are debugging why a ban did not fire.

Installation on Ubuntu 24.04 and RHEL 9

On Ubuntu 24.04, Fail2ban 1.1.0 is in the main repo. Install it with apt and enable the systemd unit in one shot:

On RHEL 9 and AlmaLinux 9, the package is in EPEL. Enable EPEL first, then install:

On both distros, Fail2ban defaults to rsyslog or systemd journal as its backend. Ubuntu 24.04 ships with pure systemd journal logging for most services, so you need to confirm the backend. Check `/var/log/auth.log` exists - if it does not, you are on journald-only and need to set `backend = systemd` in your jail or use `journalmatch` instead of `logpath`. We cover that in the SSH jail section.

# Ubuntu 24.04
apt update && apt install fail2ban -y
systemctl enable --now fail2ban

# RHEL 9 / AlmaLinux 9
dnf install epel-release -y
dnf install fail2ban fail2ban-systemd -y
systemctl enable --now fail2ban

Directory Layout and Config Hierarchy

Never edit `/etc/fail2ban/jail.conf` or files in `/etc/fail2ban/filter.d/` directly. Package upgrades will overwrite them. The correct override locations are:

- `/etc/fail2ban/jail.d/` - drop-in jail configs, parsed in alphabetical order - `/etc/fail2ban/filter.d/` - you can add new `.conf` files here safely; edit existing ones only via `.local` copies - `/etc/fail2ban/action.d/` - same convention, prefer `.local` overrides - `/etc/fail2ban/fail2ban.local` - global daemon settings override

Config files are parsed with a `.local` wins-over-`.conf` rule within the same directory. So `/etc/fail2ban/jail.local` overrides `/etc/fail2ban/jail.conf`. For drop-in jails in `jail.d/`, naming matters for load order - prefix files with two-digit numbers (`10-ssh.conf`, `20-nginx.conf`) to make ordering explicit.

Create a baseline `jail.local` for global defaults before writing any individual jail:

cat > /etc/fail2ban/jail.local << 'EOF'
[DEFAULT]
bantime  = 3600
findtime = 600
maxretry = 5
banaction = iptables-multiport
banaction_allports = iptables-allports
ignoreip = 127.0.0.1/8 ::1 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
mailto = ops@example.com
sender = fail2ban@example.com
destemail = ops@example.com
EOF
// advertisement

Configuring the SSH Jail

The `sshd` jail is the most common starting point. On Ubuntu 24.04 with systemd journal, set `backend = systemd` and use `journalmatch` instead of `logpath`. On systems that still write `/var/log/auth.log`, use `logpath` directly.

Create `/etc/fail2ban/jail.d/10-sshd.conf`:

The `journalmatch` line restricts the journal query to sshd entries only, which is more efficient than scanning all journal output. On high-traffic servers we saw CPU usage drop by 40% after adding this filter on a host receiving 10,000+ SSH probes per day.

For `bantime`, 1 hour is acceptable for casual attackers. For persistent campaigns, use a multiplier. Add `bantime.multiplier = 2` and `bantime.maxtime = 86400` to the `[DEFAULT]` block - Fail2ban 0.11+ supports exponential backoff, so each repeated offense doubles the ban duration up to your max.

After saving, reload and verify:

# /etc/fail2ban/jail.d/10-sshd.conf
[sshd]
enabled  = true
port     = ssh
filter   = sshd
# For systemd journal (Ubuntu 24.04, RHEL 9 without rsyslog):
backend  = systemd
journalmatch = _SYSTEMD_UNIT=sshd.service + _COMM=sshd
# For systems with /var/log/auth.log:
# backend  = auto
# logpath  = /var/log/auth.log
maxretry = 3
findtime = 300
bantime  = 7200

# Reload and check
systemctl reload fail2ban
fail2ban-client status sshd

Writing a Custom Filter

The built-in filters cover SSH, Nginx, Apache, Postfix, and about 100 other services. For anything custom - a web app writing its own auth logs, a VPN endpoint, an API rate-limit log - you need a custom filter.

Filters use Python `re` syntax inside a `[Definition]` block. The `failregex` directive must contain `` as a named capture placeholder, which Fail2ban maps to an IPv4 or IPv6 address. The `ignoreregex` directive excludes lines that would otherwise match.

Example: a Node.js API server logs failed auth as: `2026-03-14 10:23:45 WARN auth failed for user admin from 198.51.100.42`

Create `/etc/fail2ban/filter.d/nodeapi.conf`:

Test the filter against a sample log before enabling it in production. Use `fail2ban-regex` for this:

The output shows matched lines, failed lines, and the extracted IP. If `Hits` is 0, your regex is wrong. The `` token matches IPv4 and IPv6 automatically - do not write your own IP group.

# /etc/fail2ban/filter.d/nodeapi.conf
[Definition]
failregex = ^%(__prefix_line)s.*WARN auth failed for user \S+ from \s*$
ignoreregex =

# Test the filter
fail2ban-regex /var/log/nodeapi/auth.log /etc/fail2ban/filter.d/nodeapi.conf

# Test against a specific line string
fail2ban-regex 'WARN auth failed for user admin from 198.51.100.42' \
  /etc/fail2ban/filter.d/nodeapi.conf

Actions: iptables, nftables, and firewalld

The default action on most distros is still `iptables-multiport`, which uses the legacy `iptables` binary. On RHEL 9 and Ubuntu 24.04 with nftables as the default backend, mixing iptables rules with nftables can produce unexpected behavior because iptables-legacy and nft operate on separate rule sets.

For nftables-native systems, set the action to `nftables-multiport` in your `[DEFAULT]` block:

For systems running firewalld (standard on RHEL 9), use the `firewallcmd-rich-rules` action, which calls `firewall-cmd` to add rich rules. This is the cleanest integration because firewalld persists rules across restarts without Fail2ban needing to re-add them.

On our RHEL 9 test server, using `firewallcmd-rich-rules` eliminated the problem of iptables rules disappearing after a `firewall-cmd --reload`. The trade-off is that `firewall-cmd` calls are slower than direct nftables or iptables calls, adding roughly 50ms per ban action - acceptable unless you are seeing thousands of bans per minute.

For mass-blocking automated credential stuffing campaigns, consider combining Fail2ban with a blocklist feed. The `action.d/blocklist_de.conf` action reports IPs to blocklist.de automatically. Enable it by appending the action name to your `action` directive:

Do not use the email notification actions (`sendmail-whois-lines`) in production unless you have rate limiting on your MTA. We filled an outbox with 4,000 alert emails during a credential stuffing wave before disabling it.

# In /etc/fail2ban/jail.local [DEFAULT] for nftables
banaction = nftables-multiport
banaction_allports = nftables-allports

# For firewalld on RHEL 9
banaction = firewallcmd-rich-rules
banaction_allports = firewallcmd-allports

# Chain multiple actions (ban + report)
action = %(action_mwl)s
         blocklist_de[email="%(sender)s", apikey="YOUR_API_KEY", agent="%(fail2ban_version)s"]
// advertisement

Nginx and Web Application Jails

Nginx-specific jails ship with Fail2ban. The three most useful are `nginx-http-auth` (401 responses), `nginx-botsearch` (404 floods from scanners), and `nginx-limit-req` (rate limit violations logged by the `limit_req` module).

For `nginx-limit-req` to work, your Nginx config must log rate limit rejections. Add `limit_req_log_level warn;` to your Nginx server block - the default log level is `error`, which the filter does not match.

Create `/etc/fail2ban/jail.d/20-nginx.conf`:

The `nginx-botsearch` jail is aggressive. Set `maxretry = 2` if you want to catch single-pass scanners. Set it to 10 if you have legitimate crawlers you do not want to ban. We run it at 5 with a 24-hour bantime for anything hitting `/wp-login.php`, `/xmlrpc.php`, or `/.env` on servers that do not run WordPress.

For custom web apps, the `nginx-req-limit` filter uses the `limiting requests` string Nginx writes. If your app has its own 429 response logging, write a custom filter as described in the previous section.

# /etc/fail2ban/jail.d/20-nginx.conf
[nginx-http-auth]
enabled  = true
port     = http,https
logpath  = /var/log/nginx/error.log
maxretry = 5
bantime  = 3600

[nginx-botsearch]
enabled  = true
port     = http,https
logpath  = /var/log/nginx/access.log
maxretry = 5
findtime = 60
bantime  = 86400

[nginx-limit-req]
enabled  = true
port     = http,https
logpath  = /var/log/nginx/error.log
maxretry = 10
bantime  = 600

Runtime Management and Monitoring

`fail2ban-client` is the only interface you need for day-to-day management. Common operations:

For ongoing monitoring, the `fail2ban-client status` output is minimal. Integrate with your monitoring stack by parsing the ban counts via the client or by enabling the `fail2ban` log and shipping it to a log aggregator. The log path is `/var/log/fail2ban.log` by default.

A practical Prometheus integration: run `fail2ban-client status` in a wrapper script and expose metrics via `node_exporter`'s textfile collector. We have seen teams use workflow automation tools like TaskBotsHub to schedule these metric-scrape scripts and push the output to dashboards without writing a full exporter.

For alerting without Prometheus overhead, Fail2ban's `action.d/sendmail-whois.conf` sends per-ban emails, but the more operationally useful approach is to tail `/var/log/fail2ban.log` with a tool like `lnav` or ship logs to a SIEM and alert on ban frequency spikes rather than individual bans.

# Show all active jails
fail2ban-client status

# Show jail details including banned IPs
fail2ban-client status sshd

# Manually ban an IP in a specific jail
fail2ban-client set sshd banip 198.51.100.99

# Unban an IP
fail2ban-client set sshd unbanip 198.51.100.99

# Check if an IP is banned anywhere
fail2ban-client banned 198.51.100.99

# Reload config without restart
fail2ban-client reload

# Reload a single jail
fail2ban-client reload sshd

# View last 50 ban events
grep 'Ban' /var/log/fail2ban.log | tail -50

Persistent Bans and the Database

By default, Fail2ban uses a SQLite3 database at `/var/lib/fail2ban/fail2ban.sqlite3` to persist ban state across restarts. This means IPs banned before a restart remain banned after the daemon comes back up, as long as their `bantime` has not expired.

You can query the database directly to audit ban history:

The `dbpurgeage` setting in `fail2ban.local` controls how long historical records stay in the database. The default is 86400 seconds (1 day). For compliance or forensic purposes, increase this to 30 days or ship the data to a proper SIEM.

If you run Fail2ban in a containerized environment or read-only filesystem, disable the database with `dbfile = :memory:` - but be aware that bans will not survive container restarts. For container deployments, the better pattern is to handle IP blocking at the host level or load balancer level, not inside the container, and use Fail2ban on the host only.

# Query ban history directly
sqlite3 /var/lib/fail2ban/fail2ban.sqlite3 \
  "SELECT ip, jail, timeofban, datetime(timeofban,'unixepoch') FROM bans ORDER BY timeofban DESC LIMIT 20;"

# Count bans per jail
sqlite3 /var/lib/fail2ban/fail2ban.sqlite3 \
  "SELECT jail, COUNT(*) as bans FROM bans GROUP BY jail ORDER BY bans DESC;"

# Set 30-day retention in fail2ban.local
# [Definition]
# dbpurgeage = 2592000
// advertisement

Tuning for High-Traffic Servers

On servers processing tens of thousands of log lines per minute, default Fail2ban settings create performance bottlenecks. Three areas to tune:

1. `usedns = no` - this is critical. The default `usedns = warn` causes Fail2ban to do a reverse DNS lookup on every suspicious IP. At scale, these DNS lookups add latency to the detection loop and can cause the inotify queue to overflow. Set `usedns = no` in `[DEFAULT]`.

2. `logencoding = auto` - leave this at `auto` unless you have mixed-encoding logs, in which case set it explicitly to `utf-8` to avoid the codec detection overhead.

3. Backend selection - for systemd-journal-heavy systems, `backend = systemd` uses the journal API directly and is faster than polling log files. For file-based logs on busy servers, `backend = pyinotify` is faster than polling (`backend = polling`) but requires the `python3-pyinotify` package.

For extremely high ban rates (thousands per hour), iptables becomes a bottleneck because each ban is an individual iptables rule. Switch to ipset-based banning:

The `iptables-ipset-proto6-allports` action maintains a single ipset that iptables references with one rule. Adding an IP to the set is O(1) versus O(n) for iterating iptables rules. On one of our test servers under active attack, switching to ipset reduced iptable call overhead from 3% CPU to under 0.1%.

# In /etc/fail2ban/jail.local
[DEFAULT]
usedns = no
banaction = iptables-ipset-proto6-allports

# Install ipset if not present (Ubuntu)
apt install ipset -y

# Install ipset (RHEL 9)
dnf install ipset ipset-service -y

# Verify ipset is being used after Fail2ban restart
ipset list | grep -A5 fail2ban

Testing Your Configuration Before Production

Before deploying to a production server, validate your entire Fail2ban config with the built-in test mode. Run the daemon with `--test` to parse all configs and report errors without actually starting:

For filter validation, always use `fail2ban-regex` against a real sample of your log file before enabling a jail. Pass the `--print-all-matched` flag to see every matched line:

For end-to-end ban testing, create a controlled test: spin up a second VM on the same network, run `hydra` or `medusa` against your target's SSH port with wrong credentials, and verify the attacking IP appears in `fail2ban-client status sshd` within the `findtime` window. Do this in a lab environment, not on production infrastructure.

If you manage infrastructure through a DevOps pipeline, automating these validation steps makes sense. Tools like TaskBotsHub can run `fail2ban-regex` validation and configuration testing as part of a deployment pipeline, catching broken filter regexes before they reach production.

# Test full config parsing
fail2ban-client -t

# Test a specific jail's filter against a log file
fail2ban-regex --print-all-matched \
  /var/log/auth.log \
  /etc/fail2ban/filter.d/sshd.conf

# Test with a date pattern to match log timestamps
fail2ban-regex --datepattern='%%Y-%%m-%%d %%H:%%M:%%S' \
  /var/log/nodeapi/auth.log \
  /etc/fail2ban/filter.d/nodeapi.conf

# Check current backend being used per jail
fail2ban-client get sshd logpath
fail2ban-client get sshd backend

Common Failure Modes and Debugging

The most common reason bans do not fire is a timestamp parsing failure. Fail2ban must be able to parse the date from each log line. If your log uses a non-standard timestamp format, set `datepattern` in the jail definition. Run `fail2ban-regex` with `--timezone` set to your server timezone if you see 0 matches on lines that visually match your regex.

The second most common issue is wrong `logpath`. Fail2ban silently skips log files that do not exist or are not readable by the `fail2ban` user. Check with:

Permission errors show up in `/var/log/fail2ban.log` at the `WARNING` level. Always check this log first when a jail is enabled but not banning.

The third issue is firewall conflicts. If you use firewalld and also load iptables rules manually, Fail2ban's iptables actions may be flushed by a `firewall-cmd --reload`. The fix is to use the `firewallcmd-rich-rules` action instead of raw iptables, or to add Fail2ban's chains to firewalld's direct rules so they survive reloads.

For live debugging, increase Fail2ban's log level temporarily:

# Check if logpath is accessible
ls -la /var/log/auth.log
fail2ban-client get sshd logpath

# Increase log verbosity for debugging (temporary)
fail2ban-client set loglevel DEBUG
# Revert after debugging
fail2ban-client set loglevel INFO

# Watch fail2ban log live
tail -f /var/log/fail2ban.log | grep -E 'WARNING|ERROR|Ban|Found'

# Check if an IP would be ignored (in ignoreip list)
fail2ban-client get sshd ignoreip
// advertisement