A production-ready Caddy web server Docker image with the mholt/caddy-ratelimit plugin pre-installed for HTTP rate limiting capabilities.
- Base: Official Caddy 2 image
- Rate Limiting: mholt/caddy-ratelimit plugin included
- Multi-arch: Supports both amd64 and arm64
- Auto-updates: Weekly automated builds on Sundays to get latest security patches
- Production-ready: Built with xcaddy using stable, tested versions
- Quantified verification: Every build runs 7 load-test scenarios with hard tolerance bands and cross-checks the plugin's own Prometheus counters against externally-observed behaviour (see Verified behavior)
Pull the image:
docker pull ghcr.io/coders-compass/caddy-with-ratelimit:latestCreate a docker-compose.yml:
services:
caddy:
image: ghcr.io/coders-compass/caddy-with-ratelimit:latest
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy/data:/data
- ./caddy/config:/config
restart: unless-stoppedCreate a Caddyfile:
{
# Order directive is important for non-standard modules
order rate_limit before basicauth
}
example.com {
# Rate limit based on client IP
rate_limit {
zone dynamic_zone {
key {http.request.remote.host}
events 10
window 1m
}
}
# Your backend or static files
reverse_proxy backend:8080
}The rate_limit directive supports various configurations:
By IP address (most common):
rate_limit {
zone by_ip {
key {http.request.remote.host}
events 100
window 1m
}
}By header (e.g., API key):
rate_limit {
zone by_api_key {
key {http.request.header.X-API-Key}
events 1000
window 1h
}
}Static (global across all clients):
rate_limit {
zone global {
key static
events 10000
window 1m
}
}Multiple zones:
rate_limit {
zone strict_endpoint {
key {http.request.remote.host}
events 5
window 1m
}
zone general {
key {http.request.remote.host}
events 100
window 1m
}
}For multi-instance deployments sharing rate limit state:
{
order rate_limit before basicauth
# Configure storage backend (e.g., Redis)
storage redis {
address "redis:6379"
}
}
example.com {
rate_limit {
zone shared {
key {http.request.remote.host}
events 100
window 1m
}
distributed {
read_interval 100ms
write_interval 100ms
}
}
reverse_proxy backend:8080
}When rate limit is exceeded:
- HTTP Status:
429 Too Many Requests - Header:
Retry-After(seconds until rate limit resets)
Every push, PR, and weekly rebuild runs an automated load-test suite against the built image and asserts the following with hard tolerance bands. Any deviation outside the bands fails CI.
| Scenario | What it verifies |
|---|---|
| Light load (under limit) | Under-limit traffic returns HTTP 200; no false positives. |
| Burst at limit | Exactly at the configured budget, every request succeeds. |
| Burst over limit | A 10-request burst against a 5/10s zone returns 4–6 × 200 + 4–6 × 429. |
| Sustained 30s @ 1 rps | Sustained over-limit traffic returns a mix of 200/429; sliding window enforces the average rate. |
| Multi-IP isolation | Three IPs (via trusted X-Forwarded-For) each receive their own budget; one IP exhausting its budget does not affect the others. |
| Multi-zone isolation | Two zones with different events/window move independently; exhausting one does not trip the other. |
| Long sustained 60s @ 1 rps | Over multiple windows, accepted RPS converges within tolerance of the configured events/window rate. |
The single strongest assertion across every scenario: the plugin's own caddy_rate_limit_declined_requests_total counter delta must match the externally-observed 429 count within ±1. This catches any regression where the limiter stops firing but a 429 is still returned (or vice versa) by something else in the request path.
Each CI run uploads:
summary.json— machine-readable per-scenario JSON with timestamps, commit SHA, Caddy and plugin versions, status-code distribution, latency percentiles (p50–p99.99), RPS, and plugin counter deltas. Suitable for archival or aggregation.summary.md— human-readable Markdown, also rendered into the workflow Step Summary and posted as a sticky comment on PRs.
Artifacts live in the workflow run under "Artifacts" with 90-day retention.
- Registry: GitHub Container Registry (GHCR)
- Image:
ghcr.io/coders-compass/caddy-with-ratelimit:latest - Base: Official Caddy 2
- Plugin: github.com/mholt/caddy-ratelimit
- Updates: Automatically rebuilt weekly (Sundays)
- Source: GitHub Repository
Every published image carries:
- SPDX SBOM generated by BuildKit and attached as an OCI artifact on the manifest. Inspect with:
docker buildx imagetools inspect ghcr.io/coders-compass/caddy-with-ratelimit:latest --format '{{ json .SBOM }}' - SLSA build provenance (
mode=max) attached via BuildKit plus a signed Sigstore attestation viaactions/attest-build-provenance. Verify withgh attestation verifyorcosign verify-attestation. - Trivy vulnerability scan runs on every build before push. Builds fail on CRITICAL findings; HIGH/MEDIUM/LOW are reported. Results are uploaded to the repository's Code Scanning tab and attached to each workflow run as a
trivy-results-*artifact (SARIF + table, 90-day retention). Suppressions live in.trivyignore.
latest- Latest build from main branchmain-<sha>- Specific commit from main branchYYYYMMDD- Weekly scheduled builds
To verify rate limiting is working:
# Make rapid requests
for i in {1..15}; do
curl -i http://localhost/
doneYou should see 200 OK for the first requests, then 429 Too Many Requests once the limit is exceeded.
git clone https://github.com/Coders-Compass/caddy-with-ratelimit.git
cd caddy-with-ratelimit
docker build -t caddy-with-ratelimit:local .The test/ directory contains the same setup CI runs:
- Docker Compose stack with two rate-limit zones and a
/metricsendpoint test.sh— quick functional smoke tests (curl-based)load-test.sh— the full 7-scenario load-test suite that emitstest/results/summary.json+summary.mdand exits non-zero on any band miss
Prerequisites: Docker, curl, jq, and oha on your PATH.
cd test
docker compose up -d
./test.sh # functional smoke tests
./load-test.sh # numeric verification + JSON/Markdown artefactsResults land in test/results/. The script exits 0 only when every scenario falls inside its tolerance band, with a per-scenario fail reason explaining which band was missed.
Contributions are welcome! Please feel free to submit a Pull Request.
MIT License - See LICENSE file for details
- Caddy - The amazing web server
- mholt/caddy-ratelimit - The rate limiting plugin
- Inspired by debian-act-runner