A Caddy 2 HTTP middleware plugin that caches reverse proxy responses using a two-layer cache:
- L1: in-memory cache (otter v2, W-TinyLFU eviction)
- L2: optional persistent backend — Redis or local filesystem
It caches the full response (status code, headers, body), respects basic Cache-Control directives, supports PURGE requests, and is intentionally focused on the reverse-proxy use case. It is not RFC 7234 compliant — if you need full HTTP caching semantics, use caddyserver/cache-handler instead.
- Two-layer caching: fast in-memory L1 with a persistent L2 fallback (Redis or file)
- Automatic L1 promotion on L2 hits
- Configurable TTL, max body size, path/method matchers, and cache key template
- Per-status-code TTLs — different cache durations for success / client errors / server errors
X-Cache: Hit|Miss|Bypassresponse headerPURGEHTTP method to invalidate a single cached entry- Skips caching for
Set-Cookie,Cache-Control: no-store|no-cache|private, non-2xx responses, and oversized bodies - Async cache writes so responses are never blocked on storage I/O
- Go 1.25 or newer
- Caddy 2.5+ (tested against v2.11.2)
- xcaddy for building
- Optional: a running Redis server for the Redis L2 backend
Install xcaddy if you don't already have it:
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latestClone the repo and build a Caddy binary that includes the plugin:
git clone https://github.com/samimishal/response-cache.git
cd response-cache
make buildThe Makefile wraps:
xcaddy build --with github.com/samimishal/response-cache=.This produces a ./caddy binary in the project directory.
If you want to include the plugin in an existing Caddy build from any directory:
xcaddy build --with github.com/samimishal/response-cache@latest./caddy list-modules | grep response_cache
# http.handlers.response_cache-
Start a backend to proxy to (the repo ships a tiny demo backend):
go run example/backend.go
This listens on
:9000and returns an incrementing JSON payload so you can tell cached and fresh responses apart. -
Run Caddy with the example config:
./caddy run --config example/Caddyfile
-
Hit the endpoint twice and watch the
X-Cacheheader:curl -i http://localhost:8080/api/test # HTTP/1.1 200 OK # X-Cache: Miss # {"request_number":1, ...} curl -i http://localhost:8080/api/test # HTTP/1.1 200 OK # X-Cache: Hit # {"request_number":1, ...} <-- same response, no upstream call
-
Purge and re-fetch:
curl -i -X PURGE http://localhost:8080/api/test # HTTP/1.1 204 No Content curl -i http://localhost:8080/api/test # X-Cache: Miss # {"request_number":2, ...} <-- upstream was called again
The plugin registers a response_cache directive that is ordered before reverse_proxy by default.
response_cache {
ttl <duration> # default: 5m (applied to 2xx)
max_body_size <size> # default: 50MB
match_path <pattern> [<pattern>...]
match_methods <method> [<method>...] # default: GET HEAD
cache_key <template> # default: {method}_{host}{path}?{query}
status_ttl {
<code-or-class> <duration> # e.g. 200 10m, 404 30s, 5xx 2s
...
}
memory {
max_items <n> # L1 entry count limit
max_size <size> # L1 byte limit (overrides max_items)
}
# Pick at most one L2 backend:
redis {
addr <host:port>
password <password>
db <n>
key_prefix <prefix> # default: cache:
}
file {
path <directory>
}
}| Option | Description | Default |
|---|---|---|
ttl |
How long cached responses remain valid. Accepts Go duration syntax (5m, 1h, 30s, 24h). |
5m |
max_body_size |
Responses larger than this are passed through without caching. Accepts humanized sizes (50MB, 1GB, 512KB). |
50MB |
match_path |
Glob patterns to limit which paths are cached. If omitted, all paths are candidates. | (all paths) |
match_methods |
HTTP methods to cache. | GET HEAD |
cache_key |
Template for generating cache keys. Placeholders: {method}, {host}, {path}, {query}, {scheme}. |
{method}_{host}{path}?{query} |
Specifies different TTLs for different HTTP status codes. Keys can be:
- Exact status codes:
200,301,404,500 - Class wildcards:
1xx,2xx,3xx,4xx,5xx
Lookup order when resolving the TTL for a response:
- Exact status code (e.g.
404) - Class wildcard (e.g.
4xx) - Top-level
ttl— only for 2xx responses - Otherwise: not cached
A TTL of 0 disables caching for a matching code — useful for carving out exceptions (e.g. cache all 2xx for 10 minutes but explicitly skip 202s).
Example:
response_cache {
ttl 5m # fallback for any 2xx not matched below
status_ttl {
200 1h # exact: cache 200s for an hour
301 24h # permanent redirects get a long TTL
404 30s # micro-cache 404s
5xx 2s # micro-cache all server errors
}
memory { max_items 10000 }
}Behavior with the above config:
| Status | TTL source | TTL | Cached? |
|---|---|---|---|
| 200 | exact 200 |
1h | ✓ |
| 204 | class 2xx not set → default ttl |
5m | ✓ |
| 301 | exact 301 |
24h | ✓ |
| 302 | no match, not 2xx | — | ✗ (Bypass) |
| 404 | exact 404 |
30s | ✓ |
| 403 | no match, not 2xx | — | ✗ (Bypass) |
| 500 | class 5xx |
2s | ✓ |
| 503 | class 5xx |
2s | ✓ |
Why micro-cache errors? A 2-second TTL on 5xx shields a failing upstream from traffic surges during incidents — instead of every client retrying and hammering the broken backend, only one request per 2-second window gets through. Same idea for 404s: avoid DB lookups for bogus paths that get scanned by bots.
| Option | Description | Default |
|---|---|---|
max_items |
Maximum number of entries to hold in memory. Uses W-TinyLFU eviction. | 10000 |
max_size |
Maximum total byte weight. If set, overrides max_items and switches otter into weight-based eviction. |
(unset) |
| Option | Description | Default |
|---|---|---|
addr |
Redis server address. | (required) |
password |
Auth password. | "" |
db |
Database index. | 0 |
key_prefix |
Namespace prepended to every key stored in Redis. | cache: |
Redis TTLs are enforced server-side using SET … EX, so expired entries are cleaned up automatically. Purge uses SCAN + DEL limited to key_prefix* — it does not issue FLUSHDB.
| Option | Description | Default |
|---|---|---|
path |
Directory where cached entries are written. Created on startup if missing. | (required) |
Entries are written atomically: a temp file is created then renamed. Keys are hashed with SHA-256 and distributed across 256 subdirectories (path/{2-char-prefix}/{remaining-hex}) to avoid overloading a single directory.
File TTLs are enforced at read time — the orchestrator decodes each entry and discards expired ones. There is currently no background sweeper, so expired files remain on disk until they are next requested. If long-term disk growth is a concern, use Redis or add a cron job to clean the directory.
A response is not cached if any of the following is true:
- Request method is not in
match_methods - Request path does not match any
match_pathpattern (whenmatch_pathis set) resolveTTL(status)returns 0 — i.e. status is not 2xx and no matching entry instatus_ttl, or there's an explicitstatus_ttl { N 0 }- Response has a
Set-Cookieheader - Response
Cache-Controlcontainsno-store,no-cache, orprivate - Response body exceeds
max_body_size
When a request is skipped, the response includes X-Cache: Bypass so you can tell from the client side.
Great for single-node setups where you don't need cached data to survive restarts.
:8080 {
response_cache {
ttl 10m
memory {
max_items 5000
}
}
reverse_proxy localhost:9000
}Use Redis when you want cached entries to outlive restarts or be shared across multiple Caddy instances.
:8080 {
response_cache {
ttl 1h
max_body_size 100MB
memory {
max_items 10000
}
redis {
addr redis.internal:6379
password {env.REDIS_PASSWORD}
db 0
key_prefix mysite:
}
}
reverse_proxy api.internal:8080
}Handy for single-node setups that still want persistence across restarts without running Redis.
:8080 {
response_cache {
ttl 30m
memory {
max_items 10000
}
file {
path /var/cache/caddy
}
}
reverse_proxy localhost:9000
}Cache success normally, micro-cache errors to shield a flaky upstream:
:8080 {
response_cache {
ttl 10m # default for 2xx
status_ttl {
404 1m # cache not-founds briefly
5xx 2s # micro-cache all server errors
}
memory { max_items 10000 }
}
reverse_proxy flaky-backend:8080
}Or do it per-exact-code for finer control:
:8080 {
response_cache {
status_ttl {
200 1h # long cache for OK
201 0 # never cache 201 Created
301 24h # permanent redirects
404 5m
500 5s
503 2s # shorter for overload
}
memory { max_items 5000 }
}
reverse_proxy backend:8080
}Only cache the public JSON API, leaving everything else alone.
example.com {
response_cache {
ttl 5m
match_path /api/public/*
match_methods GET
memory {
max_items 20000
}
}
reverse_proxy app:3000
}If you serve the same origin over both HTTP and HTTPS and want separate cache entries per scheme:
:80, :443 {
response_cache {
ttl 5m
cache_key {scheme}_{method}_{host}{path}?{query}
memory { max_items 10000 }
}
reverse_proxy backend:8080
}Micro-caching is useful for shielding slow upstreams from traffic spikes:
:8080 {
response_cache {
ttl 2s
match_path /expensive/*
memory { max_items 1000 }
}
reverse_proxy slow-upstream:8080
}If you prefer Caddy's native JSON config, the handler module is http.handlers.response_cache:
{
"handler": "response_cache",
"ttl": 300000000000,
"max_body_size": 52428800,
"match_methods": ["GET", "HEAD"],
"status_ttl": {
"200": 600000000000,
"404": 30000000000,
"5xx": 2000000000
},
"memory": { "max_items": 10000 },
"file": { "path": "/var/cache/caddy" }
}Durations in JSON are in nanoseconds (300000000000 = 5 minutes, 2000000000 = 2 seconds).
Send a PURGE request to the same URL you want to evict:
curl -X PURGE http://localhost:8080/api/testThe handler rewrites the key as if the request were a GET, so PURGE /api/test will correctly delete whatever GET /api/test cached.
There is currently no bulk purge or purge-by-pattern — if you need to drop everything, restart Caddy (for memory-only), delete the cache directory (for file L2), or flush the Redis key prefix (for Redis L2).
- Thundering herd: the plugin does not use singleflight. If N concurrent requests arrive for the same uncached URL, all N hit the upstream. If that is a concern, put a short TTL in front of very expensive endpoints and accept the initial burst.
- Compression: place
response_cachebeforeencodein the Caddyfile if you are using Caddy's built-in compression. The cache will store uncompressed responses and letencodehandle per-request compression negotiation. - Async cache writes: response writes to the client happen before the cache is updated. The cache-set goroutine uses
context.Background(), so it survives request cancellation.
Run the test suite:
go test -race -count=1 ./...Tests are pure-Go and do not require Redis. A Redis backend test suite can be added later behind a //go:build integration tag.
Repo layout:
.
├── entry.go / entry_test.go # CacheEntry + gob serialization
├── key.go / key_test.go # Cache key builder
├── cache.go / cache_test.go # Two-layer orchestrator (otter L1 + Storage L2)
├── handler.go / handler_test.go # Caddy module + ServeHTTP middleware
├── caddyfile.go # Caddyfile parser
├── storage/
│ ├── storage.go # Storage interface
│ ├── redis.go # Redis L2 backend
│ └── file.go / file_test.go # Filesystem L2 backend
├── example/
│ ├── Caddyfile # Memory-only example
│ ├── Caddyfile-file # Memory + file L2 example
│ └── backend.go # Demo upstream server
└── Makefile
MIT