/Traedis – Redis-backed HTTP Cache

Traedis – Redis-backed HTTP Cache

5
v0.4.0

⚠️ This project is in its early stages. Use it at your own risk.

Traedis Logo

Traedis

Release CI Traefik Plugin Catalog Dependencies

Redis-backed HTTP cache for Traefik, shipped as a middleware plugin.

  • Standards-based: follows RFC 9111 (Cache-Control, Vary, ETag, 304 Not Modified…), RFC 5861 (stale-while-revalidate, stale-if-error) and RFC 9211 (Cache-Status).
  • Dependency-free: only uses the Go standard library, with no third-party module and no unsafe.
  • Safe by default: never caches what must not be shared (Authorization, Set-Cookie, private) unless the backend explicitly allows it. See Security for the pitfalls.
  • Fail open: if Redis is slow or down, requests are forwarded to the backend, and Traefik still starts.
  • Observable: every response tells what the cache did in a Cache-Status header.
  • Stream-friendly: large bodies, WebSockets and server-sent events pass through untouched.
  • Persistent and shared: responses are cached in Redis, not in Traefik's memory. The cache survives restarts and redeploys, and all Traefik replicas share it.

Installation

Requires Traefik v3 and Redis ≥ 8.0 (for HSETEX).

Declare the plugin in the Traefik static configuration:

experimental:
plugins:
traedis:
moduleName: github.com/eleaven/traedis
version: 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-app
middlewares:
- 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.

Configuration

All keys but redis.dsn are optional. Durations use the Go syntax (50ms, 5m, 1h).

KeyDefaultDescription
redis.dsnrequiredredis://[user[:password]@]host[:port][/db], or rediss:// for TLS, which verifies Redis's certificate.
redis.tls.casystem authoritiesWith rediss://, the authorities trusted to sign Redis's certificate, as a PEM file or its content.
redis.tls.insecureSkipVerifyfalseWith rediss://, accept any certificate. The connection is encrypted, but Redis may be anyone.
redis.poolSize10Maximum connections to Redis, per middleware and per Traefik instance. A request that finds them all busy waits for one.
redis.dialTimeout50msMaximum time to get a Redis connection, free in the pool or new. Past it, the request is forwarded without the cache.
redis.timeout1sMaximum time for a whole Redis operation, including getting the connection and transferring a body. Past it, the request is forwarded without the cache.
redis.maxFailures5Consecutive failures after which Redis is no longer asked. 0: every request tries it.
redis.retryAfter1sHow 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.
defaultTtl0Freshness when the backend sends no max-age, s-maxage or Expires (5m). 0: not cached. Read Security first.
staleTtl1hHow long, at most, an expired response stays in Redis to be validated or served stale.
defaultStaleWhileRevalidate0stale-while-revalidate window for responses without the directive, at most staleTtl. 0 disables it.
defaultStaleIfError0stale-if-error window for responses without the directive, at most staleTtl. 0 disables it.
maxBodyBytes5242880 (5 MiB)Larger responses are streamed to the client and not cached. At most 512 MiB, the largest value Redis accepts.
maxBufferedBytes67108864 (64 MiB)Total size of the bodies held in memory per Traefik instance. Past it, requests are forwarded without the cache. At least maxBodyBytes.
maxVariants16Maximum cached variants per URL. 0: responses with Vary are not cached.
maxRevalidations10Maximum background refreshes running at once, per middleware and per Traefik instance.
revalidationTimeout30sMaximum duration of a background refresh.
sortQueryfalseSort 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.
followClientCacheControlfalseFollow the request's Cache-Control, whose directives are advisory. See Sent by the client.
exposeCacheStatustrueAdd the Cache-Status header to responses, to tell what the cache did.
exposeKeyfalseAdd the Redis key to Cache-Status (key="…"). Requires exposeCacheStatus.

HTTP headers

Traedis follows the standard HTTP headers sent by the backend and the client, and adds a few of its own.

Sent by the backend

The backend's response says whether it may be cached, for how long, and whether it may be served stale.

Cache-Control directiveEffect
max-age=NFresh for N seconds.
s-maxage=NSame, and wins over max-age. Never served stale.
no-store, private, no-cacheNot cached.
private="Set-Cookie", no-cache="Set-Cookie"Cached without the headers they name. Only the client that caused the miss gets them.
publicCached even when the request has Authorization.
must-revalidate, proxy-revalidateNever served stale.
stale-while-revalidate=NOnce stale, still served for N seconds, and refreshed in the background.
stale-if-error=NOnce stale, still served for N seconds instead of a backend error.
HeaderEffect
ExpiresFresh 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, DateHow old the response already is. It is cached for the freshness it has left.
VaryOne cached variant per value of the request headers it names. Not cached with *, Cookie or Authorization (one variant per user).
ETag, Last-ModifiedLet the cache validate a stale response instead of fetching it again, and answer 304 to clients that already have it.
Set-CookieNot cached, even with public, unless private="Set-Cookie" or no-cache="Set-Cookie" keeps the cookie out.

