As a long-enough overdue update on my previous guide, Installing WordPress on Debian 13 (Link here), it’s time to focus a bit more on security, which happens to be the thing I mentioned I would talk about in that previous post. Like my previous guide, I’ve also written about this topic in the past (Read here if you’re curious), but I wanted to write a more up to date and better version of it, especially since nowadays it’s not very good, at least in my opinion.
Contents
Fail2ban: A Crude and Dumb Yet Surprisingly Effective IPS
Before implementing Fail2ban, it’s important to have a basic understanding of how it works, what I’ll be showing you, and why I’m showing you certain things. If you already know this stuff, then feel free to skip this section. You can skip to How Does Fail2ban Help or Installing Fail2ban if you don’t care about the background information.
The Problem With Scrapers
If you’ve spent any time hosting a web server or any other service on the public internet, you’ve probably noticed stuff like this in your logs:
<Redacted IP> - - [19/Oct/2025:23:18:33 +0000] "GET /ALFA_DATA/alfacgiapi/radio.php?bx=0e215962017 HTTP/1.1" 404 548 "-" "Mozilla/5.0 (Linux; Android 11; Redmi Note 9 Pro Build/RKQ1.200826.002; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/90.0.4430.210 Mobile Safari/537.36" "<Redacted IP>" "<Redacted IP>"
<Redacted IP> - - [19/Oct/2025:23:18:33 +0000] "GET /ALFA_DATA/alfacgiapi/404.php?bx=0e215962017 HTTP/1.1" 404 548 "-" "Mozilla/5.0 (Linux; Android 11; Redmi Note 9 Pro Build/RKQ1.200826.002; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/90.0.4430.210 Mobile Safari/537.36" "<Redacted IP>" "<Redacted IP>"
<Redacted IP> - - [19/Oct/2025:23:23:36 +0000] "GET /con.php HTTP/1.1" 404 548 "www.google.com" "Mozlila/5.0 (Linux; Android 7.0; SM-G892A Bulid/NRD90M; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/60.0.3112.107 Moblie Safari/537.36" "<Redacted IP>" "<Redacted IP>"
<Redacted IP> - - [19/Oct/2025:23:23:36 +0000] "GET /con.php HTTP/1.1" 404 548 "www.google.com" "Mozlila/5.0 (Linux; Android 7.0; SM-G892A Bulid/NRD90M; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/60.0.3112.107 Moblie Safari/537.36" "<Redacted IP>" "<Redacted IP>"
While there’s lots of friendly scrapers, which will typically obey robots.txt, there are many more crudely written ones that are simply trying to brute-force a bunch of URL’s, hoping to find sensitive information or an exploit. These scrapers are ran by an infinite variety of people, ranging from middle schoolers trying to impress their friends after learning about Kali Linux, those trying to mine crypto or build a botnet, or more recently, thanks to the AI boom, AI crawlers DDoS’ing websites (Exhibit A, Exhibit B).
Some of these scrapers are smart, some of which I’ve observed myself, but the vast majority of them are just running through a list of hosts, probably some of which come from places like Shodan to narrow their search, test a bunch of URL’s, and report back their findings. Hell, I still see several that don’t even bother changing their user agent from something like python-requests, use an empty user agent, or even whatever tool/script they’re using.
Why Even Care?
Reading through this, you might be tempted to ask yourself “There’s nothing of interest on my website. Why even bother?”, which is a very valid question.
The biggest issue with scrapers is that even if they’re not doing anything malicious, they’re still wasting resources on your web server. Your web server still has to accept that scraper’s request, send back a partial or even full response, and log it. Rinse and repeat with several scrapers doing this multiple times a day, it adds up to a lot of wasted resources. It’s significantly less resource intensive to simply blackhole all traffic from that IP address rather than having your web server respond to it.
Another issue is that they might not be doing anything malicious today, but there’s a chance that they do find something, or a vulnerability comes out for something that you’re hosting (Log4j, for example). Best case scenario, you’re getting trolled or mining crypto for someone. Worst case scenario, your server is now being used as a relay to commit federal crimes, you’re now part of a nation-state operated botnet, and the feds (Along with your hosting provider) are now after you. It’s probably a good time to mention that you should check your backups.
Regardless, the biggest thing is that it helps cut down on resource usage, and it helps mitigate against future vulnerabilities.
How Does Fail2ban Help?
The reason why I described Fail2ban the way I did is because its operation is very simple, yet surprisingly effective. All Fail2ban does is look in a log file for failed requests within a certain time period (By default it’s the past 10 minutes), and if there’s enough matches, then it’ll block that IP address.
Even though it’s very simple and arguably kind of crude, it still works surprisingly well. Even with smarter bots that try to mix in legitimate requests, presumably to try and fool proper IPS’s, Fail2ban still blocks them, since it only cares about failed requests made within a certain time.

