Run Redis as a Cache vs a Persistent Store
Redis can play two different roles. As a cache, it holds disposable copies of data that lives elsewhere, and losing everything in it costs you nothing but a few slow requests. As a persistent store, it is the system of record for data like sessions, queues, or counters, and losing it means losing real state. The same server binary handles both roles, but getting each one right means configuring it separately: a cache with bounded memory and eviction, or a durable store with AOF persistence and a volume.
Three settings decide the mode
These are the settings that separate the two configurations:
| Setting | Cache | Persistent store |
|---|---|---|
maxmemory | Set to a hard limit | Unset or generous headroom |
maxmemory-policy | allkeys-lru (or allkeys-lfu) | noeviction (default) |
| Persistence (RDB/AOF) | Disabled | AOF enabled, plus RDB snapshots |
The dividing line is whether Redis is allowed to throw data away: a cache is, a store is not. Everything else follows from that.
Configure Redis as a cache
For a cache you want bounded memory, automatic eviction of cold keys, and no disk writes.
Deploy the Redis template, then set a custom start command on the service:
redis-server --save "" --appendonly no --maxmemory 400mb --maxmemory-policy allkeys-lruWhat each flag does:
--save ""disables RDB snapshots. Without this, the Redis Docker image uses its built-in snapshot schedule and writes dump files to disk. A cache does not need them.--appendonly nokeeps the append-only file off. This is already the default; the flag makes the intent explicit.--maxmemory 400mbcaps the dataset. When the cap is reached, Redis evicts keys instead of growing.--maxmemory-policy allkeys-lruevicts the least recently used key, across all keys, whenever memory is needed. Useallkeys-lfuinstead if your access pattern favors frequently used keys over recently used ones.
A cache configured this way does not need a volume. If the service restarts, it comes back empty and refills from your source of truth.
Pick a maxmemory value
Railway bills memory at usage-based rates (see pricing), so maxmemory is also your cost ceiling for the dataset. Leave headroom between maxmemory and the service's memory limit: Redis needs extra memory beyond the dataset for client buffers and copy-on-write during forks. That headroom follows a common rule: set maxmemory to roughly half of the memory you expect the container to use at peak.
Eviction policies compared
| Policy | Behavior | Use when |
|---|---|---|
noeviction | Writes fail with an error when memory is full | Redis is a store, never a cache |
allkeys-lru | Evict least recently used key, any key | General-purpose cache |
allkeys-lfu | Evict least frequently used key, any key | Cache with stable hot set |
volatile-lru | Evict LRU among keys with a TTL | Mixed cache and store in one instance (avoid this, see below) |
volatile-ttl | Evict the key closest to expiry | TTL-driven caches |
allkeys-random / volatile-random | Evict random keys | Rarely the right choice |
Configure Redis as a persistent store
For sessions, job queues (BullMQ, Sidekiq, Celery), rate-limit counters, or any data your application cannot rebuild, you need durability.
-
Attach a volume. The Railway Redis template runs the official Redis Docker image, which uses
/dataas its working directory. Make sure the service has a volume mounted at/dataso RDB and AOF files survive restarts and redeploys. That mount happens when the container starts, not during build, which works fine for Redis since it only reads its data directory at runtime. -
Enable AOF. Set the start command:
redis-server --appendonly yes --appendfsync everysecThe append-only file logs every write. With appendfsync everysec, Redis syncs the log to disk once per second, so a crash loses at most about one second of writes. Alongside that AOF log, RDB snapshots stay on their default schedule and act as compact restore points.
-
Keep
noeviction. It is the default policy. When memory fills up, writes fail loudly instead of Redis silently deleting your queue jobs or sessions. A failed write is a signal to resize; a silently evicted job is data loss. -
Turn on backups. Volume-backed services support scheduled backups. Persistence protects you from restarts; backups protect you from bad deploys and operator error.
If the dataset grows, you can resize the volume live from the volume settings without downtime on paid plans.
Do not mix both roles in one instance
You could run one Redis with TTLs on cache keys, no TTLs on durable keys, and volatile-lru as the policy. That setup has three problems:
- Cache traffic and store traffic compete for the same memory budget, so a burst of cache fills can pressure the durable dataset.
- Persistence settings apply to the whole instance. You either pay AOF write overhead for cache traffic or run your durable data without it.
- One
FLUSHALLor one memory incident takes out both.
Run two Redis services in the same project instead: redis-cache with the cache configuration and redis-store with the persistent configuration. Both are reachable from your other services over private networking at redis-cache.railway.internal and redis-store.railway.internal, and internal traffic does not count toward egress billing.
Connect from your application
Each Redis service exposes a REDIS_URL variable you can reference from other services in the project. With two instances, reference each service's variable separately.
Install the client:
npm install ioredisThen connect to both, using the cache with a cache-aside pattern and the store for durable state:
import Redis from "ioredis";
// family: 0 enables dual-stack lookup, required for
// private networking hostnames like redis-cache.railway.internal
const cache = new Redis(process.env.CACHE_REDIS_URL!, { family: 0 });
const store = new Redis(process.env.STORE_REDIS_URL!, { family: 0 });
// Cache-aside: try the cache, fall back to the real source, then fill.
async function getProduct(id: string): Promise<Product> {
const cached = await cache.get(`product:${id}`);
if (cached) return JSON.parse(cached) as Product;
const product = await fetchProductFromDatabase(id);
// Always set a TTL on cache keys. Eviction is the safety net, not the plan.
await cache.set(`product:${id}`, JSON.stringify(product), "EX", 300);
return product;
}
// Durable state: no TTL, lives in the persistent instance.
async function recordLogin(userId: string): Promise<void> {
await store.hset(`session:${userId}`, {
lastLogin: Date.now().toString(),
});
}
interface Product {
id: string;
name: string;
priceCents: number;
}
async function fetchProductFromDatabase(id: string): Promise<Product> {
// Replace with your real database query.
return { id, name: "example", priceCents: 1000 };
}The family: 0 option matters when connecting over the internal network. Without it, some clients fail DNS resolution for .railway.internal hostnames. See the ENOTFOUND troubleshooting guide for details.
Which configuration fits your workload
- Page and API response caching, computed results, HTML fragments: cache mode. Rebuildable by definition.
- Sessions: persistent store. Users notice when every session drops on a redeploy.
- Job queues: persistent store with AOF. An evicted or lost job is silent data loss.
- Rate limiting: depends. If a reset window on restart is acceptable, cache mode is fine. If limits enforce billing or abuse rules, use the store.
- Pub/sub: neither setting matters. Pub/sub messages are never persisted in Redis regardless of configuration; use a queue if you need delivery guarantees.
Next steps
- Redis on Railway - Deploy and connect the Redis template.
- Using Volumes - Attach persistent storage to a service.
- Backups - Schedule automated volume backups.
- Choose Between Cron Jobs, Background Workers, and Queues - Background processing patterns that pair with Redis.