← Back to blog

Docker Compose for local development: productive setup

2026-05-24

Docker Compose for Local Development: A Productive Setup

Local development environments have always been a pain point for software teams. The classic "works on my machine" problem persists because each developer's laptop becomes a unique snowflake of installed dependencies, environment variables, and running services. Docker Compose solves this by defining your entire application stack in declarative configuration files, ensuring every team member works with identical infrastructure.

This guide walks through building a productive, real-world Docker Compose setup for local development. You will learn patterns that scale from simple web applications to complex microservices architectures.

Why Docker Compose Over Plain Docker

While docker run commands work for single containers, they become unmanageable quickly. A typical web application needs a database, cache, message queue, and perhaps multiple backend services. Docker Compose lets you define these relationships in a docker-compose.yml file and control them with simple commands.

The key advantages for local development include:

Project Structure for Maintainability

Before diving into configuration, establish a consistent project layout. A well-organized structure separates concerns and makes your setup navigable:

myproject/
├── docker-compose.yml          # Main orchestration file
├── docker-compose.override.yml # Local overrides (gitignored in some patterns)
├── docker/
│   ├── php/
│   │   ├── Dockerfile
│   │   └── php.ini
│   ├── nginx/
│   │   ├── Dockerfile
│   │   └── default.conf
│   └── mysql/
│       └── my.cnf
├── .env                        # Environment variables (gitignored)
├── src/                        # Application source code
└── Makefile                    # Common command shortcuts

This separation allows different environments to extend the base configuration. Your production deployment might use docker-compose.prod.yml while developers work with the default file plus optional overrides.

The Core Compose File: A Practical Example

Let us build a complete setup for a modern web application: a Node.js API with PostgreSQL, Redis for caching, and Nginx as a reverse proxy. This pattern transfers to Python, PHP, Go, or any stack.

version: "3.9"

services:
  api:
    build:
      context: .
      dockerfile: docker/node/Dockerfile
      target: development
    container_name: myproject-api
    restart: unless-stopped
    volumes:
      - ./src:/app:cached
      - /app/node_modules
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=development
      - DATABASE_URL=postgres://postgres:postgres@db:5432/myapp
      - REDIS_URL=redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    networks:
      - backend
    command: npm run dev

  db:
    image: postgres:15-alpine
    container_name: myproject-db
    restart: unless-stopped
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./docker/postgres/init:/docker-entrypoint-initdb.d
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: myapp
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5
    networks:
      - backend

  cache:
    image: redis:7-alpine
    container_name: myproject-cache
    restart: unless-stopped
    volumes:
      - redis_data:/data
    ports:
      - "6379:6379"
    networks:
      - backend

  nginx:
    image: nginx:alpine
    container_name: myproject-nginx
    restart: unless-stopped
    volumes:
      - ./docker/nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
    ports:
      - "80:80"
    depends_on:
      - api
    networks:
      - backend

volumes:
  postgres_data:
  redis_data:

networks:
  backend:
    driver: bridge

Several critical patterns appear in this configuration that deserve explanation.

Optimized Dockerfile for Development

The Node.js service uses a multi-stage Dockerfile with a development target. Building separate stages for development and production prevents bloated production images while keeping local debugging tools available:

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

FROM base AS development
RUN npm install -g nodemon
COPY . .
EXPOSE 3000

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

The development stage includes nodemon for automatic restarts, while the production stage removes devDependencies and compiles assets. Reference the correct target in your Compose file to avoid shipping debugging tools.

Volume Mounting Strategies

The volumes configuration in the API service demonstrates two essential techniques:

For interpreted languages, bind mounts provide instant code reloading. Compiled languages might instead mount only configuration files and use volume triggers or rebuild workflows.

Health Checks and Startup Order

Docker Compose does not guarantee services start in a usable state, only that containers launch. The PostgreSQL service includes a healthcheck that pg_isready executes. The API service then declares depends_on with condition: service_healthy—ensuring it waits for actual database readiness, not just container startup.

Without this pattern, you encounter race conditions where applications crash trying to connect to databases still initializing. Implement health checks for every service your application depends upon.

Environment Management with Dotenv Files

Hardcoding credentials in Compose files creates security risks and reduces flexibility. Docker Compose automatically loads variables from a .env file in the same directory:

