Caddy is an excellent reverse proxy and web server — automatic TLS, zero-config HTTPS, clean Caddyfile syntax. But there's one thing it doesn't ship with: HTTP rate limiting.
If you're running Caddy behind a public-facing VPS, this gap matters. Without rate limiting, your services are exposed to Layer 7 DDoS, credential stuffing, scraper abuse, and brute-force attacks at the application layer — none of which your firewall can touch.
This post walks through building a custom Caddy image that includes the caddy-ratelimit plugin, configuring rate limits per-site, and verifying the result with a real load test. I'll also flag a gotcha that bit me during testing — one that matters even more if you're running Caddy behind Cloudflare, like I do.
What We're Building
- A custom Caddy Docker image with the
caddy-ratelimitplugin compiled in viaxcaddy - Per-IP rate limiting: 20 requests per 10-second sliding window
- Applied to all site blocks (main site + www redirect)
- Verified with a real load test: rapid-fire requests return
200up to the threshold, then429for the rest
Why Caddy Core Doesn't Have Rate Limiting
Caddy's philosophy is minimal core + extensible plugins. Rate limiting is a plugin (github.com/mholt/caddy-ratelimit), not a built-in directive. It's maintained by Matthew Holt — Caddy's original author — so it's well-integrated and actively developed, though the module's own README still labels it a work in progress. Worth knowing going in, though I haven't hit any stability issues in normal use.
If you try to add rate_limit to a vanilla Caddyfile, Caddy will reject the config:
caddy validate --config Caddyfile
# Error: unknown directive 'rate_limit'
You need the plugin compiled into the Caddy binary. This post shows how.
Prerequisites
- Docker and Docker Compose installed
- A running Caddy stack (reverse proxy in front of your app)
- Basic familiarity with Caddyfile syntax
We're using Caddy v2.11.2 (official image, current stable as of this writing). Check the Caddy releases page for the latest tag before you build — the process itself is the same for any recent version.
Step 1: Create the Dockerfile
The official caddy image doesn't include the rate-limit plugin. We build a custom image using the -builder variant, which ships with xcaddy preinstalled.
Create a Dockerfile in your Caddy project directory:
FROM caddy:2.11.2-builder AS builder
RUN xcaddy build --with github.com/mholt/caddy-ratelimit
FROM caddy:2.11.2
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
Three things happen here: it pulls the Go-based builder image with xcaddy installed, compiles a new Caddy binary with the rate-limit plugin baked in, then copies just that binary into the slim production image — so you're not shipping the entire Go toolchain in your final container. On a modern machine, the build takes about 80 seconds.
Step 2: Update docker-compose.yml
Swap the image directive for a build directive:
# Before
caddy:
image: caddy:2.11.2
container_name: caddy
# After
caddy:
build:
context: .
dockerfile: Dockerfile
image: caddy-ratelimit:2.11.2
container_name: caddy
The image: caddy-ratelimit:2.11.2 line just tags the built image so you can reference it later. Ports, volumes, networks, and healthcheck all stay the same.
Step 3: Add Rate Limiting to Your Caddyfile
The rate_limit directive lives inside a site block. Place it before your reverse_proxy so requests hit the limit before they ever reach your backend.
https://your-site.com {
rate_limit {
zone default {
key {remote_host}
events 20
window 10s
}
}
reverse_proxy your-app:8000
}
Key parameters:
| Parameter | What it does | Our value |
|---|---|---|
key |
Identifier for the rate-limit bucket | {remote_host} (client IP) |
events |
Max requests allowed per window | 20 |
window |
Time window for the limit | 10s |
This is a sliding-window limiter, not a token-bucket with a separate burst allowance — events and window together define the whole limit. Keep that in mind when you see other rate-limiting tools (like nginx's) that expose a distinct burst parameter; caddy-ratelimit doesn't work that way.
If you have multiple site blocks (e.g., www redirect + main site), add rate_limit to each:
https://www.your-site.com {
rate_limit {
zone default {
key {remote_host}
events 20
window 10s
}
}
redir https://your-site.com{uri} permanent
}
https://your-site.com {
rate_limit {
zone default {
key {remote_host}
events 20
window 10s
}
}
reverse_proxy your-app:8000
}
The zone name (default) must be unique within the server block. If you need different thresholds per site, use different zone names.
Step 4: Build and Deploy
# Build the custom image
docker compose build
# Recreate the caddy container with the new image
docker compose up -d --force-recreate caddy
This causes a brief gap (a few seconds) on ports 80/443 while the container is recreated. Other containers on the same network stay up.
Step 5: Verify
Confirm the rate-limit module is loaded, validate the Caddyfile, and check the container is healthy:
docker exec caddy caddy list-modules | grep rate_limit
# http.handlers.rate_limit
docker exec caddy caddy validate --config /etc/caddy/Caddyfile
# Valid configuration
docker ps
# caddy Up 30 seconds (healthy)
Step 6: Load Test
Rather than ask you to take this on faith, I stood up a small live demo instance running the exact same config: ratelimit.sandbox99.cc. It's a bare static site behind the same rate_limit block, plus a custom error page so a 429 looks like an actual response instead of a blank browser error:
# Rate limit demo site (educational)
https://ratelimit.sandbox99.cc {
import base_headers
rate_limit {
zone ratelimit {
key {remote_host}
events 20
window 10s
}
}
root * /srv/ratelimit
file_server
handle_errors {
@429 expression {http.error.status_code} == 429
handle @429 {
root * /srv/ratelimit
rewrite * /429.html
file_server
}
}
}
Same events/window values as the walkthrough — just a real zone name (ratelimit) instead of default, a static file root instead of a reverse_proxy, and a handle_errors block that serves a friendly 429.html instead of Caddy's plain-text default.
Since this is a live public domain, there's no need for the --resolve trick from earlier — just hit it directly:
for i in $(seq 1 45); do
curl -sk -o /dev/null -w '%{http_code}\n' \
https://ratelimit.sandbox99.cc
done | sort | uniq -c
Expected output (roughly):
19 200
26 429
You should see something close to the first 20 requests succeed within the 10-second window, with everything after that returning 429 Too Many Requests. Don't be surprised if the exact split is off by one or two from the raw events number — request timing, curl's own overhead, and where in the window each request lands all shift the count slightly.
Feel free to run this against ratelimit.sandbox99.cc yourself — it's a public, throwaway demo, so hammer away.
Production Considerations
Choosing Your Threshold
The values we used (20 requests / 10 seconds) work for a typical web application. Adjust based on your traffic:
| Scenario | Events | Window | Notes |
|---|---|---|---|
| Static site / blog | 10–20 | 10s | Low tolerance for abuse |
| API with authenticated users | 30–60 | 10s | Higher for logged-in traffic |
| Login endpoint | 5–10 | 60s | Tight limit, prevents credential stuffing |
| General web app | 20–30 | 10s | Balanced (our choice) |
The key Field — and the Cloudflare/CDN Gotcha
{remote_host} uses the client's IP address, which is correct — but only if Caddy can actually see the real client IP.
If you're running Caddy behind a proxy or CDN (Cloudflare's orange-cloud proxying, for example — which is exactly what I run in front of my own Caddy stack), every request Caddy sees arrives from Cloudflare's edge IPs, not your visitors' real addresses. Without telling Caddy to trust and unwrap the forwarded headers, {remote_host} resolves to the same handful of Cloudflare IPs for everyone. That collapses your carefully-tuned per-IP limit into one shared bucket — a single busy visitor can trip the limit and start serving 429s to every other visitor behind the same edge node.
The fix is Caddy's trusted_proxies support, which tells Caddy which upstream IPs to trust for forwarded-IP headers:
https://your-site.com {
servers {
trusted_proxies static {
cloudflare
}
}
rate_limit {
zone default {
key {remote_host}
events 20
window 10s
}
}
reverse_proxy your-app:8000
}
With trusted_proxies configured, Caddy resolves {remote_host} to the real visitor IP instead of Cloudflare's edge IP, and your per-IP granularity actually holds. If you're not behind a CDN or reverse proxy layer, you can skip this — {remote_host} will already be the real client IP.
Other alternatives you'll see mentioned:
{http.request.header.X-Forwarded-For}— works, but it's spoofable unless you're certain nothing untrusted can reach Caddy directly. Not recommended on its own.- A custom
keyexpression combining IP + path, for per-endpoint limits.
Docker and UFW
If you're running Caddy in Docker, note that Docker's published ports bypass UFW's INPUT chain. Any port you publish with -p is reachable regardless of UFW rules.
We addressed this by adding DOCKER-USER iptables rules that allow only ports 80/443 from the public interface:
iptables -I DOCKER-USER -i ens3 -p tcp --dport 80 -j ACCEPT
iptables -I DOCKER-USER -i ens3 -p tcp --dport 443 -j ACCEPT
iptables -A DOCKER-USER -i ens3 -j DROP
This runs on every Docker restart via a systemd service. Without it, any future docker run -p 3306:3306 would expose MySQL to the internet — UFW can't block it.
Monitoring
Rate-limited requests log to Caddy's default logger. Watch for spikes in 429 responses:
docker logs caddy 2>&1 | grep '"status":429' | wc -l
A sudden burst of 429s may indicate an active attack or a misconfigured client.
What You Get
- Built-in protection against L7 DDoS, credential stuffing, and scraper abuse at the reverse-proxy layer
- Zero application changes — your backend never sees the blocked requests
- Per-IP granularity — legitimate users aren't affected by others' abuse (as long as
trusted_proxiesis set correctly behind a CDN) - Automatic recovery — clients can retry after the window expires (no permanent bans)
Wrapping Up
Caddy's plugin ecosystem fills the gaps in its core. Rate limiting is the most common one you'll need for a public-facing deployment. The process is straightforward:
- Build a custom image with
xcaddy+caddy-ratelimit - Add
rate_limitblocks to your Caddyfile — andtrusted_proxiesif you're behind a CDN - Deploy and verify
The entire change is about 15–20 lines of config. Your services get application-layer protection that firewalls and WAFs often miss.