Skip to content
Mail Cabinet Choose a plan

Fail2Ban and CSF Setup: SSH, nginx and WordPress Jails, Tested

Fail2Ban and CSF Setup: SSH, nginx and WordPress Jails, Tested

Fail2Ban reads service logs and, after a few failed logins from one IP address, adds that address to the firewall for a set time. CSF does the same with its LFD daemon and also manages the iptables rules. Both block a specific IP at the network level, so they do not stop brute force spread over thousands of addresses, do not see what never reaches a log, and behind Cloudflare they ban the wrong address unless you configure more. Below are working jails for SSH, nginx and WordPress, recidive, and six pitfalls we reproduced on a test bench.

How we tested

On 10 November 2026 on a test bench: an Ubuntu 24.04 container, Fail2Ban 1.0.2 from the Ubuntu package, nftables, OpenSSH 9.6p1, nginx 1.24.0, PHP-FPM 8.3, MariaDB 10.11, WordPress 6.4.3. The server, the attacker, the admin and a reverse proxy each lived in their own network namespace; sshpass and curl generated the password guessing, and time to ban was measured from fail2ban.log with millisecond timestamps. IPv6 was tested in a separate User-Mode Linux 6.17 kernel because IPv6 is disabled in the container kernel. CSF was not installed on the bench: the CSF part is based on its readme and csf.conf from the open source code. Fail2Ban, CSF, nginx, Cloudflare and Debian documentation checked on 10.11.2026. These are test bench results, not data from RichHost servers.

What Fail2Ban and CSF actually block

Fail2Ban is made of filters (regular expressions for log lines), jails (which log to read and how many matches to count) and actions (what to do with the IP). By default the action puts the address into an nftables or iptables set, and the kernel drops its packets to the listed ports. So a ban applies to the source IP of a packet, not to a user, a site or an attack as such.

Tool Reads Blocks Does not block
Fail2Ban Log files or journald The IP on the jail’s ports (or all ports in recidive) A botnet where each address stays under maxretry; requests through a proxy while the ban sits in the server firewall
CSF + LFD Logs at the paths in csf.conf, every 5 seconds (LF_PARSE) The IP, permanently or temporarily; CSF itself defines the open ports The same, plus IPv6 is off by default (IPV6 = “0”)
ModSecurity The HTTP request itself Individual malicious requests (WAF) Slow password guessing with well formed requests

One thing to know about CSF: ConfigServer (Way to the Web Ltd) closed on 31 August 2025. The code is GPLv3 and lives on in forks: cPanel maintains a build for cPanel and WHM servers only, and community forks exist for Debian and RHEL. For a new VPS without a control panel, Fail2Ban from the distribution repository is the simpler choice.

A working jail.local for SSH, nginx and WordPress

Never edit jail.conf: a package update overwrites it. Put your settings in /etc/fail2ban/jail.local. The bench ran exactly these sections, only with short ban times so we could watch several cycles in a few minutes.

# /etc/fail2ban/jail.local  (Debian 12 / Ubuntu 24.04, Fail2Ban 1.0.x)
[DEFAULT]
ignoreip  = 127.0.0.1/8 ::1 203.0.113.10
bantime   = 1h
findtime  = 10m
maxretry  = 5
bantime.increment = true
bantime.maxtime   = 1w
backend   = auto
banaction = nftables
banaction_allports = nftables[type=allports]

[sshd]
enabled = true
port    = ssh
# no /var/log/auth.log: read the journal
backend = systemd

[nginx-http-auth]
enabled = true
port    = http,https
logpath = /var/log/nginx/error.log

[wordpress]
enabled = true
filter  = wordpress-hard
port    = http,https
logpath = /var/log/nginx/access.log
# behind Cloudflare or another proxy:
# action = %(action_)s
#          nginx-deny

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

