Full transparency: Links marked with (*) are affiliate links. Yes, we might earn a commission if you buy. No, that doesn’t mean we’re shilling garbage. We recommend what we’d actually use ourselves. Read our full policy.

The Problem Nobody Talks About

You’ve done the “right” thing: Cloudflare in front for DDoS protection, Hetzner firewall restricting to Cloudflare IPs only, fail2ban for intrusion prevention. Except fail2ban is now useless because every request comes from Cloudflare’s IP ranges.

The conventional advice? “Just configure fail2ban to read X-Forwarded-For headers!” Right. Except when you’re using a managed hosting panel (like xCloud), you often can’t customize fail2ban filters properly. Even if you could, you’re fighting against a tool designed for a different architecture.

The contrarian take: Stop trying to make fail2ban work in a proxy environment. Use CrowdSec instead—it was actually designed for this.

Why CrowdSec Over Fail2ban

CrowdSec advantages in proxy setups:

  • Built-in Cloudflare header support (no hacks needed)
  • Crowd-sourced threat intelligence (you benefit from 70k+ other servers)
  • Modern bouncer architecture (block at multiple layers)
  • Better WordPress attack detection out of the box
  • Actually maintained for CDN/proxy scenarios

Fail2ban is fine for:

  • Simple SSH protection on directly-exposed servers
  • Legacy setups where you can’t change anything
  • When you don’t have a proxy layer

For a Cloudflare-fronted multi-site WordPress server? CrowdSec is the cleaner solution.

The Architecture

Defense layers:

  1. Hetzner Firewall: Only Cloudflare IPs + management IP allowed
  2. Cloudflare: Free tier Bot Fight Mode, rate limiting rules
  3. CrowdSec: Detection engine reading NGINX logs
  4. NFTables Bouncer: Kernel-level blocking of banned IPs
  5. NGINX Rate Limiting: Additional protection independent of CrowdSec

Implementation (The Actually Useful Part)

Prerequisites Check

First, verify your NGINX is already logging real IPs. Check your logs:

tail /var/log/nginx/access.log

If you see IPs like 104.16.x.x or 172.64.x.x (Cloudflare ranges), you need to configure real IP first:

nginx

# /etc/nginx/nginx.conf - in http block
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;
real_ip_header CF-Connecting-IP;
real_ip_recursive on;

Test and reload: nginx -t && systemctl reload nginx

Install CrowdSec (Modern Method)

The old installation script uses deprecated apt-key. Do this instead:

bash

# Add GPG key properly
mkdir -p /usr/share/keyrings
curl -fsSL https://packagecloud.io/crowdsec/crowdsec/gpgkey | gpg --dearmor -o /usr/share/keyrings/crowdsec-archive-keyring.gpg
# Add repository
echo "deb [signed-by=/usr/share/keyrings/crowdsec-archive-keyring.gpg] https://packagecloud.io/crowdsec/crowdsec/ubuntu/ $(lsb_release -cs) main" | tee /etc/apt/sources.list.d/crowdsec.list
# Install
apt update && apt install crowdsec
# Verify
crowdsec -version

Configure Log Acquisition

CrowdSec uses /etc/crowdsec/acquis.d/ directory. The default NGINX config is fine as-is:

yaml

