If you host public-facing websites, take a look at your access logs right now.
Within minutes, you will find botnets fuzzing for .env files, scanners probing ancient WordPress plugin paths, and automated scripts testing SQL injection strings against endpoints that don’t even have a database.
I run around 20 self-hosted services behind Caddy. WordPress, Ghost, Vaultwarden, Miniflux, Linkding, AI Councils, Ente, and two Mastodon instances. Caddy handles reverse proxying and automatic HTTPS without breaking a sweat. But vanilla Caddy doesn’t inspect request bodies for exploits. If someone sends an SQL injection string in a query parameter, Caddy forwards it straight to the backend.
I wanted a Web Application Firewall (WAF) to catch those probes before they reached my apps. Traditional setups usually put ModSecurity inside Nginx or offload everything to Cloudflare. I wanted to keep things self-contained and native to Caddy.
That led me to OWASP Coraza.
Adding Coraza to Caddy with xcaddy
Coraza is a WAF written in Go. It implements the ModSecurity SecLang syntax and ships with the OWASP Core Rule Set (CRS v4) embedded directly into the binary.
Because Caddy can be extended with Go plugins, adding Coraza requires building Caddy with the official plugin (github.com/corazawaf/coraza-caddy/v2) using xcaddy.
Here is the multi-stage Dockerfile I use:
FROM caddy:2.11-builder AS builder
RUN xcaddy build v2.11.4 --with github.com/corazawaf/coraza-caddy/v2 --with github.com/caddy-dns/porkbun --with github.com/caddy-dns/cloudflare --with pkg.jsn.cam/caddy-defender --with github.com/sablierapp/sablier/plugins/caddy --with github.com/gsmlg-dev/caddy-admin-ui@main --with github.com/ueffel/caddy-brotli
FROM caddy:2.11
COPY --from=builder /usr/bin/caddy /usr/bin/caddyOffloading the Build with a Remote Docker Context
Building Caddy with six plugins takes significant memory and CPU. If you run a budget VPS with 1 GB or 2 GB of RAM, the Go compiler will easily trigger the Linux Out-Of-Memory (OOM) killer and crash running containers.
Instead of compiling on the production VPS, I use Docker contexts over SSH to build the image on my local machine (steammachine):
# Add your local workstation as a remote builder contextdocker context create remote-builder --docker "host=ssh://rezha@steammachine"
# Build on the workstation and stream the resulting image back to productiondocker --context remote-builder build -t caddy-caddy:latest .docker --context remote-builder save caddy-caddy:latest | docker --context default loadThe heavy Go compilation happens on my desktop in seconds, and only the finished image gets transferred to production.
Once baked into the binary, you enable the WAF globally in your Caddyfile:
{ order coraza_waf first ...}That directive tells Caddy to run incoming traffic through Coraza before routing it to any reverse proxy.
The Golden Rule: Detection Only First
The quickest way to make your users hate you is turning a WAF directly into blocking mode on day one.
The OWASP Core Rule Set checks for thousands of attack patterns. If your users post Markdown containing code blocks, save drafts with HTML tags, or upload photos, a strict WAF will block them instantly.
I set up two snippets in my Caddyfile so I could toggle each site between passive monitoring and active blocking:
(coraza-waf-block) { coraza_waf { load_owasp_crs directives ` Include @coraza.conf-recommended Include @crs-setup.conf.example Include @owasp_crs/*.conf SecRuleEngine On SecAuditEngine RelevantOnly SecAuditLog /var/log/caddy/coraza_blocked.log SecAuditLogFormat JSON SecAuditLogParts ABIJDEFHKZ ` }}
(coraza-waf-monitor) { coraza_waf { load_owasp_crs directives ` Include @coraza.conf-recommended Include @crs-setup.conf.example Include @owasp_crs/*.conf SecRuleEngine DetectionOnly SecAuditEngine RelevantOnly SecAuditLog /var/log/caddy/coraza_monitor.log SecAuditLogFormat JSON SecAuditLogParts ABIJDEFHKZ ` }}Notice the separate log paths: coraza_blocked.log and coraza_monitor.log.
This separation matters. If thousands of scanner requests hit your blocked WordPress site, you do not want that noise drowning out your monitoring data on other services.
The 29,000-Request Attacker
I started with a mix of production sites in monitoring mode: my WordPress blog (rezhajulio.id), a Ghost publication (farahclara.id), and a couple of internal web services.
After five days, I parsed the 1.77 GB audit log (61,339 events). The breakdown:
Total Events: 61,339
Hits by Domain: rezhajulio.id: 44,379 (72.3%) farahclara.id: 11,617 (18.9%) internal services: 5,343 (8.7%)
Top Offending IP: 62.60.130.240 (Spaceship Networks): 29,065 hitsA single IP address had hammered /wp-login.php 29,065 times in five days trying credential stuffing. It triggered Rule 920450 (X-Method-Override tampering) and Rule 949110 (Anomaly Score Limit Exceeded).
Meanwhile, every legitimate post edit, image upload, and visitor session passed through with zero false positives.
I promoted those four sites to active blocking (coraza-waf-block):
curl -I "https://rezhajulio.id/test?id=1%20UNION%20SELECT%201"# HTTP/2 403 ForbiddenNormal traffic returned 200 OK. Malicious queries were stopped cold.
How Monitoring Mode Saved Mastodon Federation
Next, I expanded monitoring to my other services: Vaultwarden, Miniflux, Linkding, and my two Mastodon instances (pegelinux.top and fedi.my.id).
Three days later, I checked coraza_monitor.log (51,261 events, 501 MB).
What I found was alarming:
Top Hosts in Monitor Log: pegelinux.top: 28,173 (55.0%) fedi.my.id: 18,076 (35.3%)
Top Triggered Rule: Rule 920420 (67,870 hits): Request content type is not allowed by policyOver 90% of all logged events came from Mastodon.
What Happened?
In the Fediverse, servers communicate by posting status updates to each other’s /inbox endpoints. Those ActivityPub requests use specific content types:
application/activity+jsonapplication/ld+json; profile="https://www.w3.org/ns/activitystreams"
By default, OWASP rule 920420 only permits standard web content types (application/json, application/x-www-form-urlencoded, etc.). It did not recognize ActivityPub, so every incoming federated post from other instances was flagged as an attack.
If I had enabled active blocking right away, Mastodon federation would have silently broken across both domains. Posts, boosts, and mentions from the rest of the Fediverse would have bounced with HTTP 403.
Because the sites were in DetectionOnly mode, Caddy logged the violation without dropping the requests. Not a single federated post was lost.
The Fix
To resolve this, I created a Mastodon-specific snippet in Caddyfile using CRS hook 900220 to whitelist ActivityPub content types before the main rules run:
(coraza-waf-mastodon) { coraza_waf { load_owasp_crs directives ` Include @coraza.conf-recommended Include @crs-setup.conf.example SecAction "id:900220,phase:1,nolog,pass,t:none,setvar:'tx.allowed_request_content_type=|application/x-www-form-urlencoded| |multipart/form-data| |multipart/related| |text/xml| |application/xml| |application/soap+xml| |application/json| |application/cloudevents+json| |text/plain| |application/activity+json| |application/ld+json|'" Include @owasp_crs/*.conf SecRuleEngine DetectionOnly SecAuditEngine RelevantOnly SecAuditLog /var/log/caddy/coraza_monitor.log SecAuditLogFormat JSON SecAuditLogParts ABIJDEFHKZ ` }}Testing the endpoint again:
curl -i -X POST "https://pegelinux.top/inbox" \ -H "Content-Type: application/activity+json" \ -d '{"@context": "https://www.w3.org/ns/activitystreams", "type": "Create"}'The request reached the Mastodon backend with zero WAF anomaly points. Federation stayed intact.
Why 403 Isn’t Enough: Adding Fail2ban
Blocking an exploit with HTTP 403 Forbidden protects your application, but it still costs resources.
For every malicious request, Caddy has to complete the TLS handshake, parse HTTP headers, and evaluate the regex ruleset. When an attacker sends 29,000 requests, your server still wastes CPU and network bandwidth.
The better approach is to hand those IPs over to the Linux firewall (iptables) so packets get dropped before they even reach Caddy.
1. The Fail2ban Filter
Because Coraza writes structured JSON logs, extracting the attacker’s IP is straightforward:
[Definition]failregex = ^.*"client_ip":"<HOST>".*$ignoreregex =2. The Jail on DOCKER-USER
On Linux, Docker bypasses standard INPUT iptables rules and forwards traffic directly to containers. To block traffic to Dockerized services like Caddy, Fail2ban has to insert rules into the DOCKER-USER chain:
[caddy-coraza]enabled = trueport = 80,443filter = caddy-corazalogpath = /var/log/caddy/coraza_blocked.logbackend = autoaction = iptables-multiport[name=caddy-coraza, port="80,443", protocol=tcp, chain=DOCKER-USER]findtime = 10mmaxretry = 3bantime = 1dbantime.increment = truebantime.factor = 2bantime.maxtime = 52wignoreip = 127.0.0.1/8 ::1 167.253.158.187 173.245.48.0/20 103.21.244.0/22 ...How the Bans Work:
- 3 strikes: Triggering 3 WAF blocks in 10 minutes results in a 24-hour ban.
- Exponential escalation (
bantime.increment = true): If the bot returns after expiration, the ban doubles: 2 days, 4 days, up to 52 weeks. - The recidive jail: If an IP is banned 3 times within 24 hours across any jail, our
[recidive]jail locks it out for 1 year.
Within 72 hours of enabling this jail, Fail2ban banned 141 attacker IPs directly at the firewall level.
Status for the jail: caddy-coraza|- Filter| |- Total failed: 1620`- Actions |- Currently banned: 40 |- Total banned: 142The Cloudflare Gotcha
One of my domains (www.farahclara.id) sits behind Cloudflare’s proxy. Shortly after turning on the jail, an attacker probed that domain. Coraza saw Cloudflare’s egress node as the client_ip, and Fail2ban almost banned a Cloudflare IP.
Two settings fixed this:
- Added Cloudflare’s CIDR blocks to
ignoreipin Fail2ban so proxy nodes can never be banned. - Configured
trusted_proxiesin Caddy’s global block so Caddy extracts the real visitor IP fromCF-Connecting-IP:
{ servers { trusted_proxies static 173.245.48.0/20 103.21.244.0/22 103.22.200.0/22 103.31.4.0/22 141.101.64.0/18 108.162.192.0/18 190.93.240.0/20 188.114.96.0/20 197.234.240.0/22 198.41.128.0/17 162.158.0.0/15 104.16.0.0/13 104.24.0.0/14 172.64.0.0/13 131.0.72.0/22 2400:cb00::/32 2606:4700::/32 2803:f800::/32 2405:b500::/32 2405:8100::/32 2a06:98c0::/29 2c0f:f248::/32 }}Log Rotation with copytruncate
Caddy’s access logs roll automatically, but Coraza’s audit engine writes directly to disk via standard file descriptors without built-in rotation.
To prevent disk bloat, I added /etc/logrotate.d/caddy-coraza:
/var/log/caddy/coraza_*.log { daily missingok rotate 14 compress delaycompress notifempty copytruncate maxsize 100M}The critical setting is copytruncate. It copies the active log and truncates the existing file to zero bytes in place. Because the file descriptor stays valid, the running Caddy container keeps writing without needing a restart.
Where Things Stand
Today, the setup protects 15 domains:
- Active Blocking (12 domains): WordPress, Ghost, Vaultwarden, Miniflux, Linkding, Syncribullet, AI Councils, Uptime Kuma, Memos, and other internal services.
- Tuned Monitoring (3 domains): Both Mastodon instances and my Ente photo storage backend.
If you run Caddy and want real exploit protection, Coraza works remarkably well. Just take the time to run in monitoring mode first, especially if you host federated or API-heavy applications. Your logs will tell you exactly what you need to fix before you start dropping packets.