Docker Compose for Development: Building Reproducible Local Environments | SoniNow Blog

Limited TimeLearn More

dockerdocker composedevopsdevelopmentcontainers

Docker Compose for Development: Building Reproducible Local Environments

Published

2026-06-23

Read Time

4 mins

Docker Compose for Development: Building Reproducible Local Environments

"Works on my machine" is the war cry of every development team that lacks environment reproducibility. Docker Compose eliminates this by defining your entire local stack—services, networks, volumes, and dependencies—in a single declarative YAML file. Every developer, CI runner, and staging host runs the exact same environment.

Structuring a Multi-Service Compose File

A well-designed docker-compose.yml separates concerns while keeping services interconnected. For a typical web application with a database, cache layer, and background worker:

version: "3.9"
services:
  web:
    build:
      context: .
      dockerfile: Dockerfile.dev
    ports:
      - "3000:3000"
    volumes:
      - .:/app
      - /app/node_modules
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_started
    environment:
      DATABASE_URL: postgres://user:pass@postgres:5432/app
      REDIS_URL: redis://redis:6379

  postgres:
    image: postgres:16-alpine
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user"]
      interval: 5s

  redis:
    image: redis:7-alpine
    volumes:
      - redisdata:/data

  worker:
    build:
      context: .
      dockerfile: Dockerfile.dev
    command: npm run worker
    volumes:
      - .:/app
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  pgdata:
  redisdata:

Key design decisions here: named volumes for databases so data survives container restarts, health checks on PostgreSQL so the app doesn't connect before the database is ready, and a bind mount for the application directory with an anonymous volume for node_modules to prevent host-OS binary conflicts.

Volume Mounts and Hot Reloading

The bind mount .:/app is what makes Docker Compose viable for development—file changes on the host propagate instantly into the container. Most frameworks with file watchers (Vite, Next.js, Rails, Django) detect these changes and trigger hot reloads without additional configuration.

Watch out for performance on macOS and Windows: bind mounts through Docker Desktop are significantly slower than native filesystem access. Mounting entire monorepos can degrade file-watching performance. Solutions include using Docker Sync or delegating mounts (,delegated suffix on Docker Desktop).

Networking and Service Discovery

Compose creates a default bridge network and registers each service name as a DNS entry. Services communicate by hostname: the web service connects to postgres:5432, not localhost:5432. This mirrors how Kubernetes service discovery works, giving you production parity at development time.

For testing external services like OAuth providers or third-party APIs, add a service block that mocks the external endpoint:

wiremock:
  image: wiremock/wiremock:latest
  ports:
    - "8080:8080"
  volumes:
    - ./mocks:/home/wiremock
  command: --global-response-templating

Environment Overrides and Profiles

Use multiple Compose files to layer configuration. A docker-compose.override.yml is automatically merged when running docker compose up. Keep production-like service definitions in the base file and add development conveniences (additional ports, debuggers, profiling tools) in overrides.

Compose profiles let developers spin up only the services they need:

profile: frontend-only
services:
  web:
    profiles: [full, frontend]
  worker:
    profiles: [full, worker]

A frontend developer runs docker compose --profile frontend up and skips the worker container entirely, saving memory and startup time.

Production Parity Considerations

The Dockerfile.dev should differ minimally from the production Dockerfile. Use multi-stage builds to share the base layer:

FROM node:20-alpine AS base
WORKDIR /app
COPY package*.json ./
RUN npm ci

FROM base AS dev
RUN npm install --include=dev
CMD ["npm", "run", "dev"]

FROM base AS production
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"]

Both Dockerfiles start from the same base, ensuring the OS packages and Node version match between development and production. This catches deployment issues at docker compose up, not after git push.

Reproducibility Across Teams

Pin image tags to specific digests instead of latest or major version tags:

postgres:
  image: postgres:16-alpine@sha256:abc123...

Add a .env file to your repository (with a .env.example committed) for secrets-optional configuration. Each developer drops a personal .env with local overrides that never enters the repo. Combine this with env_file directives to keep configuration clean.

Build Reproducible Environments with SoniNow

Docker Compose is the fastest path to a reproducible development environment that matches production behavior. Our team at SoniNow specializes in containerized development workflows and can help your team set up Docker-based environments that eliminate "works on my machine" for good.