# .env
COMPOSE_PROJECT_NAME=myproject
POSTGRES_USER=devuser
POSTGRES_PASSWORD=devpassword
POSTGRES_DB=myapp_dev
API_PORT=3000
NGINX_PORT=8080

Reference these variables in your Compose file with substitution syntax:

services:
  db:
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    ports:
      - "${API_PORT}:3000"

Never commit .env files containing secrets. Provide a .env.example template instead, and document required variables in your README.

Performance Optimization for Mac and Windows

Docker Desktop on macOS and Windows runs containers inside a Linux VM, introducing filesystem performance penalties. Several optimizations mitigate this:

Use VirtioFS on macOS: Docker Desktop 4.6+ includes VirtioFS, dramatically improving bind mount performance. Enable it in Settings > General.

Optimize volume consistency: The delegated and cached flags trade consistency for speed. Use cached when the container rarely writes back to the mounted directory.

Consider docker-sync or mutagen: For large codebases, dedicated synchronization tools outperform native Docker volumes. Mutagen integrates natively with recent Docker Desktop versions.

Store heavy I/O in named volumes: Database files, build caches, and vendor directories belong in named volumes rather than bind mounts:

volumes:
  - vendor_data:/app/vendor

volumes:
  vendor_data:

Debugging and Development Workflows

Productive development requires debugging access. Configure services to expose debugging ports and use Docker Compose exec for interactive sessions:

# Attach to running container with shell
docker compose exec api sh

# Run one-off commands
docker compose exec db psql -U postgres -d myapp

# View logs with follow
docker compose logs -f api

# Restart single service after config changes
docker compose up -d --no-deps --build api

For Node.js debugging, expose the V8 inspector port:

services:
  api:
    ports:
      - "3000:3000"
      - "9229:9229"  # Node inspector
    command: npm run debug  # uses --inspect=0.0.0.0:9229

Attach your IDE's debugger to localhost:9229 using the "Attach to Remote" configuration.

Database Migrations and Seed Data

Automate database preparation so new developers have populated data immediately. The PostgreSQL image executes scripts in /docker-entrypoint-initdb.d on first startup:

-- docker/postgres/init/01-schema.sql
CREATE TABLE IF NOT EXISTS users (
    id SERIAL PRIMARY KEY,
    email VARCHAR(255) UNIQUE NOT NULL,
    created_at TIMESTAMP DEFAULT NOW()
);

-- docker/postgres/init/02-seed.sql
INSERT INTO users (email) VALUES ('dev@example.com');

For migration tools like Flyway or Prisma, add an initialization service that runs migrations before application startup:

services:
  migrate:
    build:
      context: .
      target: development
    command: npx prisma migrate dev
    depends_on:
      db:
        condition: service_healthy
    volumes:
      - ./src:/app:cached
    networks:
      - backend

Testing Integration

Run your test suite in containers matching your development environment. Create a separate Compose override for test execution:

# docker-compose.test.yml
services:
  api:
    environment:
      - NODE_ENV=test
      - DATABASE_URL=postgres://postgres:postgres@test-db:5432/myapp_test
    depends_on:
      - test-db

  test-db:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: myapp_test
    tmpfs:
      - /var/lib/postgresql/data

The tmpfs mount creates an in-memory database, speeding up test execution and ensuring isolation. Execute with: docker compose -f docker-compose.yml -f docker-compose.test.yml run --rm api npm test

Team Onboarding and Documentation

Compress your entire setup into discoverable commands. A Makefile provides self-documenting shortcuts:

# Makefile
.PHONY: up down build logs shell test

up:
	docker compose up -d

down:
	docker compose down -v

build:
	docker compose build --no-cache

logs:
	docker compose logs -f

shell:
	docker compose exec api sh

test:
	docker compose -f docker-compose.yml -f docker-compose.test.yml run --rm api npm test

migrate:
	docker compose exec api npx prisma migrate dev

New team members need only clone, copy .env.example to .env, and run make up.

Common Pitfalls to Avoid

Conclusion

A well-crafted Docker Compose setup eliminates environment inconsistencies and accelerates team productivity. The configurations presented here provide a foundation applicable across technology stacks. Focus on fast feedback loops through volume optimization, reliable startup sequencing with health checks, and discoverable workflows through Makefiles or similar tooling.

Invest time in your local development infrastructure—it pays continuous dividends as your team grows and your application evolves.