⚠️ This project is in its early stages. Use it at your own risk.
Redis-backed HTTP cache for Traefik, shipped as a middleware plugin.
Cache-Control, Vary, ETag, 304 Not Modified…), RFC 5861
(stale-while-revalidate, stale-if-error) and RFC 9211 (Cache-Status).unsafe.Authorization, Set-Cookie, private) unless the backend explicitly allows
it. See Security for the pitfalls.Cache-Status header.Requires Traefik v3 and Redis ≥ 8.0 (for HSETEX).
Declare the plugin in the Traefik static configuration:
experimental:plugins:traedis:moduleName: github.com/eleaven/traedisversion: vX.Y.Z # replace with the release you want
Then declare the middleware in the dynamic configuration, and add it to your routers:
http:middlewares:cache:plugin:traedis:redis:dsn: "redis://:password@redis:6379/0"routers:my-app:rule: Host(`app.example.com`)service: my-appmiddlewares:- cache
Put authentication and rate limiting before the cache (see Security). compose.yml and example/
provide a complete local setup, with Traefik, Redis and sample backends.
All keys but redis.dsn are optional. Durations use the Go syntax (50ms, 5m, 1h).
| Key | Default | Description |
|---|---|---|
redis.dsn | required | redis://[user[:password]@]host[:port][/db], or rediss:// for TLS, which verifies Redis's certificate. |
redis.tls.ca | system authorities | With rediss://, the authorities trusted to sign Redis's certificate, as a PEM file or its content. |
redis.tls.insecureSkipVerify | false | With rediss://, accept any certificate. The connection is encrypted, but Redis may be anyone. |
redis.poolSize | 10 | Maximum connections to Redis, per middleware and per Traefik instance. A request that finds them all busy waits for one. |
redis.dialTimeout | 50ms | Maximum time to get a Redis connection, free in the pool or new. Past it, the request is forwarded without the cache. |
redis.timeout | 1s | Maximum time for a whole Redis operation, including getting the connection and transferring a body. Past it, the request is forwarded without the cache. |
redis.maxFailures | 5 | Consecutive failures after which Redis is no longer asked. 0: every request tries it. |
redis.retryAfter | 1s | How often Redis is tried again once it is no longer asked. |
statusCodes | ["200-399"] | Codes (404) and ranges (400-499) that may be cached. It only narrows what RFC 9111 allows; it never makes a response cacheable. |
defaultTtl | 0 | Freshness when the backend sends no max-age, s-maxage or Expires (5m). 0: not cached. Read Security first. |
staleTtl | 1h | How long, at most, an expired response stays in Redis to be validated or served stale. |
defaultStaleWhileRevalidate | 0 | stale-while-revalidate window for responses without the directive, at most staleTtl. 0 disables it. |
defaultStaleIfError | 0 | stale-if-error window for responses without the directive, at most staleTtl. 0 disables it. |
maxBodyBytes | 5242880 (5 MiB) | Larger responses are streamed to the client and not cached. At most 512 MiB, the largest value Redis accepts. |
maxBufferedBytes | 67108864 (64 MiB) | Total size of the bodies held in memory per Traefik instance. Past it, requests are forwarded without the cache. At least maxBodyBytes. |
maxVariants | 16 | Maximum cached variants per URL. 0: responses with Vary are not cached. |
maxRevalidations | 10 | Maximum background refreshes running at once, per middleware and per Traefik instance. |
revalidationTimeout | 30s | Maximum duration of a background refresh. |
sortQuery | false | Sort the query parameters by name in the cache key, so that ?a=1&b=2 and ?b=2&a=1 share a cached response. Only for backends that ignore their order. |
authorizationHeaders | [] | Request headers that identify the client, as Authorization does (X-WebAuth-User). Requests with one are treated like those with Authorization. |
followClientCacheControl | false | Follow the request's Cache-Control, whose directives are advisory. See Sent by the client. |
exposeCacheStatus | true | Add the Cache-Status header to responses, to tell what the cache did. |
exposeKey | false | Add the Redis key to Cache-Status (key="…"). Requires exposeCacheStatus. |
Traedis follows the standard HTTP headers sent by the backend and the client, and adds a few of its own.
The backend's response says whether it may be cached, for how long, and whether it may be served stale.
Cache-Control directive | Effect |
|---|---|
max-age=N | Fresh for N seconds. |
s-maxage=N | Same, and wins over max-age. Never served stale. |
no-store, private, no-cache | Not cached. |
private="Set-Cookie", no-cache="Set-Cookie" | Cached without the headers they name. Only the client that caused the miss gets them. |
public | Cached even when the request has Authorization. |
must-revalidate, proxy-revalidate | Never served stale. |
stale-while-revalidate=N | Once stale, still served for N seconds, and refreshed in the background. |
stale-if-error=N | Once stale, still served for N seconds instead of a backend error. |
| Header | Effect |
|---|---|
Expires | Fresh until this date, when there is no max-age or s-maxage. Without any of the three, the response is not cached, unless defaultTtl is set. |
Age, Date | How old the response already is. It is cached for the freshness it has left. |
Vary | One cached variant per value of the request headers it names. Not cached with *, Cookie or Authorization (one variant per user). |
ETag, Last-Modified | Let the cache validate a stale response instead of fetching it again, and answer 304 to clients that already have it. |
Set-Cookie | Not cached, even with public, unless private="Set-Cookie" or no-cache="Set-Cookie" keeps the cookie out. |
The client's request says whether a cached response may answer it.
Its Cache-Control is ignored by default. Its directives are advisory
(RFC 9111 §5.2.1), and following them lets any client replace the cached response
with what the backend answers to its own request, get a stale one, and tell whether a URL is cached without caching it. With
followClientCacheControl: true:
Cache-Control directive | Effect |
|---|---|
no-cache | Always forwarded, whatever is cached. The response is cached. |
no-store | The response is not cached. A cached one can still answer. |
max-age=N | Refuses a cached response older than N seconds. |
min-fresh=N | Refuses a cached response that will not be fresh for N more seconds. |
max-stale=N | Accepts a response that is stale by less than N seconds. max-stale alone accepts any staleness. |
only-if-cached | Never forwarded. When nothing cached can answer, the client gets a 504 without Cache-Status. With max-stale, it is what an offline client sends. |
| Header | Effect |
|---|---|
If-None-Match, If-Modified-Since | Answered with a 304 when the client already has the cached response. |
Range, If-Range | Answered with the requested bytes of the cached response (range requests). |
Authorization, and the headers of authorizationHeaders | The response is not cached, unless the backend allows sharing it with public, s-maxage or must-revalidate. |
Cookie | Not part of the cache key: a cookie does not make a response private. |
Accept-Encoding, and the headers that Vary names | Select the variant. |
Upgrade, Accept: text/event-stream | Forwarded untouched (WebSockets, server-sent events). |
Content-Length, Transfer-Encoding | A GET or HEAD with a body is forwarded untouched, and its response is not cached. Some backends read parameters in the body, which the cache key does not have. |
| Header | Effect |
|---|---|
Age | On a response served from the cache, the seconds since the backend made it. |
If-None-Match, If-Modified-Since | Sent to the backend instead of the client's, to validate a cached response. |
Cache-Status | On every response, what the cache did (RFC 9211), unless exposeCacheStatus is false. |
Cache-Status reads traedis; followed by one of these values:
| Value | Meaning |
|---|---|
hit; ttl=N | Served from the cache, fresh for N more seconds. |
hit; ttl=-N; detail=stale-while-revalidate | Served from the cache, stale by N seconds, and refreshed in the background. |
hit; ttl=-N; detail=max-stale | Served from the cache, stale by N seconds, as the request allows (followClientCacheControl: true). |
fwd=uri-miss | Forwarded: nothing is cached for this URL. |
fwd=vary-miss | Forwarded: this URL has variants, but none for this request. |
fwd=stale | Forwarded: the cached response is stale. |
fwd=request | Forwarded: the request has no-cache, or its max-age or min-fresh refuses a cached response that is still fresh (followClientCacheControl: true). |
fwd=stale; fwd-status=304; ttl=N | Validated by the backend: the cached response is served, fresh again for N seconds. |
fwd=stale; fwd-status=503; ttl=-N; detail=stale-if-error | The backend failed: the cached response is served instead of its error. |
fwd=bypass | Forwarded untouched: WebSocket upgrade, server-sent events or request with a body. |
fwd=bypass; detail=redis | Forwarded: Redis failed, timed out or is no longer asked. |
fwd=bypass; detail=memory | Forwarded: the bodies held in memory reach maxBufferedBytes, so the cached response is not sent. |
fwd=method | Forwarded: OPTIONS, TRACE or an unsafe method. |
fwd=method; detail=invalidated | Unsafe method: the URL's cached responses were removed (detail=redis when Redis could not be reached). |
Two parameters can come with them:
| Parameter | Meaning |
|---|---|
fwd-status=N | The status the backend answered with, whatever the client gets. |
key="…" | The Redis key, with exposeKey: true, to help debug the cache. |
The specification details each rule. In short:
GET is cached per middleware and per URL when its status is in statusCodes, it is fresh
and complete, it fits in maxBodyBytes, and no header forbids it.Vary names, up to maxVariants per URL.
Responses are cached by Content-Encoding, not by Accept-Encoding.304 to clients that already have the response, and validates stale
responses with the backend's ETag and Last-Modified.Range requests are answered from them.stale-while-revalidate, stale-if-error or the client's max-stale, for at most
staleTtl.2xx or 3xx answer to a POST, PUT, DELETE… removes the URL's cached
responses.redis.maxFailures consecutive failures.A shared cache serves the response made for one client to all the others. The defaults are careful, but the cache only knows what your backend and your configuration tell it. Check these points. The specification explains each of them.
defaultTtl at 0, unless every response that your backend sends without Cache-Control is the same for all clients.private or no-store with whatever depends on the client, redirections included, or leave 3xx out of
statusCodes. Cookies are not part of the cache key, and private="Set-Cookie" only keeps the cookie itself out.authorizationHeaders the headers that identify the client when it is not Authorization: an API key, or the header that
basicAuth or forwardAuth set (X-WebAuth-User).Vary every request header that the response depends on (User-Agent, Accept-Language, Forwarded…). Otherwise, one
client chooses what the others get.forwardedHeaders.trustedIPs, so that http and https
requests don't share cached responses.405, or filter them in the router rule. Otherwise, any client can
empty the cache of any URL.maxmemory with the allkeys-lfu policy, and keep it for the cache only, on a private network or behind rediss://.exposeCacheStatus: false if clients must not see what the cache did. Age still tells them that a response was served from
the cache, and so that someone else requested it.rateLimit and inFlightReq before the cache:http:routers:my-app:middlewares:- auth # a hit is served without running what comes after the cache- ratelimit # random URLs are misses that reach the backend- inflightreq # each request may hold a body in memory- cache
A hit is served in about 1.5 ms, whatever the backend costs, so the slower the backend, the larger the gain. A miss takes no longer than the backend alone, since its response is cached after the client receives it.
Load test through Traefik with 10 concurrent clients, as requests per second and median latency:
| Backend | Without cache | Cache miss | Cache hit | Gain |
|---|---|---|---|---|
| Text answered after 100 ms | 99.0 req/s – 102 ms | 97.0 req/s – 103 ms | 5,032 req/s – 1.48 ms | ×51 |
| Rendered PNG | 98.4 req/s – 96.2 ms | 106 req/s – 87.7 ms | 4,598 req/s – 1.60 ms | ×47 |
| Image converted to AVIF | 85.7 req/s – 107 ms | 84.7 req/s – 107 ms | 4,768 req/s – 1.56 ms | ×56 |
These figures come from the CI run of October 13, 2026, on a shared GitHub Actions runner: read them as orders of magnitude.
Every CI run publishes its own in its summary, with the p95 and the cost of the middleware alone. make bench runs them locally.
See CONTRIBUTING.md for the local stack, the tests and the rules of the project.