Fail2Ban With Nginx and Cloudflare Free (With IPv6 Support)

This post will teach you how to set up Fail2Ban actions for services reverse-proxied by Nginx and proxied by Cloudflare. I’ll be using Vaultwarden as an example. I have Nginx and Fail2Ban installed natively, and Vaultwarden in a Docker container. You can adjust it to work with Nginx and/or Fail2Ban running in Docker containers.

Unmask Visitor IP in Nginx

Nginx needs to know the visitor’s real IP for the ban to work. Without this step, Nginx will simply see Cloudflare IPs and let it through. Even if you’re not going to use Fail2Ban, you should set this up.

Make sure jq and crontab are installed in your system. We’ll be using a cronjob to fetch Cloudflare IPs and Nginx’s ngx_http_realip_module to unmask the IPs. The script we’re using is from this GitHub repo.

Place the script somewhere on your system. I have it in /opt/scripts/cloudflare.sh.

#!/bin/bash

cf_ips="$(curl -fsLm2 --retry 1 https://api.cloudflare.com/client/v4/ips)"
# Don't overwrite the file if the request fails
[ "$(echo "$cf_ips" | jq -r '.success')" = "true" ] || exit 1
CLOUDFLARE_FILE_PATH=${1:-/etc/nginx/cloudflare}

echo "# Cloudflare IP Ranges" > $CLOUDFLARE_FILE_PATH
echo "" >> $CLOUDFLARE_FILE_PATH
echo "# - IPv4" >> $CLOUDFLARE_FILE_PATH
for ipv4 in $(echo "$cf_ips" | jq -r '.result.ipv4_cidrs[]//""' | sort); do
    echo "set_real_ip_from $ipv4;" >> $CLOUDFLARE_FILE_PATH
done
echo "" >> $CLOUDFLARE_FILE_PATH

echo "# - IPv6" >> $CLOUDFLARE_FILE_PATH
for ipv6 in $(echo "$cf_ips" | jq -r '.result.ipv6_cidrs[]//""' | sort); do
    echo "set_real_ip_from $ipv6;" >> $CLOUDFLARE_FILE_PATH
done
echo "" >> $CLOUDFLARE_FILE_PATH

echo "real_ip_header CF-Connecting-IP;" >> $CLOUDFLARE_FILE_PATH

nginx -t && systemctl reload nginx

Make sure the file permissions are set to 755 (sudo chmod 755 /opt/scripts/cloudflare.sh).

Add the following cronjob using sudo crontab -e:

# Sync Cloudflare IPs and reload Nginx
0 4 * * * /opt/scripts/cloudflare.sh >/dev/null 2>&1

Add this line to the http block in your Nginx config /etc/nginx/nginx.conf:

include /etc/nginx/cloudflare;

Now for every service you’re reverse-proxying, add this line inside location in the server block:

include proxy_params;

Run sudo nginx -t to verify the config, and reload the config using sudo systemctl reload nginx. Nginx will now unmask the visitors’ real IP coming through Cloudflare proxy.

Setup Cloudflare

Free Cloudflare accounts are limited to 5 WAF rules per zone. But Cloudflare offers one free custom list that can store 10,000 IPs. This is more than enough.

Start by creating a custom list. Follow these instructions. You can name it whatever you want. Make sure “Type” is set to “IP”. Select “Create” and check the URL. It will be in the following format: https://dash.cloudflare.com/<account-id>/configurations/lists/<list-id>/add. Note down the values in account-id and list-id, we’ll be using this later.

We also need an API token to edit this list. Follow these instructions. We’ll be using a custom token. Set the permissions to “Account”, “Account Filter Lists”, and “Edit”. Note down the API token.

Next up, create a WAF rule. Follow these instructions. Set “Field” to “IP Source Address” and “Operator” to “is in list”. Your list should automatically be selected. Set “Action” to “Block” and “Order” to “First”. That’s your Cloudflare setup done!

Keep in mind that this blocks banned IPs across your entire zone, not just Vaultwarden. If you only want to block them on Vaultwarden, add another condition: set “Field” to “Hostname”, “Operator” to “equals”, and enter your Vaultwarden domain.

Setup Fail2Ban

Make sure jq is installed in your system.

The action we’re going to be using is from @sebres. This supports both IPv4 and IPv6 addresses. Create /etc/fail2ban/action.d/cloudflare-list.conf with the following contents:

[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=500" \
                   | jq -r --arg ip "<cfip>" 'first(.result[] | select(.ip == $ip) | .id) // empty')
              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)

Now create /etc/fail2ban/action.d/cloudflare-list.local with the following contents:

[Init]
cfapitoken = <api-token>
cfaccountid = <account-id>
cfbanlistid = <list-id>

