Tutorial

Docker Compose for Production-Like Local Dev: My Exact Setup

May 18, 20268 min readby Rahul Patel

The exact Docker Compose setup I use on every new Node.js project. Postgres, Redis, workers, health checks, named volumes, the node_modules trick, and the new compose watch workflow.

DockerDevOpsNode.jsTutorial
Share:

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:
Tip

The 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.

more from the notebook

March 15, 2026Building

How I Built a Zero-Dependency npm Package from Scratch

The story behind auto-api-observe a zero-dependency Express/Fastify observability middleware. Architecture decisions, A…

March 1, 2026Technical

AsyncLocalStorage in Node.js: The Complete Guide with Real Examples

Everything you need to know about AsyncLocalStorage from basic context tracking to building a full request-scoped loggi…

January 25, 2026Tutorial

Next.js 15 Static Export to GitHub Pages The Complete Guide

Deploy Next.js 15 App Router to GitHub Pages with static export. Covers next.config, GitHub Actions, fonts, images, trai…