Sent by the client

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 directiveEffect
no-cacheAlways forwarded, whatever is cached. The response is cached.
no-storeThe response is not cached. A cached one can still answer.
max-age=NRefuses a cached response older than N seconds.
min-fresh=NRefuses a cached response that will not be fresh for N more seconds.
max-stale=NAccepts a response that is stale by less than N seconds. max-stale alone accepts any staleness.
only-if-cachedNever forwarded. When nothing cached can answer, the client gets a 504 without Cache-Status. With max-stale, it is what an offline client sends.
HeaderEffect
If-None-Match, If-Modified-SinceAnswered with a 304 when the client already has the cached response.
Range, If-RangeAnswered with the requested bytes of the cached response (range requests).
Authorization, and the headers of authorizationHeadersThe response is not cached, unless the backend allows sharing it with public, s-maxage or must-revalidate.
CookieNot part of the cache key: a cookie does not make a response private.
Accept-Encoding, and the headers that Vary namesSelect the variant.
Upgrade, Accept: text/event-streamForwarded untouched (WebSockets, server-sent events).
Content-Length, Transfer-EncodingA 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.

Added by Traedis

HeaderEffect
AgeOn a response served from the cache, the seconds since the backend made it.
If-None-Match, If-Modified-SinceSent to the backend instead of the client's, to validate a cached response.
Cache-StatusOn every response, what the cache did (RFC 9211), unless exposeCacheStatus is false.

Cache-Status reads traedis; followed by one of these values:

ValueMeaning
hit; ttl=NServed from the cache, fresh for N more seconds.
hit; ttl=-N; detail=stale-while-revalidateServed from the cache, stale by N seconds, and refreshed in the background.
hit; ttl=-N; detail=max-staleServed from the cache, stale by N seconds, as the request allows (followClientCacheControl: true).
fwd=uri-missForwarded: nothing is cached for this URL.
fwd=vary-missForwarded: this URL has variants, but none for this request.
fwd=staleForwarded: the cached response is stale.
fwd=requestForwarded: 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=NValidated by the backend: the cached response is served, fresh again for N seconds.
fwd=stale; fwd-status=503; ttl=-N; detail=stale-if-errorThe backend failed: the cached response is served instead of its error.
fwd=bypassForwarded untouched: WebSocket upgrade, server-sent events or request with a body.
fwd=bypass; detail=redisForwarded: Redis failed, timed out or is no longer asked.
fwd=bypass; detail=memoryForwarded: the bodies held in memory reach maxBufferedBytes, so the cached response is not sent.
fwd=methodForwarded: OPTIONS, TRACE or an unsafe method.
fwd=method; detail=invalidatedUnsafe method: the URL's cached responses were removed (detail=redis when Redis could not be reached).

Two parameters can come with them:

ParameterMeaning
fwd-status=NThe status the backend answered with, whatever the client gets.
key="…"The Redis key, with exposeKey: true, to help debug the cache.

Caching rules

The specification details each rule. In short:

  • Cacheable responses: a 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.
  • Variants: one cached response per value of the request headers that Vary names, up to maxVariants per URL. Responses are cached by Content-Encoding, not by Accept-Encoding.
  • Conditional requests: the cache answers 304 to clients that already have the response, and validates stale responses with the backend's ETag and Last-Modified.
  • Range requests: only whole responses are cached, and Range requests are answered from them.
  • Stale responses: served for stale-while-revalidate, stale-if-error or the client's max-stale, for at most staleTtl.
  • Invalidation by unsafe methods: a 2xx or 3xx answer to a POST, PUT, DELETE… removes the URL's cached responses.
  • Redis failures: requests are forwarded when Redis is slow or down, and Redis is no longer asked after redis.maxFailures consecutive failures.

Security

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.

  • Leave defaultTtl at 0, unless every response that your backend sends without Cache-Control is the same for all clients.
  • Have your backend send 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.
  • List in 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).
  • Name in Vary every request header that the response depends on (User-Agent, Accept-Language, Forwarded…). Otherwise, one client chooses what the others get.
  • Give each router its own cache middleware when routers share URLs but not backends, or when they rewrite the URL. Rewrite it after the cache, not before.
  • Behind a proxy that terminates TLS, list it in the entry point's forwardedHeaders.trustedIPs, so that http and https requests don't share cached responses.
  • Have your backend refuse the methods it does not handle with a 405, or filter them in the router rule. Otherwise, any client can empty the cache of any URL.
  • Give Redis a maxmemory with the allkeys-lfu policy, and keep it for the cache only, on a private network or behind rediss://.
  • Set 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.
  • Put authentication, 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

Performance

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:

BackendWithout cacheCache missCache hitGain
Text answered after 100 ms99.0 req/s – 102 ms97.0 req/s – 103 ms5,032 req/s – 1.48 ms×51
Rendered PNG98.4 req/s – 96.2 ms106 req/s – 87.7 ms4,598 req/s – 1.60 ms×47
Image converted to AVIF85.7 req/s – 107 ms84.7 req/s – 107 ms4,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.

Contributing

See CONTRIBUTING.md for the local stack, the tests and the rules of the project.

License

MIT