Introduction
CrowdSec's firewall bouncers are great at blocking brute-force attackers at the IP layer, but they cannot see inside an HTTP request. A bot hitting your website with /?id=1 UNION SELECT ... is just "an IP sending traffic" to the host firewall, which means the attack goes straight through to your application until enough log lines have been generated for a scenario to fire.
CrowdSec AppSec is a different component of the same engine. It is an in-process Web Application Firewall (WAF): your reverse proxy forwards each request to AppSec before serving it, and AppSec decides whether to let the request through, block it, or trigger a CAPTCHA. AppSec ships with support for the OWASP Core Rule Set (CRS), the de-facto open-source rule set used by ModSecurity and many commercial WAFs.
In this tutorial, you turn a server into a public WAF using:
- Nginx as the reverse proxy (with the
crowdsec-nginx-bouncermodule) - CrowdSec as the security engine (already battle-tested for SSH and log-based protection)
- The CrowdSec AppSec component, listening on
127.0.0.1:7422 - The OWASP CRS rule set, installed as inband rules so matching requests get blocked in real time
By the end of the tutorial, requests containing SQL injection, XSS, path traversal, or Log4Shell payloads are answered with HTTP 403 by Nginx before ever reaching your backend application.
Prerequisites
- A server (this tutorial was tested on the Hetzner cloud server CX23, 2 vCPU / 4 GB RAM)
- Ubuntu 24.04 or 26.04 LTS
- A user with
sudoprivileges (the commands in this guide are prefixed withsudo; if you are already logged in as root, you can drop the prefix) - A firewall (e.g. Hetzner Cloud Firewall, or
iptables/nftables) that allows ports:
22 (SSH) · 80 (HTTP) · 443 (HTTPS) - Basic familiarity with Nginx virtual hosts
If you plan to put this in front of an existing web application, you can install everything on the same server as the app or on a dedicated reverse-proxy host. The latter is recommended on busy sites because CrowdSec AppSec does real per-request work.
Step 1 - Install CrowdSec
Update the package index and install CrowdSec using the upstream repository script:
sudo apt update
sudo apt install -y curl gnupg ca-certificates jq
curl -sS https://install.crowdsec.net | sudo bash
sudo apt install -y crowdsecVerify:
sudo cscli version
systemctl is-active crowdsecYou should see a version string of v1.7.x or newer and active for the systemd unit.
If you want a deeper introduction to the base CrowdSec concepts (collections, parsers, scenarios), check the existing community tutorial "Installing and setting up CrowdSec to protect SSH". The rest of this tutorial focuses specifically on the AppSec component.
Step 2 - Install Nginx and the CrowdSec Nginx bouncer
Install the Nginx web server and the CrowdSec Lua bouncer that hooks into it:
sudo apt install -y nginx crowdsec-nginx-bouncerThe package manager pulls in the Nginx Lua module dependencies automatically. Check that Nginx is running:
systemctl is-active nginx
nginx -vAt the time of writing, Ubuntu 24.04 ships Nginx 1.24. On Ubuntu 26.04 it is 1.28.
The bouncer installs two important files:
/etc/crowdsec/bouncers/crowdsec-nginx-bouncer.conf- bouncer configuration (API URL, API key, and AppSec settings)/etc/nginx/conf.d/crowdsec_nginx.conf- the Lua hooks that intercept every HTTP request and check it against CrowdSec
Bouncer registration is done automatically by the post-install script. Confirm the registration:
sudo cscli bouncers listYou should see an entry of type crowdsec-nginx-bouncer with Valid set to ✔️.
Step 3 - Enable the AppSec listener in CrowdSec
The AppSec component is loaded as an acquisition source, just like file or journald sources. Create an acquisition file that tells CrowdSec to start an AppSec listener on 127.0.0.1:7422 using a config we will define in the next step:
sudo tee /etc/crowdsec/acquis.d/appsec.yaml > /dev/null <<'EOF'
appsec_config: custom/crs-inband
labels:
type: appsec
listen_addr: 127.0.0.1:7422
source: appsec
EOFNothing will reload yet because custom/crs-inband does not exist. We create it in the next step.
Step 4 - Install OWASP CRS and create an inband rules config
Install the AppSec base collections plus the OWASP Core Rule Set:
sudo cscli collections install crowdsecurity/appsec-virtual-patching
sudo cscli collections install crowdsecurity/appsec-generic-rules
sudo cscli collections install crowdsecurity/appsec-crsThe CRS collection pulls several hundred rules covering SQL injection, cross-site scripting, local file inclusion, remote code execution, common scanner signatures, and more.
The CRS collection only registers those rules as out-of-band by default, meaning they generate alerts but do not block the request in real time. For a WAF that stops attacks, you want the rules to run inband. Create a custom AppSec config:
sudo tee /etc/crowdsec/appsec-configs/crs-inband.yaml > /dev/null <<'EOF'
name: custom/crs-inband
default_remediation: ban
inband_rules:
- crowdsecurity/base-config
- crowdsecurity/crs
- crowdsecurity/vpatch-*
outofband_rules:
- crowdsecurity/appsec-generic-test
EOFbase-configsets sane defaults (maximum request size, required headers, ...)crowdsecurity/crsis the whole OWASP CRS bundlevpatch-*are virtual patches for specific CVEs (Log4Shell, ConnectWise, Fortinet, and many others)- The out-of-band section only keeps the generic test scenario so you can later verify the pipeline from the CrowdSec Console if you enroll the instance
Restart CrowdSec so it loads the new acquisition source and config:
sudo systemctl restart crowdsec
sleep 3
sudo ss -tlnp | grep 7422The last command should show that crowdsec is listening on 127.0.0.1:7422. You can also tail the log to see the rule counts (the -i flag makes grep case-insensitive so it matches both Appsec and appsec):
sudo journalctl -u crowdsec --since "5 minutes ago" | grep -iE "inband|appsec"You should see something similar to:
msg="Loaded 185 inband rules" component=appsec_config
msg="Appsec listening on 127.0.0.1:7422"If the output is empty, CrowdSec may have logged the startup messages outside the time window, or your version may phrase them differently. As a fallback, drop the time filter and inspect the most recent CrowdSec log lines directly:
sudo journalctl -u crowdsec -n 50 --no-pagerLook for lines that mention loading inband rules and a listener bound to
127.0.0.1:7422. As long as the previousss -tlnp | grep 7422command showscrowdseclistening on that port, the AppSec listener is up and you can move on to Step 5.
Step 5 - Point the Nginx bouncer at the AppSec listener
The bouncer knows how to forward requests to AppSec, but you need to tell it where to go. Edit /etc/crowdsec/bouncers/crowdsec-nginx-bouncer.conf:
sudo sed -i 's|^APPSEC_URL=.*|APPSEC_URL=http://127.0.0.1:7422|' \
/etc/crowdsec/bouncers/crowdsec-nginx-bouncer.conf
sudo sed -i 's|^ALWAYS_SEND_TO_APPSEC=.*|ALWAYS_SEND_TO_APPSEC=true|' \
/etc/crowdsec/bouncers/crowdsec-nginx-bouncer.conf
sudo grep -E '^APPSEC|^ALWAYS_SEND' \
/etc/crowdsec/bouncers/crowdsec-nginx-bouncer.confAPPSEC_URL points to the listener you started in Step 4. ALWAYS_SEND_TO_APPSEC=true forces the bouncer to consult AppSec on every request while you are testing. On a production server with heavy traffic you can set it back to false later, in which case the bouncer only forwards requests coming from IPs that are already suspicious according to other CrowdSec scenarios.
The bouncer is an init_by_lua module, so Nginx must be restarted (not just reloaded) to reinitialize the Lua VM:
sudo systemctl restart nginx
sudo tail -5 /var/log/nginx/error.logYou should see a line similar to:
[lua] crowdsec.lua:229: init(): APPSEC is enabled on '127.0.0.1:7422'
[Crowdsec] Initialisation doneStep 6 - Verify the WAF with simulated attacks
The default Nginx site on Ubuntu serves /var/www/html/index.nginx-debian.html on port 80. Send it a handful of requests containing payloads that the OWASP CRS should detect. Replace <SERVER_IP> with your server's public IPv4 address:
SERVER=http://<SERVER_IP>
# A harmless request - should return 200
curl -sS -o /dev/null -w "normal: HTTP %{http_code}\n" "$SERVER/"
# SQL injection - should return 403
curl -sS -o /dev/null -w "sqli: HTTP %{http_code}\n" \
"$SERVER/?id=1+UNION+SELECT+1,2,3--"
# Cross-site scripting - should return 403
curl -sS -o /dev/null -w "xss: HTTP %{http_code}\n" \
"$SERVER/?q=%3Cscript%3Ealert(1)%3C/script%3E"
# Path traversal / local file inclusion - should return 403
curl -sS -o /dev/null -w "traversal: HTTP %{http_code}\n" \
"$SERVER/?f=../../etc/passwd"
# Log4Shell (CVE-2021-44228) payload - should return 403
curl -sS -o /dev/null -w "log4shell: HTTP %{http_code}\n" \
"$SERVER/?x=%24%7Bjndi%3Aldap%3A//x/y%7D"The expected output is:
normal: HTTP 200
sqli: HTTP 403
xss: HTTP 403
traversal: HTTP 403
log4shell: HTTP 403Every blocked request matches one or more CRS rules on the server side. If you run the tests from a public IP, CrowdSec writes an alert with the anomaly score per category (sql_injection, xss, lfi, rce) visible via cscli alerts list.
Step 7 - Observe metrics and triggered rules
CrowdSec exposes a rich set of metrics through cscli. The AppSec section shows how many requests were processed, how many were blocked, and which rules fired:
sudo cscli metricsLook for the following tables in the output:
- Appsec Metrics - processed / blocked counters per listener
- Appsec 'listener/' Rules Metrics - the CRS rule IDs that were triggered (for example
942100for SQL injection,941100for XSS,930100for path traversal) - Local API Alerts - one line per anomaly-score block, including the attack category
You can also list the alerts directly:
sudo cscli alerts listEach entry includes the client IP, the country, the AS, the triggered categories, and the anomaly score. The default CRS anomaly threshold is 5 for a single request, but you can tune it per category if you see false positives.
Step 8 - Reduce false positives with CRS exclusion plugins
The OWASP CRS is intentionally strict. A vanilla WordPress admin or a Nextcloud sharing URL will trigger some of the rules even though the request is legitimate. CrowdSec ships a set of "exclusion plugin" collections that disable only the CRS rules that are known to produce false positives for a given application. They are pre-built from the upstream CRS plugin repository.
Install the ones that match the applications on your server:
# WordPress
sudo cscli collections install crowdsecurity/appsec-crs-exclusion-plugin-wordpress
# Nextcloud
sudo cscli collections install crowdsecurity/appsec-crs-exclusion-plugin-nextcloud
# phpMyAdmin
sudo cscli collections install crowdsecurity/appsec-crs-exclusion-plugin-phpmyadminOther available plugins include Drupal, phpBB, XenForo, DokuWiki, and cPanel. Add the plugin as an inband_rules entry in the config file you created in Step 4 (/etc/crowdsec/appsec-configs/crs-inband.yaml, the file backing the custom/crs-inband config name), then restart CrowdSec:
inband_rules:
- crowdsecurity/base-config
- crowdsecurity/crs
- crowdsecurity/crs-exclusion-plugin-wordpress
- crowdsecurity/vpatch-*sudo systemctl restart crowdsecFor your own web application, the simplest path is usually to put AppSec in "detection-only" mode at first, let it run on real traffic for a few days with ALWAYS_SEND_TO_APPSEC=true and default_remediation: allow, review the alerts in cscli alerts list, and only switch back to default_remediation: ban once the false positives are understood.
Step 9 - Operations and next steps
A few commands cover most of the operational work around the WAF:
# Rule triggers and global numbers
sudo cscli metrics
# Most recent blocks (top = newest)
sudo cscli alerts list --limit 20
# Details for a single alert (including the exact request body and the rules it hit)
sudo cscli alerts inspect <ALERT_ID>
# Restart both pieces after a config change
sudo systemctl restart crowdsec
sudo systemctl restart nginx
# Keep the rules and AppSec plugins up to date
sudo cscli hub update && sudo cscli hub upgrade
sudo systemctl restart crowdsecTwo natural next steps:
- Put Nginx behind HTTPS with Let's Encrypt via acme.sh and add a proper
server_nameblock for your domain. - Add the
crowdsec-firewall-bouncer-nftablespackage so that repeated offenders are also blocked at the host firewall layer, not just at the HTTP layer. Once they accumulate enough AppSec alerts, CrowdSec will create abandecision and the firewall bouncer will drop their packets for four hours by default.
Conclusion
Your server is now running a proper application-layer firewall:
- Nginx handles every inbound HTTP request.
- The
crowdsec-nginx-bouncersends each request to CrowdSec AppSec before passing it to the upstream application. - CrowdSec AppSec evaluates the request against the OWASP Core Rule Set and virtual patches, blocks matches with HTTP
403, and stores the event for observability. - The same CrowdSec agent still protects the SSH service and anything else whose logs it parses, and it can correlate WAF events with IP-level brute-force behavior.
Compared with legacy WAF stacks based on ModSecurity, this setup stays entirely on Hetzner Cloud, requires no external SaaS subscription, and benefits from CrowdSec's community threat intelligence feed if you enroll the instance at app.crowdsec.net.