The gap between local development and production is where bugs live. "It works on my machine" is a meme because it describes a real and persistent failure mode. Docker Compose doesn't eliminate that gap completely, but it shrinks it dramatically and it does so without requiring you to run Kubernetes locally.
This is the exact Docker Compose setup I use on new projects. Not a tutorial with toy examples the actual files, with the decisions explained.
The Goal: Dev/Prod Parity
Dev/prod parity means: the same services run in both environments, the same versions, the same configuration shape. You shouldn't use SQLite locally when production runs PostgreSQL. You shouldn't mock Redis locally when production uses Redis. The only thing that should differ is credentials and URLs.
The Compose File
docker-compose.yml
version: "3.9"
services:
# ─── Application ───────────────────────────────────────────────
app:
build:
context: .
dockerfile: Dockerfile.dev
ports:
- "3000:3000"
volumes:
- .:/app # bind mount: edits on host = live reload
- /app/node_modules # don't override node_modules with host's
environment:
- NODE_ENV=development
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/appdb
- REDIS_URL=redis://redis:6379
env_file:
- .env.local # secrets (API keys etc) not in compose file
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
command: npm run dev
# ─── PostgreSQL ─────────────────────────────────────────────────
postgres:
image: postgres:16-alpine
ports:
- "5432:5432" # expose so you can connect from host with psql/DBeaver
environment:
POSTGRES_DB: appdb
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- postgres_data:/var/lib/postgresql/data # named volume: data survives compose down
- ./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql # run once on first start
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]
interval: 5s
timeout: 5s
retries: 5
# ─── Redis ──────────────────────────────────────────────────────
redis:
image: redis:7-alpine
ports:
- "6379:6379"
command: redis-server --save 60 1 --loglevel warning
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
# ─── Background worker (same image as app, different command) ────
worker:
build:
context: .
dockerfile: Dockerfile.dev
volumes:
- .:/app
- /app/node_modules
environment:
- NODE_ENV=development
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/appdb
- REDIS_URL=redis://redis:6379
env_file:
- .env.local
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
command: npm run worker:dev
volumes:
postgres_data:
redis_data:
TipThe condition: service_healthy in depends_on is the most important line in the file. Without health checks, Docker starts services in order but doesn't wait for them to be *ready*. Your app starts, tries to connect to Postgres before it finishes initializing, and crashes. Health checks fix this.
The Dev Dockerfile
The dev Dockerfile is different from the production one. It installs all dependencies (including devDependencies), doesn't do a multi-stage build, and expects volume mounts for live code.
Dockerfile.dev
FROM node:22-alpine
WORKDIR /app
# Copy package files first — Docker caches this layer if unchanged
COPY package*.json ./
# Install ALL deps including devDependencies
RUN npm ci
# Don't COPY . . here — the volume mount handles that at runtime
# The node_modules installed above will be preserved by the volume exclusion
EXPOSE 3000
# Command is overridden by compose file
CMD ["npm", "run", "dev"]
Environment File Strategy
I use two environment files with a strict separation of concerns:
- `.env.example` — committed to git. Contains all variable names with placeholder values. Documents what config the app needs.
- `.env.local` — gitignored. Contains real secrets (API keys, OAuth tokens). Mounted into the container via
env_file. - Non-secret local-dev values (database URL, Redis URL, ports) go directly in the
environment block of the compose file — they're not secrets, and keeping them in compose makes the setup self-documenting.
Useful Commands
Makefile
# Start all services in the background
up:
docker compose up -d
# Start with logs streaming
up-logs:
docker compose up
# Rebuild the app image (after changing package.json or Dockerfile)
rebuild:
docker compose up -d --build app worker
# Run database migrations inside the app container
migrate:
docker compose exec app npm run db:migrate
# Open a psql shell
psql:
docker compose exec postgres psql -U postgres -d appdb
# Open a Redis shell
redis-cli:
docker compose exec redis redis-cli
# Wipe all data volumes and start fresh (destructive!)
reset:
docker compose down -v && docker compose up -d
# View logs for a specific service
logs:
docker compose logs -f $(service)
The Named Volume Trick for node_modules
The - /app/node_modules line in the app service's volumes section is subtle but critical. It tells Docker: mount the host directory at /app, but don't overlay the node_modules inside the container with anything from the host. Instead, use the node_modules installed when the image was built.
Without this, the host's node_modules (built for your local OS) would overwrite the container's node_modules (built for Linux). Native modules like bcrypt will crash because the host binary isn't compatible with the Linux container.
docker compose watch (New in Compose 2.22)
docker compose watch is a newer alternative to volume mounts for hot reload. Instead of bind-mounting the entire directory, it watches for file changes and syncs or rebuilds selectively. It's faster for large projects and avoids the node_modules problem entirely.
docker-compose.yml (watch extension)
services:
app:
develop:
watch:
- action: sync # fast: copy changed file into container
path: ./src
target: /app/src
- action: rebuild # slow: full image rebuild (rare)
path: package.json
Run it with docker compose watch instead of docker compose up. I'm migrating all new projects to this pattern.