There Are Still Flaws
Despite the simplicity of Fail2ban bringing some advantages, the simplicity also brings some downsides. For example, since Fail2ban doesn’t care about any other requests, if legitimate users are causing 4xx errors on your web server, which can simply be caused by static assets getting updated, but old ones are still cached, their IP addresses might still get banned. Fail2ban is more simple to configure than a proper IPS, but it still requires some tuning and false positives.
Another issue that Fail2ban has is when using it with Cloudflare, there might be a delay between Fail2ban banning the IP and it being banned in Cloudflare. In my experience, Cloudflare seemed to block IP’s pretty much instantly, but your mileage may vary. This might be a problem depending on your use case, but as long as you have some sort of rate limiting, and tell Fail2ban to multiply ban times for repeat offenders, then it shouldn’t be an issue. Plus, most bots are slow by design to evade rate limiting.
Cutting To The Chase: Installing Fail2ban
Now that I’ve gotten the long enough introduction out of the way, let’s actually start setting up Fail2ban. First, install the fail2ban and iptables-persistent packages:
sudo apt install fail2ban iptables-persistent
Basic Configuration
Most of the default options that Fail2ban ships with are fine, but there’s a few changes you’ll want to make.
Database Purge
The first thing that should be changed is the dbpurgeage setting. By default, it purges the database after just one day, which is pretty much useless for blocking repeat offenders. Even though you can directly modify fail2ban.conf, it can cause some headaches with major distro upgrade (Like upgrading from Debian 12 to 13), so you’ll want to create your own in fail2ban.d.
First, create your custom configuration file, using whatever name you want:
sudo nano /etc/fail2ban/fail2ban.d/fail2ban.conf
Next, add the following content, setting dbpurgeage to whatever you see fit:
[DEFAULT]
dbpurgeage = 1y
You can set it to some absurdly high number like 99y to effectively disable it, but IP addresses do change hands (Even if not frequently, especially for more simple bots), and it prevents your database from growing out of control. I personally have it set to one year. You can find all available options in /etc/fail2ban/fail2ban.conf, but make sure to make your changes in your custom configuration file.
Nginx 4xx Filter
Even though Fail2ban ships with filters for Nginx, I prefer making my own filters, since for me at least, the ones Fail2ban ship with are not all that intuitive to implement. Plus, most other places I see seem to recommend creating your own filter rather than using the included ones.
Create a new file named something like nginx-4xx.conf in filter.d, and add in the following content:
[Definition]
failregex = ^<HOST>.*"(GET|POST).*" (400|401|403|404|444) .*$
^<HOST> - \S+ \[\] "[^"]*" 400
# Adjust the line below as needed. You might want to include paths where static assets and dynamic content might be stored to prevent false positives.
ignoreregex = ^<HOST>.*"(GET|POST).*(favicon|apple-touch-icon).*" (401|403|404|444) .*$
Notice the second line in our failregex:
^<HOST> - \S+ \[\] "[^"]*" 400
This is taken directly from the included nginx-bad-requests.conf filter. This regex blocks requests that are either empty, or are just encoded garbage like this one:
<Redacted IP> - - [17/Nov/2025:19:04:28 +0000] "\x16\x03\x01\x05\xA8\x01\x00\x05\xA4\x03\x033\x89\x7F\xD58\x0FG\xFD\xCD\xB6b\xFE*c\x97" 400 150 "-" "-"
We can add both regex’s to the same filter, so that way anything that matches one of those regex’s will be part of the same jail.
I highly recommend using the fail2ban-regex command and regex101.com to fine tune your filter before implementing it:
sudo fail2ban-regex /var/log/nginx/access.log nginx-4xx
Jail Configuration
Like with fail2ban.conf, most of the defaults Fail2ban ships with in jail.conf are fine, but there are a few things that you should change.
First, create a new configuration file in jail.d called wordpress.conf (Or a different name if you desire). This is also where our jails for Nginx will live:
[DEFAULT]
# Somewhat randomizes ban times, so just put a random number here.
bantime.rndtime = 86400
# If you don't have a machine with a different public IP address to test, leave ignoreip blank. Set this to your own IP once you're done testing.
ignoreip = 5355:4253:4352::4942:4534:4d4f:5245/64 1.2.3.4
# How long to ban for the first time.
bantime = 5m
# How far back to look in the logs.
findtime = 10m
# How many failures until the IP is banned.
maxretry = 5
The settings for bantime, findtime, and maxretry are fairly conservative, but you’ll want to fine tune as needed. I set maxretry to 5 because I noticed that most bots seem to make at least around 5 requests, but it’s not so low that it could ban legitimate visitors. When testing, I recommend starting with more conservative settings, or at least not setting any actions when banning the IP.
Under the settings for your jail, add in the nginx-4xx jail:
[nginx-4xx]
enabled = true
port = http,https
logpath = /var/log/nginx/access.log
# Keep this line commented out for testing, or at least make sure you can directly access your servers console in case you lock yourself out.
#action = iptables-allports
Finally, restart the Fail2ban service, and check the status of the jail with the following command:
sudo fail2ban-client status nginx-4xx
To test if it works, you can either just run watch with fail2ban-client, and start typing in invalid URL’s until you see your IP banned:
sudo watch -n 1 fail2ban-client status nginx-4xx
Or you can run something like nikto, which will also test the second regex:
nikto -h https://your-domain-or-ip
Just make sure to disable any rate limiting before running nikto, otherwise you’ll be blocked by it pretty much instantly, which will interfere with your test.
After running your test, you should see your IP in the banned IP list:
Status for the jail: nginx-4xx
|- Filter
| |- Currently failed: 1
| |- Total failed: 16
| `- File list: /var/log/nginx/access.log
`- Actions
|- Currently banned: 1
|- Total banned: 1
`- Banned IP list: 1.2.3.4
You can also use the fail2ban-regex command with the --print-all-matched flag to see what was matched:
sudo fail2ban-regex /var/log/nginx/access.log nginx-4xx --print-all-matched
A Warning Before Continuing
Before you move on to the next step, and I can’t emphasize this enough, make ABSOLUTELY sure you either have a machine with a different IP address to test with, or have some sort of out-of-band access to your server (Most hosting providers let you access the console to your server). Otherwise, you will end up locking yourself out (Don’t ask me how I know). You should test it before enabling any actions as well, since you don’t want to ban your own IP, only to find out you’re locked out of your server and have to wait out the ban.
Enabling and Testing
Now that we’ve verified that the filter actually works, it’s time to test and see if you can actually ban IP’s. Open up /etc/fail2ban/jail.d/wordpress.conf, and uncomment the following line:
action = iptables-allports
Next, restart Fail2ban, and test again (I like using nikto):
nikto -h https://your-domain-or-ip
After running your test, not only should you see your IP address listed in sudo fail2ban-client status nginx-4xx, but your browser should timeout when trying to access your server:

As a pro tip, you can also see how long it will be until an IP address is unbanned by using sudo fail2ban-client get nginx-4xx banip --with-time:
1.2.3.4 2025-12-02 15:13:09 + 301 = 2025-12-02 15:18:10
You can either wait out the ban if it’s short enough (Or you found out the hard way that you locked yourself out of your own server), or unban your IP manually:
sudo fail2ban-client unban 1.2.3.4
Configuring Cloudflare
For those that aren’t using Cloudflare, you’re free to stop right here. But if you do happen to be using Cloudflare, the setup is a little bit more involved. This is because Cloudflare acts as a proxy, which has the side effect of hiding the IP address of your visitors, preventing stuff like iptables from working correctly.
Logging Actual IPs
When looking in /var/log/nginx/access.log, you may or may not see Cloudflare’s IP addresses rather than your visitors IP addresses. Even if you do happen to see visitors actual IP addresses, and verified that Cloudflare actually is proxying your traffic (Which I’ve personally noticed in some cases), I still recommend performing this step as a precaution.
In case anything goes wrong, I recommend first creating a backup of your nginx.conf:
sudo cp /etc/nginx/nginx.conf /etc/nginx/nginx.conf.bak
First, open up /etc/nginx/nginx.conf in a text editor, and add the following content in http under where the include lines are:
http {
...
# Lines shown for reference, don't add these or anything above
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
# This is the stuff you add
# Creates another log file identical to the default one, just with the visitor's actual IP address
log_format wordpress '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for" "$http_cf_connecting_ip"';
access_log /var/log/nginx/wordpress.log wordpress;
# Sets the real IP header coming from Cloudflare's IP address ranges. Make sure these are up to date
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
set_real_ip_from 103.31.4.0/22;
set_real_ip_from 141.101.64.0/18;
set_real_ip_from 108.162.192.0/18;
set_real_ip_from 190.93.240.0/20;
set_real_ip_from 188.114.96.0/20;
set_real_ip_from 197.234.240.0/22;
set_real_ip_from 198.41.128.0/17;
set_real_ip_from 162.158.0.0/15;
set_real_ip_from 104.16.0.0/13;
set_real_ip_from 104.24.0.0/14;
set_real_ip_from 172.64.0.0/13;
set_real_ip_from 131.0.72.0/22;
set_real_ip_from 2400:cb00::/32;
set_real_ip_from 2606:4700::/32;
set_real_ip_from 2803:f800::/32;
set_real_ip_from 2405:b500::/32;
set_real_ip_from 2405:8100::/32;
set_real_ip_from 2a06:98c0::/29;
set_real_ip_from 2c0f:f248::/32;
real_ip_header X-Forwarded-For;
# Make sure you add everything above this bracket
}
Even though they rarely change, depending on when you view this post, the IP ranges shown might be outdated. You can find Cloudflare’s latest list of IP ranges here.
Run sudo nginx -t to test the configuration, and if it’s successful, restart Nginx. If there’s any issues, make sure that your indentation is right and that you added everything in the right place. You can also revert back to the old configuration by running sudo cp /etc/nginx/nginx.conf.bak /etc/nginx/nginx.conf.
Pro Tip
So that way attackers can’t bypass Cloudflare and access your server directly, I recommend configuring your server’s firewall to only allow Cloudflare’s and your own IP addresses. That way, even if attackers did find out your IP, they won’t be able to do much with it.
Banning IPs
Now that we’ve gotten the logging situation under control, it’s time to figure out how to actually ban IP’s. Even though nginx can see your visitors IP addresses, iptables and whatnot can’t, so we’ll need to ban them from Cloudflare. Plus, it takes that load off your server.
Getting an API Key
To allow Fail2ban to talk to Cloudflare, you’ll need to get an API token. First, navigate to the API Tokens section on your Cloudflare profile (Link here), and click the blue Create Token button:

Next, scroll to the bottom, then click the blue Get Started button next to Create Custom Token:

Under Permissions, you’ll want to configure them as shown in the screenshot, and set Zone Resources to only include your domain. I recommend restricting the IP addresses that can use it to your server’s IP address and your own public IP:

Even though you technically can add multiple domains under Zone Resources, I recommend just creating an API token for each domain. This helps improve security (Or at least make you feel better), and provides a little bit more control.
After choosing your permissions, scroll to the bottom and click Continue to Summary, then Create Token. Your token will be displayed. I recommend keeping it somewhere safe for reference:

I also recommend using the provided curl command, ideally from your server, to test and see if the token works correctly.
The last step is to get the Zone ID of your domain to feed to Fail2ban. It’s as easy as running the following command, substituting in your own API token:
curl https://api.cloudflare.com/client/v4/zones -H "Authorization: Bearer 46616b65436c6f7564666c617265415049546f6b656e"
Assuming that you’ve restricted it to a single zone, the output will only show the information for that zone. The Zone ID will be right at the top:
{"result":[{"id":"4e6f476f7653656372657473546f4265466f756e64","name":"alexshomenetwork.com","status":"active","paused":false,"type":"full","development_mode":-12441583,
...
# You'll see a bunch of stuff, but id and name are the only things you'll need
Configuring Fail2ban
Now that you’re armed with the necessary information from Cloudflare, it’s time to configure Fail2ban. First, open /etc/fail2ban/action.d/cloudflare-token.conf in a text editor, navigate to the following lines, uncomment cfzone and cftoken, and add in your Cloudflare Zone ID and API Token:
# The Cloudflare <ZONE_ID> of the domain you want to manage
#
cfzone = 4e6f476f7653656372657473546f4265466f756e64
# Your personal Cloudflare token. Ideally restricted to just have "Zone.Firewall Services" permissions
#
cftoken = 46616b65436c6f7564666c617265415049546f6b656e
# There are several other options you can configure. For example, you can change cfmode from block to js_challenge to show a challenge rather than outright blocking an IP
Save and exit the file. Since this file contains your Cloudflare API token, and it’s world-readable by default, you’ll want to change the permissions to more restrictive ones:
sudo chmod 640 /etc/fail2ban/action.d/cloudflare-token.conf
Next, open /etc/fail2ban/jail.d/wordpress.conf, change logpath to /var/log/nginx/wordpress.log, and add cloudflare-token under action:
[nginx-4xx]
enabled = true
port = http,https
logpath = /var/log/nginx/wordpress.log
action = iptables-allports
cloudflare-token
After saving and exiting, restart the Fail2ban service.
Testing (Again)
These changes won’t do us any good if we don’t know that they work, so we’ll need to run a quick test.
First, navigate to your Cloudflare dashboard, click on your domain, navigate to Security Rules under Security, then scroll to the bottom. The IP access rules will appear under Managed Rules:

Next, on your server’s console, run sudo watch -n 1 fail2ban-client status nginx-4xx to see in real time if your IP address was banned, since it might be slow to show up on Cloudflare.
I’m sure you already know this by now, but as another warning, make sure you don’t lock yourself out of your own server.
Finally, run the same tests that you did earlier, either using nikto, or doing it the manual way. After your IP address appears in the banned IP list, refresh the Cloudflare security rules page, and you should see your IP address listed under IP access rules:

When you try accessing your website, you should see a page that looks something like this:

If you’re not blocked, make sure that the correct IP address was blocked. Even though for me it blocked the IP instantly, you might need to wait a minute.
To make sure that Fail2ban can unban an IP, which shouldn’t be an issue since you’ve already banned one, run the following command:
sudo fail2ban-client unban <your IP>
Refresh the page, and the IP access rule should be gone.
Troubleshooting
If Fail2ban is able to ban your IP address, it’s showing up in the banned IP list, but Cloudflare isn’t blocking you, there’s a few things that you should check.
The first thing you should do is check and see if the IP address is banned in Cloudflare. If it’s showing up, make sure that the IP address that was banned is the same one that you’re testing from. Make sure that it isn’t a private IP address. I mention this because it’s something that’s very simple but also very easy to forget.
If you don’t see an IP access rule, then it might be a permissions issue on your API token. To verify this, open /etc/fail2ban/fail2ban.d/fail2ban.conf, and add the following line:
loglevel = DEBUG
You’ll want to remove this once you’re done, since this will flood your log files.
After restarting the Fail2ban service, open a separate terminal, and follow /var/log/fail2ban.log:
sudo tail -f /var/log/fail2ban.log
If you see something like this when banning an IP in your logs:
'{"success":false,"errors":[{"code":10000,"message":"Authentication error"}]}'
This indicates a permission issue with your Cloudflare API token. Unfortunately, this error doesn’t give much information, nor do the audit logs in your account. You can run the same curl command that Fail2ban does to see if you get the same error (Substituting in your own information):
curl -s -X POST "https://api.cloudflare.com/client/v4/zones/4e6f476f7653656372657473546f4265466f756e64/firewall/access_rules/rules" -H "Authorization: Bearer 46616b65436c6f7564666c617265415049546f6b656e" -H "Content-Type: application/json" --data '{"mode":"block","configuration":{"target":"ip","value":"1.2.3.4"},"notes":"Fail2Ban nginx-4xx"}'
If the command works, verify that the information in /etc/fail2ban/action.d/cloudflare-token.conf is correct. If the command gives the same error, check and see if your API token has Read access for Zone, and Edit access for Zone Settings and Firewall Services. You can also remove the IP addresses under Client IP Address Filtering and set Zone Resources to include all zones for testing.
Using the same command that I showed earlier, verify that you have the correct zone ID:
curl https://api.cloudflare.com/client/v4/zones -H "Authorization: Bearer 46616b65436c6f7564666c617265415049546f6b656e"
If all else fails, or you’re not positive that the API key is correct (And you didn’t save it anywhere), you can generate a new one with the same permissions by using the Roll function:

Conclusion
So that’s how you use Fail2ban with Cloudflare and Nginx to ban potentially malicious visitors from your website. Despite it being very simple and arguably kind of hacky, it’s still a surprisingly effective security measure, especially against more simple bots, which you’re most likely going to encounter.
If you have any comments, suggestions, questions, complaints, or anything else to say, feel free to leave them in the comments, and I’ll try my best to address them.
Why Wait so Long to Publish?
Some of you might have noticed that the timestamps shown in my examples at the beginning are from October, then further down one shown in November, and the date that this blog post was published is in December.
If you’re wondering why it took so long to publish this post, especially because I promised I would make it “soon” in my previous post, this is simply because of several personal issues cropping up, eating away at the time I had to work on this post. Since my two options were either to wait, or rush out something half-baked, I decided to wait and polish it up.