Every server exposed to the internet is under constant attack. Automated bots scan SSH ports, brute-force login pages, and probe web applications within minutes of a new IP going online. Fail2Ban is the standard defense: it monitors log files for malicious patterns and automatically bans offending IP addresses by adding firewall rules. A default Fail2Ban installation helps, but advanced configuration transforms it from a basic SSH protector into a comprehensive intrusion prevention system covering SSH, Nginx, Apache, WordPress, and custom applications.

This guide goes deep: understanding Fail2Ban's architecture, configuring jails for every common service, writing custom filters with regex, integrating with UFW, setting up email and Slack notifications, creating Cloudflare ban actions, and monitoring ban statistics. By the end, your server will automatically detect and block attackers across all your services.

Prerequisites

Before starting, you need:

Why not just use UFW alone? UFW is a static firewall — it blocks or allows based on rules you write manually. Fail2Ban is dynamic — it reads log files in real-time, detects attack patterns, and creates temporary firewall rules automatically. The two work together: UFW provides the baseline rules, and Fail2Ban adds reactive, time-limited bans for detected threats.

MassiveGRID Ubuntu VPS Includes

Ubuntu 24.04 LTS pre-installed · Proxmox HA cluster with automatic failover · Ceph 3x replicated NVMe storage · Independent CPU/RAM/storage scaling · 12 Tbps DDoS protection · 4 global datacenter locations · 100% uptime SLA · 24/7 human support rated 9.5/10

→ Deploy a self-managed VPS — from $1.99/mo
→ Need dedicated resources? — from $19.80/mo
→ Want fully managed hosting? — we handle everything

Fail2Ban Architecture

Understanding how Fail2Ban works internally helps you configure it correctly and debug issues.

Core Components

Configuration File Hierarchy

/etc/fail2ban/
├── fail2ban.conf          # Main daemon configuration (don't edit)
├── fail2ban.local          # Your overrides for fail2ban.conf
├── jail.conf               # Default jail definitions (don't edit)
├── jail.local              # Your jail overrides and custom jails
├── jail.d/                 # Additional jail configuration files
├── filter.d/               # Filter definitions (regex patterns)
│   ├── sshd.conf           # SSH filter (built-in)
│   ├── nginx-http-auth.conf # Nginx auth filter (built-in)
│   └── ...                 # Many more built-in filters
├── action.d/               # Action definitions
│   ├── ufw.conf            # UFW action (built-in)
│   ├── sendmail.conf       # Email action (built-in)
│   └── ...                 # Many more built-in actions
└── paths-*.conf            # Log path definitions per distribution

Critical rule: Never edit jail.conf, fail2ban.conf, or any file in filter.d/ directly. Package updates will overwrite them. Instead, create .local files that override specific settings. Fail2Ban reads .conf first, then applies .local on top.

Installing and Initial Configuration

Install Fail2Ban:

sudo apt update
sudo apt install -y fail2ban

Fail2Ban starts automatically. Verify:

sudo systemctl status fail2ban

Create the local configuration file:

sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local

However, starting from scratch with only the settings you need is cleaner. Create a minimal jail.local:

sudo nano /etc/fail2ban/jail.local
[DEFAULT]
# Ban duration (1 hour)
bantime = 3600

# Window to count failures (10 minutes)
findtime = 600

# Number of failures before ban
maxretry = 5

# Action to take: ban via UFW and send notification
banaction = ufw
banaction_allports = ufw

# Ignore localhost and your own IPs
ignoreip = 127.0.0.1/8 ::1

# Backend for log monitoring
backend = systemd

# --- JAILS ---

[sshd]
enabled = true
port = ssh
filter = sshd
maxretry = 3
bantime = 3600
findtime = 600

Restart Fail2Ban to apply:

sudo systemctl restart fail2ban

Verify the SSH jail is active:

sudo fail2ban-client status
sudo fail2ban-client status sshd

Configuring Jails for Common Services

SSH Jail (Enhanced)

The default SSH jail is good, but for production we want stricter settings and progressive banning:

[sshd]
enabled = true
port = ssh
filter = sshd
maxretry = 3
findtime = 600
bantime = 3600
# Ban for longer on repeated offenses
bantime.increment = true
bantime.factor = 2
bantime.maxtime = 604800
# Use aggressive mode to catch more attack patterns
mode = aggressive