# /etc/crowdsec/acquis.d/nginx.yaml (already exists)
filenames:
  - /var/log/nginx/*.log
labels:
  type: nginx
source: file

Add SSH monitoring:

bash

cat > /etc/crowdsec/acquis.d/sshd.yaml << 'EOF'
filenames:
  - /var/log/auth.log
labels:
  type: syslog
source: file
EOF

Important: Don’t create a custom Cloudflare parser or whitelist. CrowdSec already ships with crowdsecurity/whitelist-good-actors that includes Cloudflare IPs. You don’t need to do anything special.

Install Collections

Collections are pre-configured detection scenarios:

bash

cscli collections install crowdsecurity/nginx
cscli collections install crowdsecurity/base-http-scenarios
cscli collections install crowdsecurity/wordpress
cscli collections install crowdsecurity/http-cve
cscli collections install crowdsecurity/sshd
# Verify
cscli collections list
# Reload
systemctl reload crowdsec

Install NFTables Bouncer

The bouncer enforces bans at the firewall level:

bash

# Install
apt install crowdsec-firewall-bouncer-nftables
# Generate API key
cscli bouncers add firewall-bouncer
# Save the API key shown

Edit /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml:

yaml

mode: nftables
update_frequency: 10s 
log_mode: file
log_dir: /var/log/
pid_dir: /var/run/
log_level: info
api_url: http://127.0.0.1:8080/
api_key: YOUR_API_KEY_HERE
disable_ipv6: false
deny_action: DROP
deny_log: false
supported_decisions_types:
  - ban
blacklists_ipv4: crowdsec-blacklists
blacklists_ipv6: crowdsec6-blacklists
nftables:
  ipv4:
    enabled: true
    set-only: false
    table: crowdsec
    chain: crowdsec-chain
    priority: -10 
  ipv6:
    enabled: true
    set-only: false
    table: crowdsec6
    chain: crowdsec6-chain
    priority: -10 
nftables_hooks:
  - input

Start it:

bash

systemctl enable crowdsec-firewall-bouncer
systemctl start crowdsec-firewall-bouncer
# Verify
systemctl status crowdsec-firewall-bouncer
nft list table inet crowdsec

Custom WordPress Scenarios (Optional)

For more aggressive WordPress protection, create custom scenarios:

bash

# WordPress login brute force - aggressive
cat > /etc/crowdsec/scenarios/wordpress-bf-aggressive.yaml << 'EOF'
type: leaky
name: custom/wordpress-bf-aggressive  
description: "Aggressive WordPress brute force detection"
filter: "evt.Meta.log_type == 'http_access-log' && evt.Parsed.request contains '/wp-login.php'"
leakspeed: "10s"
capacity: 3
groupby: evt.Meta.source_ip
blackhole: 4h
labels:
  remediation: true
  service: wordpress
  type: bruteforce
EOF
# XML-RPC attacks
cat > /etc/crowdsec/scenarios/wordpress-xmlrpc.yaml << 'EOF'
type: leaky
name: custom/wordpress-xmlrpc
description: "WordPress XML-RPC abuse"
filter: "evt.Meta.log_type == 'http_access-log' && evt.Parsed.request contains '/xmlrpc.php'"
leakspeed: "10s"
capacity: 5
groupby: evt.Meta.source_ip
blackhole: 24h
labels:
  remediation: true
  service: wordpress
  type: xmlrpc
EOF
systemctl reload crowdsec

NGINX Rate Limiting Layer

Add rate limiting independent of CrowdSec:

nginx

# /etc/nginx/nginx.conf - http block
limit_req_zone $binary_remote_addr zone=wp_login:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=wp_xmlrpc:10m rate=2r/m;
limit_req_zone $binary_remote_addr zone=general:10m rate=100r/s;
limit_conn_zone $binary_remote_addr zone=addr:10m;

In your WordPress site configs:

nginx

server {
    limit_conn addr 10;
    
    location ~ /wp-login\.php {
        limit_req zone=wp_login burst=2 nodelay;
        limit_req_status 429;
        # ... rest of PHP config
    }
    
    location = /xmlrpc.php {
        limit_req zone=wp_xmlrpc burst=1 nodelay;
        # Or just: deny all;
    }
    
    location / {
        limit_req zone=general burst=200 nodelay;
        try_files $uri $uri/ /index.php?$args;
    }
}

Test and reload: nginx -t && systemctl reload nginx

Testing

Simulate an attack to verify it works:

bash

# From your local machine
for i in {1..10}; do 
    curl -I https://yoursite.com/wp-login.php
    sleep 1
done
# Check if you got banned
cscli decisions list
# Should see your IP with the scenario that caught it
# Unban yourself
cscli decisions delete --ip YOUR.IP.ADDRESS

Daily Monitoring Commands

bash

# See active bans
cscli decisions list
# Recent alerts
cscli alerts list -l 20
# System metrics
cscli metrics
# Check bouncer status
cscli bouncers list
systemctl status crowdsec-firewall-bouncer
# View nftables rules
nft list table inet crowdsec

What About Fail2ban?

Decision: Remove it for HTTP, optionally keep for SSH.

If you want to keep fail2ban for SSH only:

ini

# /etc/fail2ban/jail.local
[DEFAULT]
ignoreip = 127.0.0.1/8 ::1 YOUR.MANAGEMENT.IP
[sshd]
enabled = true
maxretry = 3
bantime = 3600
# Disable all HTTP jails
[nginx-http-auth]
enabled = false
[nginx-noscript]
enabled = false
# ... etc

Or just remove it entirely:

bash

systemctl stop fail2ban
systemctl disable fail2ban
apt remove fail2ban

CrowdSec’s SSH collection handles brute force protection just fine.

The Cloudflare Worker Bouncer Question

CrowdSec has a Cloudflare Worker bouncer that blocks at the edge (before traffic hits your server). Sounds perfect, right?

Reality check:

  • Free tier limits: 100k requests/day total (across all workers), 1k KV writes/day
  • One Worker handles all domains (you don’t need one per domain)
  • Requires sync script to push decisions to Cloudflare KV storage
  • 5-minute sync delay vs. instant blocking with NFTables bouncer
  • Paid Workers plan ($5/month) removes these headaches

The pragmatic take: With 20 sites on Cloudflare free tier, the NFTables bouncer is simpler, faster (millisecond response), and has zero ongoing costs or rate limits. Your Hetzner firewall already restricts to Cloudflare IPs only, so edge blocking provides marginal additional value unless you’re seeing massive volumetric attacks.

If you upgrade to Workers paid plan, one Worker deployment with routes for all 20 domains is totally viable and adds real value for high-traffic sites.

Maintenance

Weekly:

  • Review cscli alerts list for attack patterns
  • Check cscli metrics for system health

Monthly:

  • Update: apt update && apt upgrade crowdsec
  • Update collections: cscli collections upgrade --all
  • Review and adjust scenario thresholds if needed

As needed:

  • Update Cloudflare IP ranges in NGINX when they announce changes (rare)

What We’re Not Doing

Wazuh: Considered it, but that’s a SIEM/XDR solution (detection and monitoring), not active blocking. It would add 2-5GB RAM overhead for log aggregation, file integrity monitoring, and compliance reporting. Overkill for straightforward WordPress hosting. Worth revisiting if you need audit trails for clients or GDPR compliance documentation.

Cloudflare paid features: Free tier Bot Fight Mode, 5 rate limiting rules, and High security level cover 80% of what paid features do. Upgrade when traffic justifies it.

The Bottom Line

What actually matters:

  1. NGINX logs real IPs (not Cloudflare IPs)
  2. CrowdSec reads those logs and learns from crowd intelligence
  3. NFTables bouncer blocks at kernel level
  4. NGINX rate limiting provides immediate throttling

What doesn’t matter:

  • Complex custom parsers (CrowdSec handles Cloudflare properly by default)
  • Manual IP range whitelists (already included in good-actors collection)
  • Trying to make fail2ban work in a proxy architecture
  • Edge blocking on free tier (nice-to-have, not critical)

This setup protects 20 WordPress sites with minimal resource overhead, crowd-sourced intelligence, and actual blocking where it counts. No security theater, no fighting against tools designed for different architectures.

Time to implement: 30-45 minutes
Ongoing maintenance: <10 minutes/week
Resource overhead: ~150MB RAM


Reference Links: