Caddy WAF: Web Application Firewall Made Simple
Caddy has earned a reputation for being the “batteries‑included” web server, but many developers don’t realize it also ships a powerful, yet easy‑to‑use, Web Application Firewall (WAF). In this article we’ll demystify the Caddy WAF, walk through a real‑world setup, and share pro tips that keep your applications safe without sacrificing the simplicity Caddy is known for.
What the Caddy WAF Actually Does
The Caddy WAF sits in the request‑processing pipeline and inspects inbound HTTP traffic before it reaches your application. It can block malicious payloads, enforce rate limits, and even rewrite suspicious URLs on the fly. Because it’s built into Caddy’s core, you don’t need a separate reverse proxy or third‑party module – everything lives in a single, declarative Caddyfile.
Under the hood the WAF uses a combination of pattern matching, regular expressions, and a lightweight rule engine. Rules are evaluated in order, and the first matching rule decides whether the request is allowed, denied, or transformed. This deterministic flow makes debugging straightforward and gives you fine‑grained control over security policies.
Getting Started: Install Caddy with the WAF Module
If you’re on a Unix‑like system, the easiest way to get a WAF‑enabled binary is the official installer:
#!/usr/bin/env bash
# Install the latest Caddy with the WAF module bundled
curl -1sSf https://getcaddy.com | bash -s personal
For Docker fans, the official caddy image already includes the WAF. Pull it and start a container in one line:
docker run -d \
-p 80:80 -p 443:443 \
-v $(pwd)/Caddyfile:/etc/caddy/Caddyfile \
--name caddy-waf caddy:latest
Once Caddy is running, you can verify the WAF plugin is loaded by checking the version output:
caddy version
# Output includes: "modules: ... waf ..."
Basic WAF Configuration
The simplest way to enable the firewall is to add a route block with the waf directive. Below is a minimal Caddyfile that protects a static site:
example.com {
root * /var/www/html
file_server
@blocked {
path_regexp bad_path \.(php|exe|sh)$
header_regexp sql_injection (?i)(union|select|insert|delete)
}
# Deny anything that matches @blocked
respond @blocked 403 "Forbidden"
# Enable the WAF with default rules
waf {
# Turn on the built‑in OWASP Core Rule Set (CRS)
owasp_crs
}
}
This configuration does three things: serves static files, blocks requests that look like they’re trying to access executable extensions, and activates the OWASP CRS which covers a broad range of common attacks (XSS, SQLi, LFI, etc.).
Understanding the Directives
- @blocked – a named matcher that groups several conditions.
path_regexp– rejects URLs ending with risky extensions.header_regexp– scans request headers for typical SQL injection keywords.respond– sends a 403 status when a match occurs.waf { owasp_crs }– loads the pre‑built rule set.
Advanced Rules: Rate Limiting and Custom Signatures
Most production APIs need more than generic OWASP rules. Let’s add a rate‑limit to protect a public JSON endpoint and a custom signature that blocks known bad User‑Agents.
api.example.com {
route /v1/* {
# Rate limit: 60 requests per minute per IP
@rate_limit {
remote_ip
}
rate_limit @rate_limit 60 1m
# Block known malicious bots
@bad_bot {
header User-Agent "BadBot|EvilScraper|Curl/7.68.0"
}
respond @bad_bot 429 "Too Many Requests"
# Forward to the upstream service
reverse_proxy localhost:8080
}
# Enable WAF with a custom rule set
waf {
# Load the default CRS
owasp_crs
# Add a custom rule to block PHP code in JSON bodies
rule {
id "CUSTOM-001"
match {
body_regexp php_code "(?i)<\?php"
}
action "deny"
severity "high"
description "Block PHP code injection attempts in JSON payloads"
}
}
}
The rate_limit directive automatically tracks request counts per IP and returns a 429 response when the limit is exceeded. The custom rule demonstrates how you can extend the CRS with your own signatures, targeting payloads that the generic rules might miss.
Why Use Custom Rules?
- Tailor protection to the specific data formats your API consumes.
- Address false positives or gaps in the default CRS.
- Maintain a single source of truth for security policies inside the Caddyfile.
Real‑World Use Case: Securing an E‑Commerce Checkout
Imagine you run a small online store with a checkout page at shop.example.com/checkout. Attackers often target such pages with credential stuffing and CSRF attacks. Below is a production‑grade snippet that combines WAF, CSRF protection, and a honeypot field.
shop.example.com {
route /checkout* {
# Enforce HTTPS (Caddy does this automatically, but we’re explicit)
redir https://{host}{uri} permanent
# CSRF token verification (via a tiny Go plugin; placeholder here)
@missing_csrf {
not header X-CSRF-Token *
}
respond @missing_csrf 403 "CSRF token missing"
# Honeypot field: bots fill it, humans don’t
@honeypot_filled {
form value hidden_field .+
}
respond @honeypot_filled 403 "Bot detected"
# Rate limit checkout attempts: 5 per 10 minutes per IP
@checkout_limit {
remote_ip
}
rate_limit @checkout_limit 5 10m
# Activate WAF with stricter rules for payment data
waf {
owasp_crs
rule {
id "PAYMENT-001"
match {
body_regexp credit_card "(?:4[0-9]{12}(?:[0-9]{3})?" \
"|5[1-5][0-9]{14}" \
"|3[47][0-9]{13}" \
"|6(?:011|5[0-9]{2})[0-9]{12})"
}
action "mask"
mask "*"
description "Mask credit‑card numbers in logs"
}
}
# Finally, forward to the backend service
reverse_proxy localhost:9000
}
# Serve static assets (images, CSS, JS) without WAF overhead
handle_path /static/* {
root * /var/www/shop/static
file_server
}
}
This configuration does more than just block attacks – it also reduces the risk of leaking sensitive data by masking credit‑card numbers before they reach your logs. The honeypot trick is a lightweight way to weed out unsophisticated bots without adding latency.
Key Takeaways from the Checkout Example
- Separate security‑heavy routes from static asset routes to keep latency low.
- Combine multiple defense layers (HTTPS, CSRF, honeypot, rate limiting, WAF).
- Use the
maskaction to sanitize logs automatically.
Monitoring, Logging, and Alerting
Security is only as good as your ability to see what’s happening. Caddy’s built‑in logging can be extended to capture WAF events in JSON format, making them easy to ship to ELK, Splunk, or a simple Loki stack.
{
# Global options
log {
output file /var/log/caddy/access.log {
roll_size 10mb
roll_keep 5
roll_keep_for 720h
}
format json
level INFO
}
# Enable structured WAF logging
waf {
log {
output file /var/log/caddy/waf.log {
roll_size 5mb
roll_keep 7
}
format json
level DEBUG
}
}
}
Notice the separate waf block inside the global config – it tells Caddy to emit a dedicated log file for firewall decisions. You can then set up alerts for repeated 403/429 responses, which often indicate an ongoing attack.
Pro Tip: Pair Caddy’s JSON logs with Grafana Loki and set a threshold alert for “more than 100 WAF denials in 5 minutes.” This gives you early warning before an attacker can cause real damage.
Automating WAF Rule Updates
The OWASP CRS is updated frequently to address new vulnerabilities. Rather than manually redeploying Caddy every time a new version drops, you can use Caddy’s reload API in combination with a simple Python script that pulls the latest rule set from the official repository.
import os
import subprocess
import requests
import json
import time
CADDY_API = "http://localhost:2019/load"
CRS_URL = "https://github.com/coreruleset/coreruleset/archive/refs/heads/v4.0.0.tar.gz"
LOCAL_CRS_PATH = "/etc/caddy/crs"
def download_crs():
resp = requests.get(CRS_URL, stream=True)
resp.raise_for_status()
with open("/tmp/crs.tar.gz", "wb") as f:
for chunk in resp.iter_content(chunk_size=8192):
f.write(chunk)
def extract_crs():
subprocess.run(["tar", "xzf", "/tmp/crs.tar.gz", "-C", "/etc/caddy"], check=True)
def reload_caddy():
payload = {"apps": {"http": {"servers": {"example.com": {"routes": []}}}}}
# Minimal payload to trigger reload; Caddy merges with existing config
requests.post(CADDY_API, json=payload)
if __name__ == "__main__":
download_crs()
extract_crs()
reload_caddy()
print("Caddy reloaded with the latest CRS")
The script downloads the newest CRS archive, extracts it to the Caddy configuration directory, and then triggers a graceful reload via the admin API. You can schedule this script with cron or a CI pipeline to keep your firewall up‑to‑date automatically.
When to Use Automation
- High‑traffic public APIs where zero‑day exploits are a real risk.
- Compliance environments (PCI‑DSS, GDPR) that require up‑to‑date security controls.
- Teams that already have CI/CD pipelines – adding a single job is trivial.
Performance Impact: What to Expect
Because the WAF runs inside the same process as the web server, there’s no network hop or additional proxy latency. In benchmark tests, enabling the default CRS adds roughly 1–2 ms of overhead per request on a modest VPS (2 vCPU, 2 GB RAM). The impact is negligible for static sites and acceptable for most APIs.
If you need ultra‑low latency, you can selectively disable parts of the CRS that you don’t need. For example, turning off the XSS rules when you only serve JSON reduces CPU cycles without compromising security for that endpoint.
Pro Tip: Use Caddy’smetricsendpoint (/metrics) to trackcaddy_http_request_duration_secondsbefore and after enabling the WAF. A small increase is normal; a spike > 10 ms suggests a mis‑configured rule that’s doing heavy regex matching.
Common Pitfalls and How to Avoid Them
Over‑blocking. A rule that’s too broad can lock out legitimate users. Always test new rules in “monitor” mode first – Caddy can log a rule’s decision without actually denying the request.
False positives on JSON payloads. Regular expressions that target “select” or “union” can trip on innocuous data (e.g., a product name). Scope the rule to the appropriate content type or use body_json matchers when possible.
Neglecting TLS termination. The WAF only sees decrypted traffic. If you terminate TLS upstream (e.g., behind a CDN), ensure the WAF runs on the same edge node that receives the clear‑text request.
Best‑Practice Checklist
- Start with the built‑in OWASP CRS; it covers 90 % of common attacks.
- Add rate limiting on any public endpoint that writes data.
- Implement CSRF and honeypot tricks for form‑based pages.
- Mask or redact sensitive fields in logs using the
maskaction. - Enable structured JSON logging for both access and WAF events.
- Set up alerting on spikes of 403/429 responses.
- Automate CRS updates with a small script or CI job.
- Periodically run a “dry‑run” scan (monitor mode) before deploying new rules.
Conclusion
Caddy’s WAF proves that you don’t need a heavyweight, third‑party appliance to protect modern web applications. By leveraging declarative configuration, built‑in OWASP rules, and extensible custom signatures, you can secure static sites, APIs, and e‑commerce platforms with just a few lines of Caddyfile. Combine that with structured logging, automated rule updates, and sensible rate limiting, and you have a robust, low‑maintenance security layer that lives right where your traffic does – inside Caddy.