Skip to content
Rezha Julio

caddy

Hardening Caddy with Coraza WAF and Fail2ban (And Almost Breaking Mastodon)

9 min read

How I added the OWASP Coraza WAF to Caddy, caught a bot sending 29,000 requests, avoided breaking Fediverse federation, and banned repeat offenders with Fail2ban.

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:

Dockerfile
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/caddy

Offloading 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):

Terminal window
# Add your local workstation as a remote builder context
docker context create remote-builder --docker "host=ssh://rezha@steammachine"
# Build on the workstation and stream the resulting image back to production
docker --context remote-builder build -t caddy-caddy:latest .
docker --context remote-builder save caddy-caddy:latest | docker --context default load

The 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:

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:

Caddyfile
(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 hits

A 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):

Terminal window
curl -I "https://rezhajulio.id/test?id=1%20UNION%20SELECT%201"
# HTTP/2 403 Forbidden

Normal 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 policy

Over 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+json
  • application/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.

Warning

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:

Caddyfile
(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:

Terminal window
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:

/etc/fail2ban/filter.d/caddy-coraza.conf
[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:

/etc/fail2ban/jail.d/caddy-coraza.conf
[caddy-coraza]
enabled = true
port = 80,443
filter = caddy-coraza
logpath = /var/log/caddy/coraza_blocked.log
backend = auto
action = iptables-multiport[name=caddy-coraza, port="80,443", protocol=tcp, chain=DOCKER-USER]
findtime = 10m
maxretry = 3
bantime = 1d
bantime.increment = true
bantime.factor = 2
bantime.maxtime = 52w
ignoreip = 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: 142

The 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:

  1. Added Cloudflare’s CIDR blocks to ignoreip in Fail2ban so proxy nodes can never be banned.
  2. Configured trusted_proxies in Caddy’s global block so Caddy extracts the real visitor IP from CF-Connecting-IP:
Caddyfile
{
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:

/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.


Related Posts


Previous Post
I Refuse to Let the Loop Ship My Code