Self-hosted & ConnectorsScale Out with Redis

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:

StateWithout 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 countOne token bucket across all replicas
Idempotency recordsA retry that lands on a different replica runs again instead of replayingReplays work whichever replica answers
Monthly quota and test-key countersEach replica counts separately and driftsOne counter per tenant
Cache invalidation (plan, suspension, mapping overrides)Other replicas see a change only after their 60-second cache expiresEvery 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

FormUse
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=production and a plain redis:// URL, the API logs one warning recommending rediss://.

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