The bantime.increment feature doubles the ban time for repeat offenders. First ban: 1 hour. Second ban: 2 hours. Third: 4 hours. Up to a maximum of 7 days (604800 seconds).

Nginx HTTP Authentication Jail

If you use Nginx basic authentication for admin areas:

[nginx-http-auth]
enabled = true
port = http,https
filter = nginx-http-auth
logpath = /var/log/nginx/error.log
maxretry = 5
findtime = 600
bantime = 3600

Nginx Bad Bots and Scanners

Block bots that request suspicious URLs (phpMyAdmin probes, .env file requests, wp-login attacks on non-WordPress servers):

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

Nginx Rate Limit Jail

If you've configured rate limiting in Nginx (as described in our Nginx reverse proxy guide), ban IPs that repeatedly hit the rate limit:

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

Apache Jails

For servers running Apache instead of Nginx:

[apache-auth]
enabled = true
port = http,https
filter = apache-auth
logpath = /var/log/apache2/error.log
maxretry = 5
findtime = 600
bantime = 3600

[apache-badbots]
enabled = true
port = http,https
filter = apache-badbots
logpath = /var/log/apache2/access.log
maxretry = 2
findtime = 600
bantime = 86400

[apache-overflows]
enabled = true
port = http,https
filter = apache-overflows
logpath = /var/log/apache2/error.log
maxretry = 2
findtime = 600
bantime = 3600

WordPress Login Protection

Create a custom filter for WordPress login failures. First, create the filter:

sudo nano /etc/fail2ban/filter.d/wordpress-login.local
[Definition]
failregex = ^ .* "POST /wp-login\.php
            ^ .* "POST /xmlrpc\.php
datepattern = ^%%d/%%b/%%Y:%%H:%%M:%%S %%z
ignoreregex =

Add the jail:

[wordpress-login]
enabled = true
port = http,https
filter = wordpress-login
logpath = /var/log/nginx/access.log
maxretry = 5
findtime = 300
bantime = 3600

MariaDB/MySQL Jail

Protect against brute-force database login attempts (relevant if you allow remote database access as described in our MariaDB installation guide):

[mysqld-auth]
enabled = true
port = 3306
filter = mysqld-auth
logpath = /var/log/mysql/error.log
maxretry = 5
findtime = 600
bantime = 3600

Creating Custom Filters with Regex

Fail2Ban's power comes from regex filters. Each filter defines a failregex that matches a failed authentication or malicious request in a log file. The <HOST> placeholder captures the IP address.

Understanding the Filter Format

A filter file has this structure:

[Definition]
failregex =  placeholder>
ignoreregex = 

The <HOST> tag is replaced with a regex that matches IPv4 and IPv6 addresses. Everything else in the pattern must match the actual log line.

Custom Filter: Repeated 404 Errors

Block scanners that generate many 404 errors probing for vulnerabilities:

sudo nano /etc/fail2ban/filter.d/nginx-404.local
[Definition]
failregex = ^ .* "(GET|POST|HEAD) .* HTTP/[12]\.[01]" 404
ignoreregex = \.(css|js|jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot)$

The ignoreregex excludes legitimate 404s for missing static assets.

Add the jail in jail.local:

[nginx-404]
enabled = true
port = http,https
filter = nginx-404
logpath = /var/log/nginx/access.log
maxretry = 20
findtime = 600
bantime = 3600

Custom Filter: Blocking Specific User-Agents

sudo nano /etc/fail2ban/filter.d/nginx-bad-useragent.local
[Definition]
failregex = ^ .* "(GET|POST|HEAD) .* HTTP/[12]\.[01]" .* "(sqlmap|nikto|nessus|masscan|zgrab|python-requests/2|Go-http-client|curl/[0-9])"
ignoreregex =
[nginx-bad-useragent]
enabled = true
port = http,https
filter = nginx-bad-useragent
logpath = /var/log/nginx/access.log
maxretry = 1
findtime = 86400
bantime = 604800

Testing Custom Filters

Before activating a custom filter, test it against your actual log file:

sudo fail2ban-regex /var/log/nginx/access.log /etc/fail2ban/filter.d/nginx-404.local

This shows how many lines match, how many IPs would be captured, and whether the regex works correctly. Always test before deploying.

