How to Run Redis Alongside Your App
There is a version of caching that has quietly become the default and is often the wrong shape: your app in one datacentre, a managed Redis in another, and every cache hit paying a network round trip that costs more than the Postgres query it was meant to avoid. I have seen a p50 'cache hit' of 4ms sitting in front of a 1.5ms indexed primary-key lookup. That cache was making the app slower and generating a monthly invoice for the privilege.
I'm Ajay, I build PandaStack. This is the practical guide to the other option: running Redis on localhost inside the same machine as your app. It is not the right answer for every workload, and I'll be specific about when it isn't. But for a single service that wants a cache, it removes a network hop, a bill and a dependency in about four lines of configuration.
When co-located Redis is the right call
The pattern fits when all three of these are true:
- One service uses the Redis. If two processes on two machines need the same keys, co-location is definitionally wrong — you'd have two caches disagreeing with each other.
- The data is derived, not primary. Rendered fragments, computed aggregates, rate-limit counters, deduplication sets. Anything you can rebuild from the source of truth.
- Losing the whole dataset is a performance event, not a correctness event. A cold cache after a deploy means a slow minute, not a lost order.
Session storage sits on the boundary. If a wiped cache logs everyone out, decide whether that's acceptable — for a lot of internal tools it genuinely is, and for a consumer product it isn't.
Step 1: Install Redis into the app's own machine
This step is where the platform you're on either helps or blocks you. A traditional function runtime gives you a sandboxed language process and no package manager, so co-location is impossible by construction. Anything that gives you a real Linux userspace — a container, a VM, a Firecracker microVM — lets you install a second process next to the first.
On PandaStack, an app runs in its own microVM with a full Ubuntu userspace, so the install goes in the build command:
# Build command
apt-get update -qq && apt-get install -y --no-install-recommends redis-server \
&& npm ciDistro packages lag upstream by a release or two. For a cache that is almost never a problem — the commands you'll use have been stable for a decade. If you specifically need a recent feature, grab the upstream tarball and build it, but check first that you actually need it.
Step 2: Configure it for a cache, not a database
Stock Redis is configured to be a database. You want the opposite, and the defaults will bite you if you leave them alone. Write a small config file rather than passing a wall of flags, so the settings are reviewable:
# redis.conf -- committed to the repo next to your app.
# Never listen on anything but loopback. There is no auth here and
# no reason for anything off this machine to reach it.
bind 127.0.0.1
protected-mode yes
port 6379
# Hard memory ceiling. Set it well below the VM's RAM -- your app
# needs the rest, and Redis going over means the kernel OOM killer
# picks a victim, which may not be Redis.
maxmemory 256mb
maxmemory-policy allkeys-lru
# No persistence. This is a cache; writing RDB dumps costs disk I/O
# and fork latency to protect data you are happy to lose.
save ""
appendonly no
# Don't let a slow disk stop writes when snapshotting is off anyway.
stop-writes-on-bgsave-error noOn sizing: give Redis a quarter of the VM's memory at most, and be honest that the kernel page cache also wants room. On a 4 GiB app VM, 256–512 MB of Redis is a sane starting point. Then measure — if evictions are near zero you can shrink it, and if your hit rate is poor because keys are being evicted in seconds, that's the signal to grow it or cache less.
Step 3: Start it before your app, and keep it started
The naive version is a single shell line that starts Redis in the background and then execs your app. It works, and it has one flaw worth understanding: if Redis dies, nothing restarts it, and your app now fails every cache call.
# Start command -- simple version.
redis-server ./redis.conf --daemonize yes && node server.jsThe better version is a supervisor loop, which is a few more lines and removes the whole failure class. This runs Redis under a restart loop in the background, waits for it to actually accept connections, then starts the app in the foreground so the platform's health checks and log capture still see your process:
#!/bin/sh
# start.sh -- Redis supervised, app in the foreground.
set -e
# Restart Redis forever if it exits, with a small backoff.
( while true; do
redis-server ./redis.conf || true
echo "redis exited, restarting in 1s" >&2
sleep 1
done ) &
# Wait for it to answer before starting the app, so the first
# request after a deploy doesn't hit a connection refused.
for i in $(seq 1 30); do
redis-cli ping >/dev/null 2>&1 && break
sleep 0.2
done
exec node server.jsThe exec on the last line matters. Without it your app runs as a child of the shell and signals go to the wrong process, which means a graceful shutdown isn't graceful.
Step 4: Make the client tolerant of Redis being gone
This is the step people skip, and it's the one that turns a co-located cache from a liability into an asset. Your app must treat a cache failure as a cache miss. If a Redis hiccup can throw an unhandled exception into a request handler, you have coupled your availability to a process you explicitly said was disposable.
// cache.js -- every failure degrades to a miss, never to an error.
import Redis from "ioredis";
const redis = new Redis({
host: "127.0.0.1",
port: 6379,
// Fail fast: localhost either answers immediately or is down.
connectTimeout: 200,
commandTimeout: 100,
// Don't queue commands while disconnected -- they'd all time out
// together and stall the event loop when it reconnects.
enableOfflineQueue: false,
maxRetriesPerRequest: 1,
});
// An error event with no listener is an unhandled rejection in Node.
redis.on("error", (err) => {
if (err.code !== "ECONNREFUSED") console.warn("redis:", err.message);
});
export async function cached(key, ttlSeconds, compute) {
try {
const hit = await redis.get(key);
if (hit !== null) return JSON.parse(hit);
} catch {
// Cache down. Fall through and compute -- slower, still correct.
}
const value = await compute();
try {
await redis.set(key, JSON.stringify(value), "EX", ttlSeconds);
} catch {
// Couldn't store it. Nothing to do; return the value anyway.
}
return value;
}The timeouts are deliberately aggressive. Against localhost, any command that hasn't answered in 100ms is not going to answer usefully — there is no network to blame. Long timeouts here mean a stuck Redis turns into a stuck request queue.
Step 5: Know whether it's actually helping
A cache nobody measures is an article of faith. Three numbers tell you everything, and Redis reports all of them:
# Hit rate: keyspace_hits / (keyspace_hits + keyspace_misses).
# Below ~80% for a warm cache usually means your keys are too specific.
redis-cli info stats | grep -E 'keyspace_(hits|misses)'
# Evictions climbing fast means maxmemory is too small for your
# working set -- or you are caching things nobody reads twice.
redis-cli info stats | grep evicted_keys
# Memory actually used vs your ceiling.
redis-cli info memory | grep -E 'used_memory_human|maxmemory_human'Export those into whatever you already use for metrics. On PandaStack you can pull them from the running app with an exec call and ship them alongside your other app metrics — there's a full walkthrough in the fleet monitoring guide.
When to move to a real managed Redis
Be honest about the exit conditions, and set them before you need them:
- A second service needs the same keys. This is the hard one — the moment two machines need a shared view, co-location is incorrect, not just suboptimal.
- You start storing something you'd miss. The first time someone puts a job queue or a payment idempotency key in the cache, you now need persistence and eviction guarantees you deliberately turned off.
- You scale to more than one instance of the app. Each instance gets its own cache, so your hit rate divides by the instance count and invalidation becomes impossible to reason about.
- Redis' memory starts competing with your app's. If you're tuning maxmemory down to keep the app alive, the cache has outgrown the box.
The summary
Install Redis into the same machine as your app, bind it to loopback, set a hard maxmemory with allkeys-lru, turn persistence off, supervise it with a restart loop, and write a client wrapper where every failure path returns a cache miss instead of an exception. That gets you sub-millisecond cache reads with no network hop, no extra bill, and no new thing to be paged about. Then watch the hit rate and eviction count, and move to a managed instance the day a second service needs the same keys — not before.
Frequently asked questions
Is running Redis on the same machine as my app bad practice?
Not inherently — it is bad practice for shared state and good practice for a private cache. The rule is whether more than one machine needs the same keys. A single service caching its own derived data gets sub-millisecond reads, no network hop and no separate bill, and the failure mode is a cold cache. The moment a second service needs to read or invalidate those keys, co-location is wrong and you need a shared managed instance.
How much memory should I give a co-located Redis?
Start at roughly a quarter of the VM's RAM and set it as an explicit maxmemory, never leave it unbounded. On a 4 GiB app VM, 256–512 MB is a reasonable opening bid. Leave real headroom, because your application process and the kernel page cache both need memory, and Redis exceeding the machine's RAM means the OOM killer picks a victim that may well be your app. Then tune from the evicted_keys counter: near zero means you can shrink it, rapid eviction means your working set is bigger than the ceiling.
Should a co-located Redis persist to disk?
Usually not. Persistence exists to protect data you cannot rebuild, and the entire premise of the co-located pattern is that the contents are derived and disposable. RDB snapshotting costs fork latency and disk I/O on a schedule; AOF costs a write amplification on every command. Set save "" and appendonly no. If you find yourself wanting persistence, that is a strong signal the data has stopped being a cache and needs to move somewhere durable.
What happens to my cache when I deploy?
It starts empty. A co-located Redis lives and dies with the machine, so a new deployment gets a cold cache and the first requests after a release are slower while it refills. This is usually fine and occasionally is not — if your cache fronts an expensive computation and a cold start would overload the backing service, add jittered TTLs so keys don't all expire together, and consider a request-coalescing lock so a hundred concurrent misses trigger one recomputation rather than a hundred.
Can I reach a sandbox's Redis port from outside?
On PandaStack, not today. The per-port URLs are implemented as an HTTP reverse proxy, so they require an HTTP request to route and a raw RESP client on 6379 gets a 400 back rather than a connection. Managed Postgres is the one exception and it is a bespoke path that understands the Postgres handshake specifically. If you need a Redis that multiple machines can reach, use a managed provider — the co-located pattern here is deliberately localhost-only.
Keep reading
- The best managed Redis providers in 2026
- How to monitor a sandbox fleet
- Running background workers alongside web apps
- Managing environment variables and secrets
- App hosting on PandaStack — a full Linux userspace, not a function runtime
49ms p50 cold start. Fork, snapshot, and scale to zero.