Setting up fail2ban to work with Cloudflare
August 29, 2026
I’ve been running the SWAG Docker image for quite some time now to handle
reverse proxying my services. At some point, I turned on Cloudflare’s DDoS
protection (or the more proper and technical term “orange cloud”) and didn’t
check that the fail2ban configuration was working properly.
Well, I finally noticed today and that explained a ton of things (why does my server stop responding to me from time to time? Oh that’s because you have the same IP as some random botnet from freaking Kyrgyzstan). After looking online I found this blog post that got me started, but I just wanted to quickly point out a couple of things that I changed from his configuration.
Having Google manage the firewall for you makes things a lot simpler than I imagined
If you’ve been following my blog, you’ll know that I set up a public IP for my
home server using Tailscale and a Google Cloud VM. Well, that means that
if we just follow what that blog author did for the trusted sources on fetching
the real IP, then it wouldn’t work because nginx would see the Google Cloud
VM’s Tailscale IP, and not the Cloudflare IP ranges.
The solution, for me at least, was rather simple and a lot more straightforward.
You just have to set those two lines in the nginx.conf configuration file,
within the http block:
set_real_ip_from 100.10.10.80;
real_ip_header CF-Connecting-IP;
Obviously, change 100.10.10.80 to the actual Tailscale IP assigned to the
Google Cloud VM.
Then, on Google Cloud console, under “Network Security > Firewall policies”,
create a new firewall rule. Name it whatever you want, make the target tag
something like cloudflare-only, and in the source filters, paste in the IP
ranges from Cloudflare’s published IP ranges.
Is this brittle? Yes. If Cloudflare changes their IP range then we may suddenly see traffic get dropped by Google’s firewall. But that’s future me’s problem, the services that I host aren’t mission critical, and considering Cloudflare last changed their IP ranges in September 2023 I should be okay. (knock wood)
(Okay, maybe at some point I will write a script that periodically updates the
ranges using the gcloud CLI, and if the stars align that might be another
blog post, but that day isn’t today so quit whining!)
You’d have to repeat this for the published IPv6 IP ranges if your VM has IPv6 enabled, but in my case I didn’t as it isn’t the default. (Keep in mind that even if you have IPv6 disabled Cloudflare will map IPv6 clients to IPv4, so you’ll still see them in your server logs!)
Then, edit your VM configuration and add the cloudflare-only tag, removing the
other tags that permit HTTP and HTTPS traffic by default. Now, only Cloudflare
traffic will be sent to the Google Cloud VM down to your home server.
Setting up Cloudflare (feat. navigating their UI)
The blog post mentions several menus you have to go to, but Cloudflare’s UI has become really sprawling to navigate in recent years. Yes they have more products to support, but that doesn’t make it any better for me…
At any rate, you will have to:
-
Create a custom list.
https://dash.cloudflare.com/?to=/:account/configurations/lists
Note down the account ID and the list ID in the URL.
-
Create an API token.
https://dash.cloudflare.com/profile/api-tokens/
You want to specifically grant
Account.Account Filter Lists. -
Create a WAF rule.
https://dash.cloudflare.com/?to=/:account/:zone/security/security-rules
You want to do “IP Source Address” “is in list” and select the list from earlier.
Setting up fail2ban
The original configuration file is okay, but I find that most distros nowadays
ship with jq rather than jp. So here’s the updated one:
[Definition]
actionban = curl -s -o /dev/null -X POST <_cf_api_prms> \
-d '[{"ip":"'"<cfip>"'","comment":"Created by fail2ban <name>"}]' \
<_cf_api_url>
actionunban = id=$(curl -s -X GET <_cf_api_prms> \
"<_cf_api_url>?search=<cfip>&per_page=1" \
| { jq -r '.result[0].id // ""' 2>/dev/null; })
if [ -z "$id" ]; then echo "<name>: id for <ip> cannot be found"; exit 0; fi;
curl -s -o /dev/null -X DELETE <_cf_api_prms> \
-d '{"items":[{"id":"'"$id"'"}]}' \
<_cf_api_url>
_cf_api_url = https://api.cloudflare.com/client/v4/accounts/<cfaccountid>/rules/lists/<cfbanlistid>/items
_cf_api_prms = -H 'Authorization: bearer <cfapitoken>' -H 'Content-Type: application/json'
[Init]
cfip = <ip>
[Init?family=inet6]
cfip = $(fail2ban-python -c 'import sys; from fail2ban.server.ipdns import IPAddr; a = IPAddr(sys.argv[1]+"/"+sys.argv[2]); print(str(a))' "<ip>" 64)
Then you want to create /etc/fail2ban/action.d/cloudflare-list.local with the
credentials you saved earlier. Here’s a copy of it in case the original blog
goes down:
[Init]
cfapitoken = <api-token>
cfaccountid = <account-id>
cfbanlistid = <list-id>
Now here’s where I slightly deviate from their approach again. In my case,
nginx and fail2ban service the public-facing side of my server, so I want
this action to apply to all fail2ban jails. That’s about as easy as editing
/etc/fail2ban/jail.local and changing the default banaction:
[DEFAULT]
banaction = cloudflare-list
Save, restart, and we’re all done!
To test, try going on mobile data and banning your phone IP:
fail2ban-client set <random_jail_name> banip <phone-ip>
fail2ban-client set <random_jail_name> unbanip <phone-ip>
Thanks to Ken Harris for the original blog post! As his blog post is licensed under CC BY-NC-SA 4.0, this particular post is, too.