Compose for Local Dev
Summary
- A local dev stack in Compose runs your app plus its backing services (Postgres, Redis) so the whole system boots with one command.
- Insight: the goal is a fast inner loop - edit code, see it reload, hit a real database - not a production replica.
- Key Concepts: bind mounts for live source, named volumes for data, healthchecks for ordering, service-name DNS for wiring.
- When to Use: any app with a database, cache, or queue that you want teammates to run identically with
docker compose up. - Limitations: this is single-host, not orchestration. It optimizes developer experience, not resilience or scale.
Recipe
- Write a Dockerfile for your app that supports hot reload in dev.
- Declare
app,db, andcacheservices incompose.yaml. - Bind-mount your source into the app container for live edits.
- Use a named volume for Postgres so data survives restarts.
- Gate the app on
dbhealth so it does not race the database. - Run
docker compose up --build --wait.
Working Example
A typical Node app with Postgres and Redis. Adapt the image and command to your language.
services:
app:
build:
context: .
target: dev
command: npm run dev
ports:
- "3000:3000"
volumes:
- .:/app
- /app/node_modules
environment:
DATABASE_URL: "postgres://app:app@db:5432/app"
REDIS_URL: "redis://cache:6379"
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
db:
image: postgres:17
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
timeout: 3s
retries: 5
cache:
image: redis:7
ports:
- "6379:6379"
volumes:
pgdata:The matching multi-stage Dockerfile keeps a dev target with dependencies for reload.
FROM node:22-slim AS base
WORKDIR /app
COPY package*.json ./
FROM base AS dev
RUN npm install
COPY . .
CMD ["npm", "run", "dev"]Bring it up and watch it wait for Postgres before starting the app.
docker compose up --build --wait
docker compose logs -f appDeep Dive
Live reload with bind mounts
- .:/app mounts your working tree into the container so edits are visible instantly.
The trailing - /app/node_modules is an anonymous volume that shadows the host's node_modules, so the container keeps the dependencies it built and does not inherit host-platform binaries.
Your reload tool (nodemon, Vite, air, watchexec) runs inside the container and picks up the mounted changes.
Service discovery
The app talks to Postgres at host db and Redis at host cache, both service names on the project network.
Those hostnames appear in DATABASE_URL and REDIS_URL. Nothing is hardcoded to an IP, so the file is portable across machines.
Data that survives restarts
The pgdata named volume holds Postgres's data directory.
docker compose down stops containers but keeps the volume, so your local data persists. Run docker compose down --volumes when you want a clean database.
Exposing ports for host tools
Publishing 5432 and 6379 lets you connect a GUI client or psql from the host.
In pure app-to-app wiring you do not need these ports, since services reach each other over the internal network. Publish them only for developer convenience.
Seeding and migrations
Run migrations as a one-off container after the database is healthy.
docker compose run --rm app npm run migrateThis reuses the app image and network, so the migration sees the same DATABASE_URL as the app.
Gotchas
The app starts before Postgres is ready. Plain depends_on only orders startup. Add a healthcheck and condition: service_healthy, as shown, so the app waits.
Host node_modules leaks into the container. Without the anonymous /app/node_modules volume, the bind mount overwrites container-installed dependencies with host ones, which breaks native modules. Keep the shadowing volume.
Port already in use. If Postgres is installed on your host, 5432 may be taken. Change the host side, for example "5433:5432", and connect host tools on 5433.
Stale data confuses tests. Named volumes persist across runs. When a test suite assumes a clean slate, tear down with --volumes first.
Secrets committed in YAML. Dev passwords in the file are fine for local throwaway stacks, but never reuse that pattern for anything shared or exposed. Move real values to an untracked .env.
Alternatives
Native services on the host. Running Postgres and Redis directly is lighter but drifts per machine and per OS. Compose gives every teammate the same versions.
Dev Containers. VS Code Dev Containers wrap this pattern with editor integration. They build on the same Compose primitives, so the tradeoff is tooling lock-in versus a plain CLI.
Tilt or Skaffold against a local cluster. If your team already deploys to Kubernetes, developing against kind or minikube keeps dev and prod closer. The cost is more moving parts than a single Compose file.
Testcontainers for integration tests. For programmatic, per-test lifecycles, Testcontainers spins services up from code. Compose is better for the interactive dev loop; Testcontainers for isolated test runs.
FAQs
Should the app image be the same in dev and prod? Use multi-stage builds. Share a base, then split a dev target with reload tooling from a lean production target.
How do teammates get identical versions? Pin image tags like postgres:17 and redis:7, and commit the Compose file. Everyone runs the same stack.
Do I need published ports for the app to reach the database? No. Internal service-name networking handles that. Ports are only for host access.
Where do migrations run? As a one-off docker compose run after the database is healthy, using the app image so they share config.
Can I use this stack in CI? Yes. docker compose up --build --wait plus docker compose run for tests works well, then docker compose down --volumes to clean up.
How do I reset local data fast? docker compose down --volumes removes named volumes, giving you a fresh database on the next up.
Related
- Compose Basics
- The Compose Model: Declaring a Local Stack
- Profiles & Overrides
- Compose vs Kubernetes Gap
- Compose Best Practices
Stack versions: This page was written for Kubernetes 1.36.2, Docker Engine 29.6.1 (BuildKit default), containerd (CRI runtime on nodes), Helm 3, Compose v2, Argo CD (latest - verify at build), and Gateway API (GA - verify controller support at build).