What matters here:

  • maxretry and findtime set the threshold: 5 matches in 10 minutes. A match is not a password. One SSH connection with a nonexistent user produces two lines (Invalid user and Failed password), so on the bench the ban came after the third connection.
  • bantime.increment extends the ban for addresses that were banned before, and recidive reads Fail2Ban’s own log and closes all ports for anyone banned three times in a day.
  • backend: auto for files, systemd for the journal. The Ubuntu 24.04 package ships jail.d/defaults-debian.conf with backend = systemd for every jail, so an nginx jail without an explicit backend = auto reads the systemd journal and never sees a line of access.log.
  • ignoreip: your static address or your VPN address. ignoreself is on by default and only covers the server’s own addresses.

The package has no WordPress filter, so we wrote one for the nginx combined log. WordPress answers a failed wp-login.php login with 200 and a successful one with 302, so only 200 counts. Any POST to xmlrpc.php counts too.

# /etc/fail2ban/filter.d/wordpress-hard.conf  (nginx combined log)
[Definition]
failregex = ^<HOST> \S+ \S+ \[\] "POST /wp-login\.php(?:\?\S*)? HTTP/[\d.]+" 200 
            ^<HOST> \S+ \S+ \[\] "POST /xmlrpc\.php(?:\?\S*)? HTTP/[\d.]+" 
ignoreregex =

Run the filter against a real log before you enable it. On the bench fail2ban-regex found 2 matches in 8 lines: the failed login and the XML-RPC request, and it left the admin’s successful login alone. Everyday commands:

fail2ban-client status sshd
fail2ban-regex /var/log/nginx/access.log /etc/fail2ban/filter.d/wordpress-hard.conf
fail2ban-client get wordpress logpath
fail2ban-client set sshd unbanip 203.0.113.10
fail2ban-client get recidive banip --with-time

What the bench showed

The attacker guessed SSH passwords as fast as sshpass allows, one password per connection. One run, shortened:

$ att_ssh.sh cw33att 9
17:01:47.105 try 1: admin@10.33.0.1: Permission denied (publickey,password).
17:01:47.648 try 4: admin@10.33.0.1: Permission denied (publickey,password).
17:01:47.853 try 5: ssh: connect to host 10.33.0.1 port 22: Connection refused
$ grep sshd /srv/cw33/log/fail2ban.log
17:01:47,623 [sshd] Found 10.33.0.2 - 2026-11-10 17:01:47
17:01:47,804 [sshd] Ban 10.33.0.2
$ nft list table inet f2b-table
  set addr-set-sshd { type ipv4_addr; elements = { 10.33.0.2 } }
  tcp dport 22 ip saddr @addr-set-sshd reject with icmp port-unreachable

We repeated the burst 10 times for each log reading method. With pyinotify the ban landed 0.34 to 1.2 seconds after the first attempt, median 0.5 seconds; with polling 0.43 to 1.2 seconds, median 0.56. Before the ban the bot got 2 to 6 connections through. So maxretry is not an exact limit: a fast bot passes the threshold while Fail2Ban is still reading the log.

With web forms the gap is bigger. 8 POST requests to wp-login.php took 0.21 seconds, and the ban came 76 milliseconds after the fifth match, when requests 6, 7 and 8 had already been answered. In the nginx-http-auth jail, 7 wrong Basic Auth passwords took 64 milliseconds and the ban came after 0.35 seconds. After the ban that IP got connection refused on port 80, while the admin’s address kept working.

We also tested XML-RPC. One system.multicall request carrying 20 login attempts is a single line in the nginx log, so Fail2Ban counts it once. However, WordPress 6.4.3 rejected every attempt after the first failure in that request, even the last one with the correct password. If you do not need XML-RPC (some mobile apps and Jetpack use it), block it at the web server, for example with a rule in the .htaccess file on your hosting.

findtime, bantime and recidive

We ran slow guessing with findtime = 60s. One connection every 35 seconds was still banned on the third connection, although the first and third attempts were 71 seconds apart. The reason is in the Fail2Ban 1.0.2 code: when the span exceeds findtime the counter is not reset but re-estimated from the attempt rate. One connection every 50 seconds was never banned: 10 passwords in 200 seconds. A parallel jail with findtime = 10m on the same lines would have banned it on the third connection.

