Protect Your Nginx WordPress Stack with CrowdSec on Debian 12 and 13
If you run WordPress sites behind nginx on Debian, you already know the reality: bots hammer /wp-login.php hundreds of times a day, exploit scanners probe for webshells and every request that reaches PHP costs CPU cycles. A single bot sweep can saturate your php-fpm worker pool and bring a server to its knees.
This guide walks through a two-layer defence: blocking common attack paths at the nginx level so they never reach PHP, then installing CrowdSec with the nftables firewall bouncer to automatically ban repeat offenders at the network level before they even reach nginx.
| Layer | What it does | Blocks at |
|---|---|---|
| Nginx security snippet | Returns 444 for known attack paths (wp-login.php, xmlrpc.php, exploit probes) | HTTP level, before PHP executes |
| CrowdSec + firewall bouncer | Detects attack patterns in logs, bans IPs via nftables, shares threat intel with community | Network level, before nginx |
Environment: Debian 12 (Bookworm) or Debian 13 (Trixie), nginx from the official nginx.org repository, WordPress with php-fpm. This guide uses the nftables firewall bouncer rather than the nginx Lua bouncer, because the Lua bouncer requires the Debian-packaged nginx and is incompatible with nginx.org packages.
Author note: I have been managing Linux servers and self-hosted infrastructure since the late 1990s. This guide comes from a real production incident where bot traffic caused a php-fpm worker storm that pushed load average above 8 on a 4-core server running 12 WordPress sites.
Prerequisites
You need a Debian 12 or Debian 13 server running nginx and WordPress with php-fpm. Root or sudo access is required. Both versions ship with nftables available by default.
Step 1: Harden Nginx Against Common WordPress Attacks
Every request to wp-login.php boots the full WordPress stack, loads all plugins, hits the database and consumes a php-fpm worker for the duration. Even if you use a hide-login plugin, the PHP execution still happens before the plugin redirects. Blocking these paths at the nginx level means zero PHP overhead.
Create a shared security snippet:
# /etc/nginx/snippets/wp-security.conf
# Block wp-login.php at nginx level, never reaches PHP
# Use a hidden login URL plugin (like WPS Hide Login) for admin access
location = /wp-login.php {
return 444;
}
# Block xmlrpc.php, heavily abused for brute force and DDoS amplification
location = /xmlrpc.php {
return 444;
}
# Deny access to wp-config.php
location ~* wp-config\.php {
deny all;
}
# Block WordPress user enumeration via author query string
if ($args ~* "^author=([0-9]+|{num:[0-9]+)") {
return 444;
}
# Block user enumeration via author pages
if ($request_uri ~ "/author/") {
return 444;
}
# Block user enumeration via sitemap
if ($request_uri ~ "wp-sitemap-users-[0-9]+.xml") {
return 444;
}
# Block REST API user enumeration
if ($request_uri ~ "/wp-json/wp/v2/users") {
return 444;
}
# Block common webshell and exploit probes
location ~* ^/(alfacgiapi|ALFA_DATA) {
return 444;
}
location ~* /wp-content/(plugins|themes)/(hellopress|fix|seotheme)/ {
return 444;
}
location = /wp-plain.php {
return 444;
}The return 444 directive closes the connection immediately without sending a response. This is more efficient than returning a 403 or 404, and gives scanners nothing to work with.
Include the snippet in each WordPress server block:
server {
listen 443 ssl;
server_name example.com;
root /var/www/example.com/htdocs;
include snippets/wp-security.conf;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
include fastcgi-php.conf;
}
}Test and reload:
nginx -t && systemctl reload nginxImportant: The wp-security.conf include must appear before the location ~ \.php$ block. Nginx processes exact-match locations (location =) before regex locations (location ~), so the explicit blocks for wp-login.php and xmlrpc.php will take priority over the general PHP handler regardless of order. However, placing the include first keeps the configuration readable and makes the intent clear.
Step 2: Disable WP-Cron via HTTP
Every page visit from a bot triggers WordPress cron execution via wp-cron.php. On a site receiving hundreds of bot hits, this means cron runs on nearly every request, stacking PHP workers unnecessarily.
Add this line to each site’s wp-config.php:
define('DISABLE_WP_CRON', true);Then set up a system cron job to run WordPress cron at a sensible interval:
# Run cron every 15 minutes, stagger per site to avoid simultaneous execution
crontab -u www-data -e*/15 * * * * /usr/bin/php /var/www/site1/htdocs/wp-cron.php > /dev/null 2>&1
3,18,33,48 * * * * /usr/bin/php /var/www/site2/htdocs/wp-cron.php > /dev/null 2>&1Step 3: Install CrowdSec
Add the CrowdSec repository and install the engine:
curl -s https://install.crowdsec.net | bash
apt update
apt install crowdsecInstall the collections relevant to a WordPress/nginx stack:
cscli collections install crowdsecurity/nginx
cscli collections install crowdsecurity/wordpress
cscli collections install crowdsecurity/base-http-scenarios
cscli collections install crowdsecurity/http-cve
cscli collections install crowdsecurity/linuxThese provide detection scenarios for WordPress brute force, plugin exploitation, HTTP scanning, known CVE exploitation attempts, and general Linux attack patterns.
Step 4: Configure Log Acquisition
CrowdSec needs to know where your nginx logs are. If the installer did not create the acquisition config automatically, create it:
# /etc/crowdsec/acquis.d/nginx.yaml
filenames:
- /var/log/nginx/*.log
labels:
type: nginx
source: fileRestart CrowdSec to pick up the configuration:
systemctl restart crowdsecVerify it is parsing your logs:
cscli metricsYou should see your nginx log files listed under “Acquisition Metrics” with lines being read and parsed.
Step 5: Install the Firewall Bouncer
The bouncer is what turns CrowdSec from a passive log analyser into an active blocker. The nftables firewall bouncer blocks offending IPs at the network level, before traffic reaches nginx:
apt install crowdsec-firewall-bouncer-nftablesVerify the bouncer registered with the CrowdSec API:
cscli bouncers listYou should see the firewall bouncer listed with a valid status and a recent API pull timestamp.
Verify it created nftables rules:
nft list ruleset | grep -A5 crowdsecYou should see crowdsec and crowdsec6 tables with input and forward chains for both IPv4 and IPv6.
Why the Firewall Bouncer Instead of the Nginx Bouncer?
The CrowdSec nginx bouncer (crowdsec-nginx-bouncer) uses the Lua module (libnginx-mod-http-lua) which depends on the Debian-packaged nginx ABI. If you run nginx from the official nginx.org repository, the Lua module will not install due to ABI incompatibility. This applies to both Debian 12 and Debian 13.
The firewall bouncer avoids this entirely by operating at the nftables level. The tradeoff is that you lose the ability to run CrowdSec AppSec (WAF) rules inline with nginx, but you gain network-level blocking which is more efficient for IP-based bans.
Step 6: Test the Setup
Add a test ban and verify it appears in nftables:
cscli decisions add --ip 198.51.100.1 --duration 1m --reason "test ban"
nft list ruleset | grep 198.51.100.1Clean up the test:
cscli decisions delete --ip 198.51.100.1Check for active decisions from real traffic:
cscli decisions listStep 7: Enrol in the CrowdSec Console (Optional)
The CrowdSec console gives you a centralised dashboard across all your servers and access to the community blocklist, which proactively blocks known bad IPs based on data from the entire CrowdSec network:
cscli console enroll <your-enrollment-key>Get your enrollment key from app.crowdsec.net.
Once enrolled, enable community blocklist reception:
cscli console enable console_management
systemctl restart crowdsecVerifying Everything Works
After the setup is complete, verify each layer:
# Check CrowdSec is running and parsing logs
cscli metrics
# Check the bouncer is active
cscli bouncers list
# Check active decisions (bans)
cscli decisions list
# Check nginx security snippet is loaded (should return 444, connection reset)
curl -sk -o /dev/null -w "%{http_code}" https://yourdomain.com/wp-login.php
# Expected: 000 (connection reset, curl shows 000 for 444 responses)
# Check xmlrpc is blocked
curl -sk -o /dev/null -w "%{http_code}" https://yourdomain.com/xmlrpc.php
# Expected: 000Summary
This two-layer approach eliminates the most common WordPress attack vectors:
The nginx security snippet stops known bad paths from reaching PHP, preventing php-fpm worker exhaustion from bot floods. Disabling WP-Cron via HTTP stops bots from triggering cron execution on every visit. CrowdSec with the firewall bouncer detects attack patterns across your logs and bans offending IPs at the network level via nftables, with optional community threat intelligence sharing.
The result is a WordPress stack that handles bot traffic with near-zero overhead instead of booting the full WordPress application for every malicious request.
FAQ
Yes. Use a hide-login plugin such as WPS Hide Login to create a custom login URL. The plugin changes the login path at the WordPress level, and since the custom URL does not match the exact /wp-login.php block in nginx, it passes through normally to PHP.
Yes, provided the container has the necessary privileges for nftables. On Incus, unprivileged containers can run nftables by default on most configurations. If you encounter permission errors, check that security.nesting is enabled on the container profile.
Create a whitelist in CrowdSec:
cscli decisions delete --ip YOUR.IP.HERE
cscli parsers install crowdsecurity/whitelistsThen edit /etc/crowdsec/parsers/s02-enrich/whitelists.yaml and add your IP to the whitelist. Restart CrowdSec after making changes.
The nginx bouncer integrates as a Lua module inside nginx and can inspect requests at the HTTP level, enabling AppSec (WAF) rules. The firewall bouncer operates at the nftables level and blocks entire IPs before traffic reaches nginx. The firewall bouncer is compatible with any nginx version but only supports IP-based blocking, not request-level inspection.
For most use cases, yes. CrowdSec covers the same log-based detection as fail2ban but adds community threat intelligence, a modern API, and a richer scenario system. If you run both, you can gradually migrate fail2ban jails to CrowdSec scenarios and eventually remove fail2ban.
