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:
| Contents | Rebuilt 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 chunks | The 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
wwwinstance 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
noevictionrather thanallkeys-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:
| Trigger | What happens |
|---|---|
| API startup | loadRedisBaseData() — skips every key that already exists |
| Any CMS save | deleteRedisBaseData() then loadRedisBaseData() — a real rebuild, because the delete cleared the keys first |
npm run redis:warm | Same 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
MapinsetRedisData.js. Eachwwwinstance 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 barereturn nullplaced above theisCacheEnabled()gate (#911, removed in TUN-876). Every read returned null regardless ofCACHE_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=falseis 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.