mdashikjs/blog
All posts
Docker Compose for Local Development That Mirrors Production
DevOps

Docker Compose for Local Development That Mirrors Production

DevOps5 min

Docker Compose for Local Development That Mirrors Production

The 'works on my machine' problem is a configuration problem. Docker Compose gives every developer the same PostgreSQL, Redis, and Elasticsearch versions as production — with one command.

DockerDocker ComposeDevelopmentDevOps
Share:

The development compose file

# docker-compose.yml
services:
  api:
    build:
      context: .
      dockerfile: Dockerfile.dev
    ports:
      - "3000:3000"
    volumes:
      - .:/app
      - /app/node_modules
    environment:
      - DATABASE_URL=postgresql://dev:dev@postgres:5432/myapp
      - REDIS_URL=redis://redis:6379
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_started

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: dev
      POSTGRES_PASSWORD: dev
      POSTGRES_DB: myapp
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U dev"]
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

  mailpit:
    image: axllent/mailpit
    ports:
      - "1025:1025"
      - "8025:8025"

volumes:
  postgres_data:

The dev Dockerfile

FROM node:20-alpine
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install
COPY . .
CMD ["pnpm", "dev"]

Notice the volume mount: .:/app syncs your local code into the container, but /app/node_modules is excluded so the container uses its own installed dependencies. This avoids platform mismatches (macOS node_modules inside a Linux container).

Health checks prevent race conditions

Without the healthcheck on PostgreSQL, your API container starts before the database is ready to accept connections. The depends_on with condition: service_healthy waits for PostgreSQL to actually respond to queries, not just start the process.

Seeding data

Mount an init script to populate the database on first start:

-- scripts/init.sql
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

INSERT INTO users (id, email, name, role) VALUES
  (uuid_generate_v4(), 'admin@test.com', 'Admin User', 'admin'),
  (uuid_generate_v4(), 'user@test.com', 'Test User', 'user');

This only runs when the volume is empty (first docker compose up). To re-seed, delete the volume: docker compose down -v.

Mailpit for email testing

Mailpit captures all outgoing email on port 1025 and shows it in a web UI on port 8025. Point your app's SMTP config at mailpit:1025 and every email your app sends is visible at http://localhost:8025. No more sending test emails to real addresses.

Matching production versions

This is the whole point. If production runs PostgreSQL 16, your compose file should too. Not 15, not "latest." Pin the exact major version:

postgres:
  image: postgres:16-alpine  # Match production

I have seen bugs caused by PostgreSQL version differences — a query that uses a feature added in 15 will fail silently or behave differently in 14.

Useful commands

docker compose up -d              # Start everything in background
docker compose logs -f api        # Follow API logs
docker compose exec postgres psql -U dev myapp  # Database shell
docker compose down               # Stop everything
docker compose down -v            # Stop and delete volumes (fresh start)

The one rule

If a new developer cannot run docker compose up and have a working environment within 5 minutes, your compose file is incomplete.

MA

Written by Md Ashik

Senior Software Engineer building reliable backends. I write about the practical tradeoffs behind shipping software that holds up in production.