# Test with a single line
echo '192.168.1.100 - - [27/Feb/2026:10:15:30 +0000] "GET /wp-admin HTTP/1.1" 404 162' | sudo fail2ban-regex - /etc/fail2ban/filter.d/nginx-404.local

Ban Times, Find Times, and Max Retries

The three timing parameters control how aggressive Fail2Ban is:

Parameter Description Recommended Range
maxretry Number of failures before ban 3-5 for auth, 10-20 for HTTP
findtime Time window to count failures 300-600 seconds
bantime How long the IP is banned 3600-86400 seconds

Progressive Banning

Enable progressive banning in the [DEFAULT] section to automatically increase ban times for repeat offenders:

[DEFAULT]
bantime = 3600
bantime.increment = true
bantime.factor = 2
bantime.formula = ban.Time * math.exp(float(ban.Count+1)*banFactor)/math.exp(1*banFactor)
bantime.maxtime = 604800

With this configuration:

Permanent Bans

To permanently ban an IP (use with caution):

bantime = -1

A bantime of -1 means the ban never expires. Only use this for jails that catch genuinely malicious behavior (like vulnerability scanners) where false positives are extremely unlikely.

IP Whitelisting

Whitelisting ensures you never accidentally ban yourself or trusted IPs.

Global Whitelist

In the [DEFAULT] section of jail.local:

[DEFAULT]
ignoreip = 127.0.0.1/8 ::1 198.51.100.50 10.0.0.0/8

This whitelists:

Per-Jail Whitelist

Override the whitelist for a specific jail:

[sshd]
enabled = true
ignoreip = 127.0.0.1/8 ::1 198.51.100.50 198.51.100.51

Dynamic Whitelist via Command

Temporarily whitelist an IP without editing configuration:

sudo fail2ban-client set sshd addignoreip 198.51.100.60

This lasts until Fail2Ban is restarted.

Email Notifications for Bans

Get notified when Fail2Ban bans an IP. This helps you stay aware of attack patterns and identify any false positives.

Install Mail Utilities

sudo apt install -y mailutils

If you need a working SMTP relay, configure Postfix as a satellite system or use an external SMTP service.

Configure Email Action

In jail.local, set the default action to include email:

[DEFAULT]
destemail = admin@yourdomain.com
sender = fail2ban@yourdomain.com
mta = mail

# Action with email notification and whois lookup
action = %(action_mwl)s

The action shortcuts:

The action_mwl action sends an email containing the banned IP, whois information, and the log lines that triggered the ban — extremely useful for post-incident analysis.

Test Email Notifications

Trigger a test by banning a test IP:

sudo fail2ban-client set sshd banip 192.0.2.1

Check your email for the notification, then unban:

sudo fail2ban-client set sshd unbanip 192.0.2.1

Fail2Ban with UFW Integration

By default on Ubuntu, Fail2Ban uses iptables directly. For consistency with your existing UFW rules, configure Fail2Ban to use UFW as its ban action.

Set UFW as the Default Action

In the [DEFAULT] section of jail.local:

[DEFAULT]
banaction = ufw
banaction_allports = ufw

This tells Fail2Ban to use /etc/fail2ban/action.d/ufw.conf for banning, which inserts UFW rules dynamically.

Verify the Integration

After Fail2Ban bans an IP, you can see the ban in both systems:

# Check Fail2Ban
sudo fail2ban-client status sshd

# Check UFW (banned IPs appear as DENY rules)
sudo ufw status numbered

When the ban expires, Fail2Ban automatically removes the UFW rule.

Alternative: iptables-multiport

If you prefer Fail2Ban to manage bans independently of UFW (to avoid cluttering your UFW rule list):

[DEFAULT]
banaction = iptables-multiport
banaction_allports = iptables-allports

Both approaches work. UFW integration keeps everything visible in one place. iptables gives Fail2Ban its own chain that's invisible to ufw status but visible with iptables -L.

Monitoring Banned IPs and Statistics

Check Overall Status

sudo fail2ban-client status

This lists all active jails.

Check a Specific Jail

sudo fail2ban-client status sshd

Output:

Status for the jail: sshd
|- Filter
|  |- Currently failed:	3
|  |- Total failed:	847
|  `- Journal matches:	_SYSTEMD_UNIT=sshd.service + _COMM=sshd
`- Actions
   |- Currently banned:	12
   |- Total banned:	156
   `- Banned IP list:	192.0.2.1 192.0.2.2 ...

View All Currently Banned IPs Across All Jails

sudo fail2ban-client banned

Check If a Specific IP Is Banned

sudo fail2ban-client get sshd banip --with-time | grep "198.51.100.100"

View the Fail2Ban Log

sudo tail -100 /var/log/fail2ban.log

The log shows every ban and unban event with timestamps:

2026-02-27 10:15:30,123 fail2ban.actions [1234]: NOTICE [sshd] Ban 192.0.2.50
2026-02-27 11:15:30,456 fail2ban.actions [1234]: NOTICE [sshd] Unban 192.0.2.50

Ban Statistics Script

Create a script to show daily ban statistics:

sudo nano /usr/local/bin/fail2ban-stats.sh
#!/bin/bash
# Fail2Ban statistics summary
echo "=== Fail2Ban Statistics ==="
echo ""
echo "-- Active Jails --"
sudo fail2ban-client status | grep "Jail list" | sed 's/.*:\s*//' | tr ',' '\n' | sed 's/^\s*/  /'

echo ""
echo "-- Current Bans Per Jail --"
for jail in $(sudo fail2ban-client status | grep "Jail list" | sed 's/.*:\s*//' | tr ',' ' '); do
    jail=$(echo $jail | tr -d ' ')
    count=$(sudo fail2ban-client status $jail | grep "Currently banned" | awk '{print $NF}')
    total=$(sudo fail2ban-client status $jail | grep "Total banned" | awk '{print $NF}')
    echo "  $jail: $count currently banned, $total total"
done

echo ""
echo "-- Today's Bans --"
today=$(date +%Y-%m-%d)
bans=$(grep "$today" /var/log/fail2ban.log 2>/dev/null | grep -c "Ban ")
unbans=$(grep "$today" /var/log/fail2ban.log 2>/dev/null | grep -c "Unban ")
echo "  Bans: $bans"
echo "  Unbans: $unbans"

echo ""
echo "-- Top 10 Banned IPs (All Time) --"
grep "Ban " /var/log/fail2ban.log 2>/dev/null | awk '{print $NF}' | sort | uniq -c | sort -rn | head -10 | while read count ip; do
    echo "  $ip: $count bans"
done
sudo chmod +x /usr/local/bin/fail2ban-stats.sh

Run it anytime:

sudo /usr/local/bin/fail2ban-stats.sh

Advanced: Custom Actions

Cloudflare Firewall Action

If your server sits behind Cloudflare, banning at the firewall level doesn't help — the attacker's real IP is in the X-Forwarded-For header, and the connection comes from Cloudflare's IPs. Instead, ban the IP at Cloudflare's edge.

Create the Cloudflare action:

sudo nano /etc/fail2ban/action.d/cloudflare-ban.local
[Definition]
actionban = curl -s -o /dev/null -X POST \
  -H "Authorization: Bearer " \
  -H "Content-Type: application/json" \
  -d '{"mode":"block","configuration":{"target":"ip","value":""},"notes":"Fail2Ban "}' \
  "https://api.cloudflare.com/client/v4/accounts//firewall/access_rules/rules"

actionunban = RULEID=$(curl -s -X GET \
  -H "Authorization: Bearer " \
  "https://api.cloudflare.com/client/v4/accounts//firewall/access_rules/rules?configuration.value=" \
  | python3 -c "import sys,json; r=json.load(sys.stdin); print(r['result'][0]['id'] if r['result'] else '')") && \
  [ -n "$RULEID" ] && curl -s -o /dev/null -X DELETE \
  -H "Authorization: Bearer " \
  "https://api.cloudflare.com/client/v4/accounts//firewall/access_rules/rules/$RULEID"

[Init]
cftoken = your-cloudflare-api-token
cfaccountid = your-cloudflare-account-id

Use it in a jail:

[nginx-cloudflare]
enabled = true
port = http,https
filter = nginx-botsearch
logpath = /var/log/nginx/access.log
maxretry = 2
bantime = 86400
action = cloudflare-ban

Slack Notification Action

Send ban alerts to a Slack channel:

sudo nano /etc/fail2ban/action.d/slack-notify.local
[Definition]
actionban = curl -s -o /dev/null -X POST \
  -H "Content-type: application/json" \
  --data '{"text":":no_entry: *Fail2Ban* banned `` in jail `` for s (failures: )"}' \
  

actionunban = curl -s -o /dev/null -X POST \
  -H "Content-type: application/json" \
  --data '{"text":":white_check_mark: *Fail2Ban* unbanned `` from jail ``"}' \
  

[Init]
slack_webhook = https://hooks.slack.com/services/YOUR/WEBHOOK/URL

Combine the Slack notification with the UFW ban action:

[sshd]
enabled = true
port = ssh
filter = sshd
maxretry = 3
bantime = 3600
action = ufw
         slack-notify

This bans the IP via UFW and sends a Slack notification. Multiple actions are specified on separate indented lines.

Custom Action: Log to a File

If you want a simple, separate log of all ban events for external processing:

sudo nano /etc/fail2ban/action.d/log-bans.local
[Definition]
actionban = echo "$(date '+%%Y-%%m-%%d %%H:%%M:%%S') BAN  jail= failures=" >> /var/log/fail2ban-bans.log
actionunban = echo "$(date '+%%Y-%%m-%%d %%H:%%M:%%S') UNBAN  jail=" >> /var/log/fail2ban-bans.log

Unbanning IPs and Managing the Ban List

Unban a Specific IP from a Specific Jail

sudo fail2ban-client set sshd unbanip 192.0.2.50

Unban an IP from All Jails

sudo fail2ban-client unban 192.0.2.50

Unban All IPs from All Jails

sudo fail2ban-client unban --all

Manually Ban an IP

sudo fail2ban-client set sshd banip 192.0.2.100

Reload Configuration Without Restarting

After editing jail.local or filter files:

sudo fail2ban-client reload

Reload a specific jail:

sudo fail2ban-client reload sshd

Complete jail.local Example

Here's a comprehensive jail.local for a server running SSH, Nginx, and a web application:

[DEFAULT]
# Default ban settings
bantime = 3600
findtime = 600
maxretry = 5

# Progressive banning
bantime.increment = true
bantime.factor = 2
bantime.maxtime = 604800

# Use UFW for banning
banaction = ufw
banaction_allports = ufw

# Whitelist
ignoreip = 127.0.0.1/8 ::1

# Email notifications
destemail = admin@yourdomain.com
sender = fail2ban@yourdomain.com
mta = mail
action = %(action_mwl)s

# Backend
backend = auto

# --- JAILS ---

[sshd]
enabled = true
port = ssh
filter = sshd
mode = aggressive
maxretry = 3
findtime = 600
bantime = 3600

[nginx-http-auth]
enabled = true
port = http,https
filter = nginx-http-auth
logpath = /var/log/nginx/error.log
maxretry = 5
findtime = 600
bantime = 3600

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

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

[nginx-404]
enabled = true
port = http,https
filter = nginx-404
logpath = /var/log/nginx/access.log
maxretry = 20
findtime = 600
bantime = 3600

Troubleshooting

Fail2Ban Won't Start

Check the configuration for syntax errors:

sudo fail2ban-client -t

Check the log for errors:

sudo journalctl -u fail2ban -n 50

Filter Isn't Matching

Test the filter against the log file:

sudo fail2ban-regex /var/log/nginx/access.log /etc/fail2ban/filter.d/nginx-404.local --print-all-matched

Common issues: wrong log path, log format doesn't match the regex, or the datepattern doesn't match the log's timestamp format.

Bans Aren't Taking Effect

Verify the action is working:

# Check iptables rules
sudo iptables -L -n | grep f2b

# Check UFW rules
sudo ufw status numbered

# Check if the ban was registered
sudo fail2ban-client get sshd banip

Too Many False Positives

If legitimate users are getting banned:

Fail2Ban Using Too Much Memory

If monitoring large log files, Fail2Ban can consume significant memory. Mitigate by:

Prefer Managed Security?

If you'd rather not manage intrusion prevention, firewall rules, log monitoring, and security notifications yourself, consider MassiveGRID's Managed Dedicated Cloud Servers. The managed service handles Fail2Ban configuration, firewall management, intrusion detection, and 24/7 security monitoring — so your infrastructure stays protected without the operational overhead. Every managed server includes 12 Tbps DDoS protection at the network edge and proactive incident response from MassiveGRID's security team.

What's Next