Adding a Redis Cache to a DevContainer Compose Stack
Your app needs a cache, and running Redis as a Compose service keeps the devcontainer close to production. This page adds Redis to the stack with a health check and shared networking, so the app reaches it at the hostname redis and the workspace waits until it's ready.
This task matters because a cache that only exists on the reviewer's laptop, or that has to be installed with a README step, is a cache that drifts. The moment Redis becomes a Compose service pinned to redis:7-alpine, it is versioned alongside the app, rebuilt from the same docker compose up, and torn down cleanly when the devcontainer is rebuilt. Everyone who opens the folder gets the same Redis on the same port with the same readiness behaviour, which removes an entire category of "works on my machine" bug reports that trace back to a locally installed Redis at a different version, or to no Redis at all. The cache stops being an environmental assumption and becomes part of the repository's declared topology.
You reach for this when the application already talks to Redis in production — for session storage, rate limiting, a job queue, or a hot read-through cache — and you want the development environment to exercise the same client code paths against a real server rather than a mock. The mental model is deliberately small: Redis is a stateless sidecar on the same user-defined bridge network as the workspace, discovered by its service name, gated by a health check so nothing connects before it answers. You are not building a durable datastore here; you are wiring up an ephemeral, disposable cache that behaves like the production one at the protocol level while owning none of its data. Keeping that distinction clear is what makes the rest of the configuration short.
Prerequisites
You need a Compose-backed devcontainer.
- A
devcontainer.jsonusingdockerComposeFileand a workspaceservice. - A shared user-defined bridge network.
- The app's Redis connection settings.
These prerequisites are less about installing anything and more about confirming the shape of the stack you already have. A Compose-backed devcontainer means your devcontainer.json points at a dockerComposeFile and names a workspace service — that service is the container VS Code attaches to, and it is the one that will hold the depends_on and the REDIS_URL. The shared user-defined bridge network is the piece that makes name resolution possible: Compose gives you one automatically when you declare a top-level networks key and attach services to it, and that embedded DNS is what turns the literal string redis into an address. Have the app's Redis connection settings on hand too, because the only application-side change is repointing the host from wherever it lives today to the service name.
The detail people get wrong is assuming the default Compose network is enough and skipping the explicit networks declaration entirely. It often appears to work, because Compose attaches every service in a file to a default network, but the moment the workspace service is defined in the devcontainer's own Compose file and Redis in another, or someone overrides the network for one service and not the other, the two land on different bridges and name resolution silently fails. Declaring devnet explicitly and attaching both services to it removes that ambiguity, so the topology is stated rather than inferred.
Step-by-Step Implementation
- Declare the Redis service.
services:
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
networks: [devnet]
networks: { devnet: {} }
The redis:7-alpine tag pins a specific major version on a small base image, which keeps the pull fast and the behaviour predictable across rebuilds — an unpinned redis:latest would quietly move under you the next time the cache is rebuilt. The healthcheck runs redis-cli ping inside the container and treats the PONG reply as the readiness signal; the interval: 5s, timeout: 3s, and retries: 5 together mean Compose probes every five seconds and only marks the service unhealthy after five consecutive failures, giving Redis roughly twenty-five seconds of grace before anything gives up. Attaching the service to networks: [devnet] and declaring devnet at the top level is what puts Redis on the shared bridge, so the failure this prevents is the app resolving redis to nothing because the two containers were never on the same network.
- Gate the workspace on Redis health.
depends_on:
redis: { condition: service_healthy }
This block belongs on the workspace service, not on Redis, and the condition: service_healthy form is doing the important work. A bare depends_on: [redis] only waits for the Redis container to be started, which happens long before the server is actually accepting connections; the long-form condition instead waits for the health check above to report healthy. That is the difference between the app booting into a connection-refused error during the container's first second and the app booting into a Redis that already answers PONG. The failure mode it prevents is the classic startup race, where the workspace wins the boot and your client library throws before Redis has bound its port — a race that is intermittent enough to pass locally and fail in CI, which is exactly the kind of flake worth designing out.
- Point the app at the hostname
redis.
REDIS_URL=redis://redis:6379
The host in this URL is redis — the Compose service name — and not localhost or 127.0.0.1, which is the single most common source of confusion when a service moves into Compose. Inside the workspace container, localhost refers to the workspace itself, so a redis://localhost:6379 that worked when Redis ran on the host now points at nothing. Compose's embedded DNS resolves the service name to the Redis container's address on devnet, and 6379 is Redis's default port, which needs no ports: mapping because the traffic stays inside the bridge network rather than crossing to the host. Setting this as REDIS_URL rather than editing code keeps the endpoint as environment configuration, so the same image runs unchanged against a different Redis in staging or production.
- Verify the app reaches it.
devcontainer exec --workspace-folder . -- redis-cli -h redis ping
Running the check through devcontainer exec --workspace-folder . is deliberate: it executes redis-cli -h redis ping from inside the workspace container, which is the vantage point that actually matters, rather than from your host where the name redis and the devnet bridge do not exist. A PONG here proves three things at once — the two services share a network, DNS resolves the service name, and Redis is accepting connections — so it is the fastest way to confirm the wiring before the app itself tries to connect. If this command hangs or returns a resolution error, you have isolated the problem to the Compose networking layer and can skip straight to the network checks rather than debugging application code that was never at fault.
Common Pitfalls
Redis issues are a startup race or a name that won't resolve.
If you do decide to persist the cache with a volume, ownership becomes the pitfall that catches people out. The redis:7-alpine image runs its server process as an unprivileged redis user, and when you mount a host directory or a named volume at /data, the mount can arrive owned by root or by a UID that the container's redis user cannot write to. Redis then fails to snapshot or, with append-only persistence enabled, refuses to start because it cannot open its data file. A named Compose volume usually sidesteps this because Docker initialises its ownership from the image, but a bind mount to a host path does not — so prefer a named volume for Redis data, and if you must bind-mount, be ready to chown the directory to the redis UID inside the container. This is the same ownership friction that any stateful sidecar introduces, and it is the reason a cache is best left ephemeral unless you have a concrete need for warmed data.
The other topic-specific trap is a health check that reports healthy while the server is still unusable. redis-cli ping returns PONG as soon as the server accepts connections, which is the right signal for a plain cache, but if you have configured Redis with a password via requirepass, an unauthenticated ping can still succeed at the connection level while every real command fails with a NOAUTH error. In that case the health check is lying by omission — the app clears the service_healthy gate and then cannot run a single authenticated command. When you add authentication, add -a (or the REDISCLI_AUTH environment variable) to the health check's redis-cli invocation so the probe exercises the same credentialed path the app uses, keeping the readiness signal honest.
| Symptom | Root Cause | Remediation |
|---|---|---|
| App can't reach redis | Not on the shared network | Put both services on the devnet bridge |
| App connects before Redis is ready | No health gate | Add healthcheck + service_healthy |
| Cache lost on rebuild (if persisted) | No volume for data | Mount a volume if you need persistence |
| Wrong host in the app | Hardcoded localhost | Use the service name redis |
Conclusion
Adding Redis is a small Compose service with a health check and shared networking: the app reaches it as redis, and the workspace waits until redis-cli ping succeeds. Ephemeral by default, it matches a cache's role; add a volume only if you need persistence.
The strategic payoff is that the cache now lives inside the same reproducibility contract as the rest of the stack. Pinning redis:7-alpine is the cache equivalent of pinning a language runtime or a base image: the version is declared, so a rebuild months from now brings back the same Redis rather than whatever latest has drifted to, and a new contributor gets a working cache from a single clone-and-open with no side instructions. The health-gated depends_on extends the same discipline to timing, converting an implicit assumption — "Redis will be up by the time we connect" — into an enforced ordering. Together, pinning and health-gating turn the cache from an environmental accident into a stated, repeatable part of the topology.
That framing also keeps the boundary between cache and database honest, which is the theme that ties this page to the rest of the Compose stack. Because Redis here holds nothing you cannot regenerate, you are free to docker compose down and rebuild without ceremony. A backing store like Postgres earns a named volume because its data is the source of truth; a cache does not, and persisting it "just in case" reintroduces the drift the whole exercise was meant to remove. Keep Redis ephemeral, reachable by name, and gated on readiness, and it stays the low-maintenance, production-shaped sidecar it should be.
FAQ
Should Redis data persist across rebuilds in dev?
Usually not — a cache is ephemeral by nature, so the default in-memory service is fine and keeps rebuilds clean. If your workflow depends on warmed cache data, mount a volume at Redis's data directory, but for most dev use an ephemeral cache is correct. Persisting the cache also means inheriting the ownership and snapshot concerns that come with /data, and it blurs the line between the cache and the real datastore. A good rule of thumb: if losing the data on rebuild would cost you nothing but a moment of recomputation, leave it ephemeral, and reserve volumes for the services whose data is genuinely the source of truth.
Why can't my app reach redis?
The app and Redis must share a user-defined bridge network for name resolution. Put both on the same network and connect to the hostname redis (the service name), not localhost — inside the app container, localhost is the app, not Redis. The quickest way to confirm the wiring is redis-cli -h redis ping from inside the workspace: a PONG proves DNS resolves and Redis is listening, while a resolution error points squarely at the network declaration. Check that both services list devnet under networks and that devnet is declared at the top level of the Compose file.
How do I make the app wait for Redis?
Add a healthcheck using redis-cli ping and set the workspace's depends_on to condition: service_healthy, so the app starts only once Redis is accepting connections rather than racing its startup. The plain list form of depends_on only waits for the container to start, which is not the same as the server being ready, so the long-form condition is what actually closes the race. With interval: 5s and retries: 5, Compose gives Redis a sensible window to come up before declaring it unhealthy, and the workspace holds until that window resolves.
Do I need to publish port 6379 to the host?
No, not for the app to reach Redis. Because both containers sit on the devnet bridge, traffic to redis:6379 never leaves the internal network, and no ports: mapping is required. You only add a ports: entry if you want to connect from the host itself, such as pointing a desktop Redis GUI at the container.
Which Redis version should the image pin to?
Match the major version you run in production, and pin it explicitly as in redis:7-alpine rather than trusting latest. Pinning is what makes the cache reproducible: the same tag rebuilds the same server behaviour every time, so a config option or command that works in the devcontainer also works in the environment you deploy to. The -alpine variant keeps the image small and the pull quick, which matters when the cache is rebuilt often as part of a disposable devcontainer.
Related
- Up to Docker Compose Integration for Multi-Service Apps — the Compose model.
- Configuring a Postgres Service in a DevContainer Compose Stack — a persistent backing service.
- Resolving DNS Failures in Compose Networks — when the name won't resolve.