Fill in the details, and make sure you set the file’s permissions to 640 (sudo chmod 640 /etc/fail2ban/action.d/cloudflare-list.local) to ensure only root can read this file.

Let’s configure Fail2Ban for Vaultwarden. Enable logging in Vaultwarden. Create /etc/fail2ban/filter.d/vaultwarden.local with the following contents:

[INCLUDES]
before = common.conf

[Definition]
failregex = ^.*?Username or password is incorrect\. Try again\. IP: <ADDR>\. Username:.*$
            ^.*?Invalid TOTP code!.* IP: <ADDR>$
ignoreregex =

The second line bans failed 2FA attempts as well.

Now create /etc/fail2ban/jail.d/vaultwarden.local with the following contents:

[vaultwarden]
enabled = true
filter = vaultwarden
backend = auto
logpath = <path/to/vaultwarden.log>
action = cloudflare-list
         nginx-block-map
maxretry = 3
bantime = 1d
findtime = 15m

If you’ve installed Vaultwarden natively, it probably logs to journald instead of a file. In that case, replace backend and filter with the following and remove logpath. Keep the filter we created earlier; the one that ships with Fail2Ban doesn’t match journald logs.

backend = systemd
filter = vaultwarden[journalmatch='_SYSTEMD_UNIT=vaultwarden.service']

Depending on your config, you might want to set chain = FORWARD. Note that we’re not banning the IP using firewall rules, because we’ll be receiving requests from Cloudflare’s IPs.

Setup Nginx

Add the following line to the http block in your main Nginx config (/etc/nginx/nginx.conf):

map $remote_addr $ip_blacklisted { include blacklisted-sessions.map; }

Add the following line to the server block of services you’re reverse-proxying:

if ($ip_blacklisted) { return 444; }

The non-standard return code 444 closes a connection without sending a response header. You can also return 403 if you like.

Lock Down Your Origin

None of this matters if attackers can skip Cloudflare entirely. If your server accepts connections from anywhere, anyone who finds your server’s IP can connect to it directly, bypassing your WAF rule and everything else Cloudflare does. Nginx will still ban them, but your Cloudflare list won’t do a thing.

Allow HTTPS only from Cloudflare’s IPs:

for ip in $(curl -fsS https://api.cloudflare.com/client/v4/ips | jq -r '.result.ipv4_cidrs[], .result.ipv6_cidrs[]'); do
    sudo ufw allow proto tcp from "$ip" to any port 443 comment "Cloudflare"
done

Check that all the ranges were added (sudo ufw status | grep Cloudflare), then remove the rule that opens 443 to everyone (sudo ufw delete allow 'Nginx Full', or however you added it). Make sure your SSL/TLS mode in Cloudflare is set to “Full (strict)”, or your site will go down.

Running Vaultwarden in Docker? Docker bypasses UFW, so publish its port on localhost only (-p 127.0.0.1:8080:80).

Cloudflare’s IPs rarely change. If they do, re-run the loop and delete the ranges they’ve dropped.

There’s one more hole. Anyone can point their own Cloudflare domain at your server’s IP and reach it through Cloudflare. To block this, add the following server block to your Nginx config. It rejects TLS handshakes for any domain you’re not serving:

server {
    listen 443 ssl default_server;
    listen [::]:443 ssl default_server;
    ssl_reject_handshake on;
}

Remove default_server from any other listen … 443 lines first, and drop the [::] line if you don’t have IPv6. To test it, run the following on your server. The first command should fail, and the second should show your certificate:

openssl s_client -connect 127.0.0.1:443 -servername example.com </dev/null
openssl s_client -connect 127.0.0.1:443 -servername vault.example.com </dev/null

Test Your Config

To verify if everything is working, go to your Vaultwarden instance and try logging in with incorrect credentials 3 times. Check /var/log/fail2ban.log (sudo tail -f /var/log/fail2ban.log), it should say that your IP has been banned. Without reloading your Vaultwarden web vault page, try logging in with the correct credentials. It won’t work, as Nginx will just drop the connection. If you reload the page, Cloudflare will say you’ve been banned. To unban yourself, run sudo fail2ban-client set vaultwarden unbanip <banned-ip>. Congrats! You have secured your Vaultwarden instance with two layers of protection using Fail2Ban.

Fail2Ban only slows down attackers guessing from a single IP. It won’t do much against a distributed attack, so enable 2FA on your account. Use a hardware key if you can.

Cloudflare’s free plan also comes with one rate limiting rule. Point it at /identity/connect/token to slow down login attempts before they even reach your server. It’s limited to a 10-second window on the free plan, so treat it as an extra layer, not a replacement.

Changelog

Note: Changes made before 27 Oct 26 are not covered.

  • 27 Oct 26: Added origin lockdown section, 2FA failregex, journald config, and a check to the Cloudflare IP script. Switched from jp to jq and fixed unban removing the wrong IP