Skip to Content
InfraData storageInfra: Redis

Redis

Two Redis Stack instances on a DigitalOcean droplet: a cache on 6379, and the Ask Rusty vector index on 6380.

The runbook — deploying, recovery, rebuilding the host — lives with the code at infra/droplet/README.md. This page covers what it is and why, not how to operate it.

What is in there

Nothing authoritative. Everything rebuilds:

ContentsRebuilt by
Query caches (member, logbook_*, flyer_skills_levels, category)The API, lazily from MySQL as requests arrive
Base-data keys (faqs, skills, news, videos, materials, tunnels, countries × en/es/fr)loadRedisBaseData(), but only for keys that are absent — see below
Vector index chunksThe Ask Rusty ingest cron

There are no sessions — authentication is stateless JWT. Losing the host costs a cache warm-up, not data, which is why replacing it is a low-risk operation.

Who reads it

api and www only. Both take REDIS_HOST and REDIS_PASSWORD from Infisical’s shared folder. admin has no Redis dependency.

www is read-only: it loads the base-data keys at boot into app.locals and getStoredData serves from there, re-reading a key once its copy is older than BASE_DATA_TTL_MS. The refresh happens behind the request — the cached copy is returned immediately — so a slow or dead Redis costs staleness rather than latency, and the last good copy keeps being served.

It did not always work that way. The boot-time copy was previously held for the life of the process with nothing to invalidate it, which meant a CMS save rebuilt Redis and the site did not change. Every CMS area calls deleteRedisBaseData() + loadRedisBaseData(), that reached Redis, and www went on serving its snapshot until the next deploy — roughly weekly. Two further consequences worth knowing:

  • A www instance that starts while Redis is cold now recovers on the next request rather than serving empty until a restart.
  • Those keys must never be evicted. The cache instance therefore runs noeviction rather than allkeys-lru, despite being a cache — under LRU the base data is evictable, which surfaces as public pages rendering empty with nothing in the logs to explain it.

When base data is written and re-read

Two independent cycles, and they are the thing to have straight before debugging “the site is not showing my change”.

The API writes it. Three triggers, and only one of them is a full rebuild:

TriggerWhat happens
API startuploadRedisBaseData() — skips every key that already exists
Any CMS savedeleteRedisBaseData() then loadRedisBaseData() — a real rebuild, because the delete cleared the keys first
npm run redis:warmSame loader, same skip-if-exists behaviour

The skip is the trap. Restarting the API does not refresh base data. If a key is already in Redis the loader logs already exists, skipping... and moves on, so a restart is not a way to pick up content or query changes — the keys have to be deleted first. This also means a query file can be edited, deployed, and have no effect for months, which is exactly how one locale ends up serving a different shape from another.

loadRedisBaseData() also bails out entirely unless CACHE_ENABLED is exactly the string "true".

www re-reads it. At boot it pulls all sixteen keys into app.locals.redisData. After that, getStoredData serves the in-memory copy and asks revalidateIfStale to re-read anything older than BASE_DATA_TTL_MS — set to 3600000 (1 hour) in Infisical, falling back to 60s if the variable is missing. Set it to 0 to revalidate on every read when debugging.

Two consequences of that design:

  • The timer is per process, held in a Map in setRedisData.js. Each www instance ages its own copy, so after a CMS save the change appears on different instances at different times — worst case a full interval apart.
  • The timestamp is recorded even when the read fails. A dead Redis therefore backs off for a full interval instead of every request re-attempting, which keeps one broken dependency from becoming a retry storm. The cost is that recovery is not instant either.

Changing base-data content

faqs is the only base-data set with no CMS screen, so FAQ edits are a manual database change. Three things make the sequence non-obvious, and getting them wrong looks exactly like success:

Commit before touching anything else. Content scripts run inside a transaction so the changes can be reviewed before they land. Until you COMMIT, the rows are visible only to the session that wrote them — an export from that same tab looks perfect while every other connection, including the API, still reads the old values. Verify from a different connection, or check SELECT * FROM information_schema.innodb_trx returns nothing.

loadRedisBaseData() skips keys that already exist. Restarting the API does not refresh changed content; it logs Redis key faqs:fr already exists, skipping... and moves on. The keys have to be deleted first.

Then let www age out, or redeploy it if you want the change live immediately rather than within BASE_DATA_TTL_MS.

# 1. COMMIT, then confirm from a connection that did not run the script # SELECT COUNT(*) FROM information_schema.innodb_trx; -- must be 0 # 2. Drop only the keys you changed (on the droplet) docker exec -i redis-cache redis-cli -a "$REDIS_PASSWORD" --no-auth-warning \ DEL faqs:en faqs:fr faqs:es # 3. Restart the API so it rebuilds them from MySQL doctl apps create-deployment <iba-api-app-id> # 4. Confirm the rebuild picked up the new content — a positive check, because # a missing key also greps clean docker exec -i redis-cache redis-cli -a "$REDIS_PASSWORD" --no-auth-warning \ --raw JSON.GET faqs:fr | grep -c '<a term you added>'

These are RedisJSON values, not strings: plain GET returns nil on them and a grep of that empty output looks like a pass. Use JSON.GET.

npm run redis:warm rebuilds only missing keys for the same reason; it needs --force to replace existing ones, and that rebuilds all 17. It also resolves REDIS_HOST from whatever environment it happens to load, falling back to localhost rather than failing — so run it deliberately or not at all.

Two instances, not one

The index must never evict: a dropped chunk means retrieval quietly returns worse answers with no error anywhere. Keeping it separate gives it its own memory ceiling and lifecycle, so it can be flushed and rebuilt without touching the cache.

CACHE_ENABLED

Every Redis read and write is a no-op unless CACHE_ENABLED is exactly the string "true". "TRUE", "1" and "yes" all read as enabled to a human and are not.

This used to fail silently: setRedisValue returned true whether it wrote or skipped, so the base-data loader logged “Successfully set data” for all 17 keys while writing none of them. It now checks isCacheEnabled() and logs a single honest warning instead.

This switch can be bypassed, and was. Between 2026-08-29 and 2026-09-22, getRedisValue() opened with a bare return null placed above the isCacheEnabled() gate (#911, removed in TUN-876). Every read returned null regardless of CACHE_ENABLED, for over three weeks.

It hid because it was asymmetric: writes still ran, so the warm-up logged success and the Redis health check passed, while every read silently missed and fell through to MySQL. That is strictly worse than either setting — the platform paid every write cost and got no cache benefit.

The lesson for anyone tempted to disable caching: CACHE_ENABLED=false is the mechanism. It is symmetric, needs no deploy, and is visible in the app env. A short-circuit in one accessor is none of those things.

The client gives up permanently

The API’s Redis client uses reconnectStrategy: false and, after five failed attempts (about ten seconds), sets redisUnavailable for the lifetime of the process. Any outage longer than that — a droplet reboot, for instance — leaves the API serving everything from MySQL until it is redeployed. Working, slower, and silent about it after the initial errors.

Redeploy the API after any Redis interruption.

It is reachable from the internet

Redis listens on the droplet’s public address, protected by requirepass only. That is a tracked decision, not an oversight: the private VPC path works, but Infisical’s DigitalOcean sync strips the VPC attachment on every secret change, so a private deployment would break at an unpredictable later moment.

Tracked in TUN-832 , with the full reasoning in the runbook.

Not managed Redis

DigitalOcean no longer offers it — the engine list is Valkey 8 only, and Valkey does not include RediSearch. Self-hosted Redis Stack is a requirement for vector search, not a preference.

Last updated on