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-ratelimit plugin compiled in via xcaddy
  • 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 200 up to the threshold, then 429 for 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 key expression 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_proxies is 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:

  1. Build a custom image with xcaddy + caddy-ratelimit
  2. Add rate_limit blocks to your Caddyfile — and trusted_proxies if you're behind a CDN
  3. 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.