Scale Out with Redis
Run more than one API replica by sharing rate limits, idempotency records, quota counters and cache invalidation through Redis, and know exactly what happens when Redis is unavailable.
Why replicas need shared state
A single API replica keeps several pieces of state in process memory. That is correct for one replica and wrong for several, because each replica would count on its own. With REDIS_URL set, the API moves this state into Redis:
| State | Without Redis (per replica) | With Redis (shared) |
|---|---|---|
| Rate limits (per tenant, per key, and per client IP on the OAuth token route) | Each replica admits the full rate, so the effective limit multiplies by the replica count | One token bucket across all replicas |
| Idempotency records | A retry that lands on a different replica runs again instead of replaying | Replays work whichever replica answers |
| Monthly quota and test-key counters | Each replica counts separately and drifts | One counter per tenant |
| Cache invalidation (plan, suspension, mapping overrides) | Other replicas see a change only after their 60-second cache expires | Every replica sees it on the next request |
Already safe across replicas without Redis: batch job claiming and webhook deliveries (both coordinated through Postgres row locks), and startup migrations (serialised by a Postgres advisory lock).
Do not run more than one API replica without REDIS_URL. Nothing fails loudly; the limits and replays above just quietly stop being correct. The startup log states which mode you are in: redis adapters enabled … or REDIS_URL unset; … per-process (single replica only …).
Under a licence your monthly quota is unlimited, so quota drift has no visible effect, but rate limits, idempotency and cache invalidation still need Redis.
Configure REDIS_URL
| Form | Use |
|---|---|
redis://[user:password@]host[:port][/db] | Plain TCP. Only on a private network you control |
rediss://[user:password@]host[:port][/db] | TLS (minimum TLS 1.2, server name checked against the host). Use this whenever traffic crosses a network you do not control |
A password-only form (redis://:password@host) is accepted. URL-encode special characters in the password. The URL is a secret; set it through the environment, never in a config file. Log lines print only the scheme, host, port and database, never credentials.
- A malformed URL stops startup (the error never echoes the URL).
- With
TRANSMUTE_ENV=productionand a plainredis://URL, the API logs one warning recommendingrediss://.
Use Redis 7. Persistence is optional: every key either expires or is rebuilt from Postgres. Set maxmemory-policy volatile-lru (every key the API writes has a TTL). Size is small: keys are short counters, and the largest values are cached idempotency responses (at most 1 MiB each, kept 24 hours).
Redis never receives a request body. The one payload-adjacent value it holds is the cached response for requests you sent with an Idempotency-Key, for 24 hours, the same thing the API otherwise keeps in memory. transmute writes these values unencrypted. Treat the instance as a data store: enable encryption at rest, use TLS in transit and keep it in the same region as the API.
When Redis is down
Every Redis-backed component fails open. A Redis error never fails a request; that call falls back to the replica's in-process state, and each component logs one rate-limited degraded line rather than one per request.
GET /v1/ready reports Redis but never fails because of it:
curl -s http://localhost:8080/v1/ready
{ "status": "ready", "redis": "degraded" }
While Redis is unavailable you are temporarily in the per-replica behaviour from the table above: rate limits are per replica, idempotent replays only work on the same replica, and cache changes take up to 60 seconds to spread. Counters rebuild from Postgres and idempotency records simply expire, so no data migration is needed when Redis returns or if you remove it.
Helm
Two ways to give the API a Redis:
In-cluster (simplest). redis.enabled: true deploys a single non-persistent redis:7-alpine Deployment and points the API's REDIS_URL at its Service (redis://…-redis:6379/0). It stays on the cluster network, so plain redis:// is acceptable there.
redis:
enabled: true
replicaCount: 3
# or let the HPA scale:
# autoscaling:
# enabled: true
# minReplicas: 2
# maxReplicas: 4
Managed Redis. Leave redis.enabled: false and pass the URL from a Secret through extraEnv:
extraEnv:
- name: REDIS_URL
valueFrom:
secretKeyRef:
name: transmute-redis
key: url # rediss://user:password@redis.internal.example:6380/0
replicaCount: 3
Raise replicaCount or enable autoscaling only once Redis is wired. Each API replica opens up to 10 Postgres connections, so check your database's connection limit before scaling.
Batch workers scale separately
Batch work is claimed from Postgres with row locks, so you can run any number of batch workers without Redis. To move batch processing off the API pods, see Standalone worker. The file-drop connector is the exception: it must run as exactly one replica per set of watched directories (File-drop connector).
Verify
curl -s http://localhost:8080/v1/ready # {"redis":"ok","status":"ready"}
Then send the same idempotent request 20 times. The first answer runs the conversion; every later answer, whichever replica serves it, carries Idempotency-Replayed: true:
for i in $(seq 20); do
curl -s -o /dev/null -w '%{http_code} %header{idempotency-replayed}
' \
-H "Authorization: Bearer $TRANSMUTE_KEY" \
-H "Idempotency-Key: scaleout-check-1" \
-H "Content-Type: text/plain" \
--data-binary @statement.mt940 \
"http://localhost:8080/v1/convert?to=camt.053"
done