We tested escalation with a deliberately short 20 second ban to see several cycles:

# sshd bantime 20s + recidive (maxretry 3, findtime 1d, bantime 1w)
17:02:56 [sshd] Ban 10.33.0.2     17:03:15 [sshd] Unban
17:03:17 [sshd] Ban 10.33.0.2     17:03:36 [sshd] Unban
17:03:39 [sshd] Ban 10.33.0.2
17:03:39 [recidive] Ban 10.33.0.2
$ fail2ban-client get recidive banip --with-time
10.33.0.2  2026-11-10 17:03:39 + 604800 = 2026-11-17 17:03:39

# sshd bantime 20s + bantime.increment = true
[sshd] Increase Ban 10.33.0.2 (2 # 40s -> 2026-11-10 17:07:05)
[sshd] Increase Ban 10.33.0.2 (3 # 1m 20s -> 2026-11-10 17:08:26)
[sshd] Increase Ban 10.33.0.2 (4 # 2m 40s -> 2026-11-10 17:11:06)

Recidive closed every TCP port for the attacker 47 seconds after the first attempt, after which the site on port 80 stopped answering too. With bantime.increment a known address was banned again after a single connection, and the ban doubled each time: 20 seconds, 40, 1 minute 20, 2 minutes 40.

Infographic: Fail2Ban bench results, what worked and what did not
Bench results: green items worked, red items are the pitfalls

Pitfalls we reproduced

You ban yourself

The admin on the bench mistyped the password seven times and was banned after 1.1 seconds. SSH from that address stopped answering while the site kept working, because the sshd jail only closes port 22. You can unban from the provider’s console or from another address with fail2ban-client set sshd unbanip. After the address went into ignoreip, six failed logins in a row only produced Ignore 10.33.0.3 by ip in the log. Do not lower maxretry to 1 or 2: the first wrong key or an old password saved in a client will lock you out.

The systemd backend and the missing auth.log

Debian 12 no longer installs rsyslog by default, so /var/log/auth.log may not exist and SSH logs live only in journald. That is why the Ubuntu package sets backend = systemd. We reproduced both mismatches on the bench:

# Ubuntu 24.04 package: /etc/fail2ban/jail.d/defaults-debian.conf
[DEFAULT]
banaction = nftables
banaction_allports = nftables[type=allports]
backend = systemd

# sshd logs to a file, the jail reads the journal: 8 failed logins
[sshd] Added journal match for: '_SYSTEMD_UNIT=sshd.service + _COMM=sshd'
|- Currently failed: 0
|- Total failed:     0

# backend = auto, /var/log/auth.log does not exist
ERROR   Failed during configuration: Have not found any log file for sshd jail

In the first case Fail2Ban runs and reports zeros during an attack. In the second the server does not start at all. One more detail: since OpenSSH 9.8 some messages come from a process named sshd-session, and the sshd filter handles that only from Fail2Ban 1.1.1 (August 2026, per the project changelog). So after any upgrade, make one failed login and check that Total failed went up. We did not test with the systemd journal itself: the container has none.

IPv6

In a kernel with IPv6, Fail2Ban 1.0.2 banned 2001:db8:33::2 just like an IPv4 address: the nftables action keeps a separate IPv6 set. But one host usually gets a whole /64, and switching addresses inside it costs nothing:

$ fail2ban-client status sshd
   `- Banned IP list:   2001:db8:33::2
$ nft list table inet f2b-table
  set addr6-set-sshd { type ipv6_addr; elements = { 2001:db8:33::2 } }
  tcp dport 22 ip6 saddr @addr6-set-sshd reject with icmpv6 port-unreachable

# one host, 10 addresses of the same /64, 2 tries each
20 x Permission denied,  Currently banned: 0

20 connections from 10 addresses in one /64 produced no ban at all. If your site uses IPv6 on the server, key only SSH login is the reliable defense, and web forms need limits in the application. CSF needs IPv6 switched on separately: the stock csf.conf has IPV6 = "0".

A site behind Cloudflare or another proxy

Behind a proxy every request reaches the server from the proxy’s addresses. We put an nginx in front of the site as a stand in for a CDN and went through three stages:

# 1. no real_ip: nginx sees only the proxy
10.33.0.4 - - [10/Nov/2026:17:22:31 +0000] "POST /wp-login.php HTTP/1.0" 200
NOTICE  [wordpress] Ban 10.33.0.4
admin via proxy: 502  (connect() failed (111: Connection refused) while connecting to upstream)

# 2. real_ip on, ban in nftables
NOTICE  [wordpress] Ban 10.33.0.2
attacker via proxy: 200 200 200

# 3. real_ip + nginx-deny
deny 10.33.0.2;
attacker via proxy: 403 403 403    admin via proxy: 200

Without real_ip, Fail2Ban banned the proxy itself and the admin got 502. With real_ip the log showed the real address, but the nftables ban did nothing: the attacker’s packets still arrive from the proxy. Only a ban inside nginx worked, where after real_ip $remote_addr is the real client. For Cloudflare there is also the cloudflare-token action, which adds a rule through the Cloudflare API. A ready action for nginx:

# /etc/fail2ban/action.d/nginx-deny.conf
[Definition]
actionstart = touch <denyfile>
actionstop  = truncate -s 0 <denyfile> && <nginx_reload>
actionban   = echo "deny <ip>;" >> <denyfile> && <nginx_reload>
actionunban = sed -i '/^deny <ip>;$/d' <denyfile> && <nginx_reload>
[Init]
denyfile     = /etc/nginx/fail2ban-deny.conf
nginx_reload = nginx -s reload

# /etc/nginx/conf.d/realip.conf  (http level)
# every range from https://www.cloudflare.com/ips-v4 and /ips-v6
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
# ...
real_ip_header CF-Connecting-IP;
include /etc/nginx/fail2ban-deny.conf;

Trust the header only from the proxy’s ranges. With set_real_ip_from 0.0.0.0/0 on the bench, the attacker connected directly and sent a new address in X-Forwarded-For every time: 12 attempts were logged as 12 different IPs, no ban. Cloudflare’s range list changes (15 IPv4 networks on 10.11.2026), so refresh it on a schedule.

The log path changed

Fail2Ban survived ordinary rotation (rename plus nginx -s reopen) and banned the next attack. But when we moved the site’s access_log to a separate file, as control panels do, the jail kept reading the old file: 10 failed logins, zero new matches. After a logpath with the pattern /var/log/nginx/domains/*.log and fail2ban-client reload wordpress, bans were back. Check which files a jail really reads with fail2ban-client get wordpress logpath. We covered where logs live and how to read them in our guide to access and error logs.

CSF and LFD: what to know if it is already installed

With the stock csf.conf, LFD blocks SSH after 5 failures (LF_SSHD = "5") and does it permanently (LF_SSHD_PERM = "1"), counts failures over one hour (LF_INTERVAL = "3600") and turns an address into a permanent block after 4 temporary ones in a day (LF_PERMBLOCK). A fresh install has TESTING = "1": cron clears the rules every 5 minutes and LFD does not start, so there is no real protection until you set it to 0. The CSF readme states plainly that behind CloudFlare iptables cannot block the real address, and its API integration only works for the ModSecurity and cxs triggers. If CSF and LFD already run, do not add a Fail2Ban SSH jail on top: the two tools would keep separate ban lists in one firewall.

What else stops SSH password guessing

Fail2Ban cuts log noise, but the main SSH defense is elsewhere. Switch to SSH key only login with PasswordAuthentication no, and password guessing becomes pointless. Disable root login (PermitRootLogin no). For critical servers, OpenSSH has supported FIDO2 hardware keys since version 8.2, and a PAM module adds TOTP one time codes. If you run many servers, look at CrowdSec: it shares lists of attacking addresses between participants. And once a quarter run a website security audit: it finds open ports and stray files that Fail2Ban never sees.

If X, do Y

If Do
Your site is on shared hosting You do not install Fail2Ban or CSF; the provider runs the server firewall. For WordPress: a strong password, two factor login, XML-RPC closed if unused
Your own VPS on Debian or Ubuntu, no panel Fail2Ban from the repository: sshd, recidive, bantime.increment; check the backend with one failed login
SSH already accepts keys only Keep the sshd jail for a clean log, but do not lower maxretry: the key is the real protection
The site is behind Cloudflare or another proxy real_ip for the proxy ranges only, ban in nginx (nginx-deny) or through the Cloudflare API; an nftables ban will not help
You have a dynamic IP address Put your VPN or office address in ignoreip and keep the provider’s console at hand
The server is reachable over IPv6 Check that bans land in the IPv6 set; remember the /64 bypass; enable IPV6 in CSF
Guessing is slow, once every few minutes Raise findtime and rely on recidive with findtime 1d
Your panel writes a separate log per domain A logpath pattern and a check with fail2ban-client get <jail> logpath
The server already runs cPanel with CSF Keep LFD, turn TESTING off, do not duplicate SSH in Fail2Ban

And on shared hosting

On shared hosting the provider manages the server, the firewall and server level brute force protection, and customers do not install Fail2Ban or CSF. RichHost sells shared hosting and domains and has no VPS plans, so this guide is for people with their own server and for understanding what happens on the machine your site runs on. On RichHost shared hosting plans (from $8 per month, our own control panel with an AI assistant, free Let’s Encrypt) your part of the work is the site itself: passwords, updates, closed XML-RPC and reading the logs.

Sources (checked 10.11.2026): Fail2Ban jail.conf; Fail2Ban ChangeLog; CSF (cPanel fork): readme.txt and csf.conf; cPanel: own CSF fork and ConfigServer closure; nginx realip; Cloudflare: restoring original visitor IPs; Debian 12 release notes, section 5.1.7.

FAQ

Do I need Fail2Ban if SSH accepts keys only?

Not strictly, but it helps. The key stops the guessing itself, while Fail2Ban removes thousands of noise lines from the log and, through recidive, closes other ports to persistent addresses.

Fail2Ban or CSF in 2026?

For a VPS without a panel, Fail2Ban: it is in the Debian and Ubuntu repositories and actively developed (version 1.1.1 came out in August 2026). CSF makes sense where it is already integrated, mainly on cPanel servers.

Why does Fail2Ban ban nothing while the log is full of failures?

Most often the jail reads the wrong source: journald instead of a file or the other way round, or an old log path. Check fail2ban-client status, get <jail> logpath and run the filter through fail2ban-regex.

Can I install Fail2Ban on shared hosting?

No, it needs root and access to the server firewall. On shared hosting, protect the site itself: password, two factor login, limited XML-RPC; server protection stays with the provider.

More on this topic

All articles
WordPress Staging Site: How to Create, Hide and Push Changes
Additional28.07.2026

WordPress Staging Site: How to Create, Hide and Push Changes

How to build a WordPress staging site on a subdomain: copy files and database, wp search-replace instead of sed, password and noindex,…

Cron Jobs on Web Hosting: How to Set Up and Fix Common Errors
Hosting Guides and Tutorials12.06.2026

Cron Jobs on Web Hosting: How to Set Up and Fix Common Errors

How to add a cron job in the panel or with crontab, why a command works over SSH but fails in cron,…

File Permissions on Hosting: chmod 644, 755, 600 and Why Not 777
Hosting Guides and Tutorials07.05.2026

File Permissions on Hosting: chmod 644, 755, 600 and Why Not 777

Which permissions to set for directories, files and wp-config.php, what the PHP user changes, why 777 is dangerous and how to fix…