MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr πŸ”₯ tagline

Hey there πŸ‘‹ I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 β€” present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 β€” Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms β€” earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Docker Compose: Multi-Container Development Environments

Set up complex local dev environments with Docker Compose: services, networks, volumes.

DockerDocker ComposeDevOps

By MinhVo

Introduction

Modern applications rarely run in isolation. A typical web application involves a web server, application runtime, database, cache layer, message queue, and potentially dozens of supporting services. Managing these individually with raw Docker commands is tedious and error-prone. Docker Compose solves this by letting you define your entire application stack in a single YAML file and orchestrate everything with one command.

Docker Compose has become the de facto standard for local development environments across the industry. From startups to enterprises, teams rely on it to ensure every developer runs identical infrastructure, eliminating the "works on my machine" problem that has plagued software teams for decades.

Docker container orchestration concept

Understanding Docker Compose: Core Concepts

The Compose Specification

Docker Compose uses a declarative YAML file (typically named docker-compose.yml or compose.yml) to describe your application's services, networks, volumes, and configuration. The Compose Specification is now an open standard maintained by the Open Container Initiative, meaning tools beyond Docker can interpret and execute Compose files.

The file structure breaks down into four top-level keys:

  • services: Container definitions for each component of your application
  • networks: Custom network configurations for service isolation
  • volumes: Persistent data storage definitions
  • configs and secrets: Runtime configuration and sensitive data management

Service Resolution and DNS

When Docker Compose creates your application, it automatically provisions a dedicated bridge network. Every service registered in your Compose file becomes a DNS entry on that network. This means your Node.js API can reach PostgreSQL simply by using db as the hostnameβ€”no IP addresses, no port mapping on the internal network. This built-in service discovery mirrors how services find each other in production orchestration platforms like Kubernetes.

services:
  api:
    image: node:18-alpine
    environment:
      # 'db' resolves to the PostgreSQL container's IP
      DATABASE_URL: postgres://user:pass@db:5432/myapp
      # 'redis' resolves to the Redis container's IP
      REDIS_URL: redis://redis:6379
    depends_on:
      - db
      - redis
 
  db:
    image: postgres:15-alpine
 
  redis:
    image: redis:7-alpine

Dependency Management

The depends_on key controls startup order, but it only guarantees that a container has startedβ€”not that the service inside is ready. For databases and other services that need initialization time, use the condition form with health checks:

services:
  api:
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
 
  db:
    image: postgres:15-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user -d myapp"]
      interval: 5s
      timeout: 5s
      retries: 5
      start_period: 10s

Volume Types

Docker Compose supports three volume types, each serving different purposes:

TypeSyntaxUse CasePersistence
Named volumespostgres_data:/var/lib/pg/dataDatabase storageSurvives docker compose down
Bind mounts./src:/app/srcSource code hot-reloadHost filesystem
Anonymous volumes/app/node_modulesPrevent host overrideRecreated on each up
tmpfs mounts/tmpEphemeral runtime dataLost on stop

Container networking and volumes

Architecture and Design Patterns

The Reverse Proxy Pattern

Placing a reverse proxy (Nginx, Traefik, or Caddy) as the single entry point simplifies routing and enables SSL termination, load balancing, and request logging without modifying application services:

services:
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./certs:/etc/nginx/certs:ro
    depends_on:
      - frontend
      - api
 
  frontend:
    build: ./frontend
    # No exposed portsβ€”only accessible via nginx
 
  api:
    build: ./api
    # No exposed portsβ€”only accessible via nginx

The Sidecar Pattern

Sidecar containers augment your main application with cross-cutting concerns like logging, monitoring, or configuration management. Compose makes this natural:

services:
  api:
    build: ./api
    volumes:
      - shared_logs:/var/log/app
 
  log-shipper:
    image: fluent/fluentd:latest
    volumes:
      - shared_logs:/var/log/app:ro
      - ./fluentd.conf:/fluentd/etc/fluent.conf:ro
    depends_on:
      - api
 
volumes:
  shared_logs:

Multi-Stage Development/Production Builds

Use separate Compose files or profiles to differentiate between development and production configurations:

# compose.yml (base)
services:
  api:
    build:
      context: ./api
      target: production
    environment:
      NODE_ENV: production
 
# compose.override.yml (auto-loaded in development)
services:
  api:
    build:
      target: development
    environment:
      NODE_ENV: development
    volumes:
      - ./api/src:/app/src
    command: npm run dev

Step-by-Step Implementation

Step 1: Project Structure

project/
β”œβ”€β”€ docker-compose.yml
β”œβ”€β”€ .env
β”œβ”€β”€ frontend/
β”‚   β”œβ”€β”€ Dockerfile
β”‚   β”œβ”€β”€ package.json
β”‚   └── src/
β”œβ”€β”€ api/
β”‚   β”œβ”€β”€ Dockerfile
β”‚   β”œβ”€β”€ package.json
β”‚   └── src/
β”œβ”€β”€ nginx/
β”‚   └── nginx.conf
β”œβ”€β”€ db/
β”‚   └── init.sql
└── scripts/
    └── wait-for-it.sh

Step 2: Application Dockerfiles

Create optimized Dockerfiles with multi-stage builds for both development and production:

# api/Dockerfile
FROM node:18-alpine AS base
WORKDIR /app
COPY package*.json ./
 
FROM base AS development
RUN npm ci
COPY . .
EXPOSE 4000
CMD ["npm", "run", "dev"]
 
FROM base AS production
RUN npm ci --omit=dev
COPY . .
RUN npm run build
EXPOSE 4000
CMD ["node", "dist/index.js"]
# frontend/Dockerfile
FROM node:18-alpine AS base
WORKDIR /app
COPY package*.json ./
 
FROM base AS development
RUN npm ci
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
 
FROM base AS build
RUN npm ci
COPY . .
RUN npm run build
 
FROM nginx:alpine AS production
COPY --from=build /app/build /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

Step 3: Complete Compose Configuration

version: '3.8'
 
services:
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - frontend
      - api
    restart: unless-stopped
 
  frontend:
    build:
      context: ./frontend
      target: development
    environment:
      - REACT_APP_API_URL=/api
    volumes:
      - ./frontend/src:/app/src
      - ./frontend/public:/app/public
      - /app/node_modules
    depends_on:
      - api
 
  api:
    build:
      context: ./api
      target: development
    environment:
      - NODE_ENV=development
      - DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
      - REDIS_URL=redis://cache:6379
      - JWT_SECRET=${JWT_SECRET}
    volumes:
      - ./api/src:/app/src
      - /app/node_modules
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
 
  db:
    image: postgres:15-alpine
    environment:
      POSTGRES_USER: ${DB_USER:-appuser}
      POSTGRES_PASSWORD: ${DB_PASSWORD:-apppass}
      POSTGRES_DB: ${DB_NAME:-myapp}
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./db/init.sql:/docker-entrypoint-initdb.d/01-init.sql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-appuser} -d ${DB_NAME:-myapp}"]
      interval: 5s
      timeout: 5s
      retries: 5
      start_period: 10s
 
  cache:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    command: redis-server --appendonly yes
 
  mailhog:
    image: mailhog/mailhog:latest
    ports:
      - "1025:1025"   # SMTP
      - "8025:8025"   # Web UI
 
volumes:
  postgres_data:
  redis_data:

Step 4: Environment Configuration

# .env
DB_USER=appuser
DB_PASSWORD=secretpassword
DB_NAME=myapp
JWT_SECRET=your-jwt-secret-change-in-production

Step 5: Essential Commands

# Start all services in detached mode
docker compose up -d
 
# View logs for all services
docker compose logs -f
 
# View logs for a specific service
docker compose logs -f api
 
# Check service status and health
docker compose ps
 
# Execute a command in a running container
docker compose exec api npm run migrate
 
# Run a one-off command
docker compose run --rm api npm run seed
 
# Rebuild images after Dockerfile changes
docker compose build --no-cache
 
# Stop and remove containers (preserves volumes)
docker compose down
 
# Stop and remove everything including volumes
docker compose down -v
 
# Scale a service (for stateless services)
docker compose up -d --scale api=3

Development workflow

Real-World Use Cases

Use Case 1: Microservices Development Team

A team of 12 developers building an e-commerce platform runs 8 interconnected services locally using Docker Compose: a Next.js storefront, two Node.js APIs (catalog and orders), PostgreSQL, Redis, Elasticsearch, RabbitMQ, and an Nginx reverse proxy. New developers clone the repository, run docker compose up, and have a fully functional environment within 5 minutes. The Compose file serves as living documentation of the system architecture.

Use Case 2: CI/CD Pipeline Testing

A SaaS company creates isolated Compose environments in their CI pipeline for each pull request. The pipeline spins up the full stack, runs integration tests against real databases and message queues, captures logs on failure, and tears everything down. This approach caught 23 integration bugs in the first quarter that unit tests alone would have missed.

Use Case 3: Legacy Monolith Migration

A financial services company is incrementally decomposing a PHP monolith into Go microservices. During the multi-year migration, Docker Compose runs both the legacy PHP application and new Go services side by side. Shared networks allow the new services to communicate with the monolith's database while new databases are being provisioned.

Use Case 4: Demo and Staging Environments

A developer relations team uses Docker Compose to create reproducible demo environments for conference talks and customer demonstrations. Each demo scenario has its own Compose override file that configures specific data sets, feature flags, and UI themes. The team can spin up any demo scenario in seconds.

Best Practices for Production

  1. Pin image versions explicitly: Use specific tags like postgres:15.4-alpine instead of latest. This prevents unexpected breaking changes when upstream images update and ensures every developer and CI server runs identical infrastructure.

  2. Implement health checks for all stateful services: Without health checks, depends_on only waits for a container to start, not for the service to accept connections. Health checks ensure your application waits for PostgreSQL to be ready to accept queries, not just for the process to start.

  3. Use .env files for environment-specific values: Keep secrets and environment-specific configuration out of docker-compose.yml and version control. Use .env files with .env.example templates that document required variables without exposing actual values.

  4. Leverage named volumes for persistent data: Named volumes persist data across docker compose down and docker compose up cycles. They can be independently backed up, restored, and shared between containers.

  5. Use anonymous volumes to protect container paths: Mounting /app/node_modules as an anonymous volume prevents your host's node_modules from overriding the container's installed dependencies when using bind mounts for source code.

  6. Configure restart policies: Add restart: unless-stopped to critical services so they automatically recover from crashes during development without requiring manual intervention.

  7. Separate base and override configurations: Use docker-compose.yml for shared configuration and docker-compose.override.yml for development-specific settings. This keeps production configurations clean and development overrides local.

  8. Use Compose profiles for optional services: Group optional services like debug tools, monitoring agents, or documentation servers behind profiles to keep the default startup lean.

Common Pitfalls and Solutions

PitfallImpactSolution
Using latest image tagsNon-reproducible builds, unexpected breaking changesPin to specific version tags like postgres:15.4-alpine
No health checks on databasesRace conditions, connection refused errorsAdd healthcheck with pg_isready, mysqladmin ping, etc.
Hardcoded secrets in compose fileSecrets committed to version controlUse .env files with .gitignore, or Docker secrets
Host node_modules overriding containerMissing or incompatible dependenciesAdd anonymous volume: /app/node_modules
No .dockerignore fileLarge build contexts, slow image buildsCreate .dockerignore excluding node_modules, .git, etc.
Exposing database ports to hostSecurity risk, port conflicts on teamsOnly expose ports needed for debugging; use internal networking
Not using depends_on with conditionsIntermittent startup failuresUse condition: service_healthy for databases and caches
Forgetting volume cleanupDisk space exhaustion over timePeriodically run docker system prune and docker volume prune

Performance Optimization

Development performance on macOS and Windows suffers from file system overhead with bind mounts. Several strategies mitigate this:

services:
  api:
    build: ./api
    volumes:
      # Use delegated consistency for macOS performance
      - ./api/src:/app/src:delegated
      # Anonymous volume to prevent host override
      - /app/node_modules
      # tmpfs for ephemeral data
    tmpfs:
      - /tmp
      - /app/.cache
    deploy:
      resources:
        limits:
          cpus: '1.0'
          memory: 512M
        reservations:
          cpus: '0.25'
          memory: 128M

For macOS, enable VirtioFS in Docker Desktop settings for dramatically faster file system performance. Alternatively, use Docker's mutagen file synchronization for near-native I/O on large codebases.

Optimize image builds by ordering Dockerfile instructions from least to most frequently changed, leveraging the build cache. Place COPY package*.json before COPY . . so dependency installation is cached when only source code changes.

services:
  api:
    build:
      context: ./api
      cache_from:
        - node:18-alpine
        - myapp/api:latest

Comparison with Alternatives

FeatureDocker ComposeVagrantMinikubePodman ComposeOrbstack
Startup timeSecondsMinutesMinutesSecondsSeconds
Memory overheadLow (~50MB)High (~1GB+)High (~2GB+)Low (~50MB)Low (~30MB)
Production parityHighMediumVery highHighHigh
Learning curveLowMediumHighLowVery low
Multi-service nativeYesVia provisioningYesYesYes
Hot reload supportBind mountsNFS/rsyncHost mountsBind mountsVirtioFS
Windows supportExcellentExcellentGoodGoodLimited
Community ecosystemVery largeLargeLargeGrowingGrowing

Advanced Patterns

Dynamic Configuration with Environment Interpolation

services:
  api:
    image: myapp/api:${TAG:-latest}
    environment:
      LOG_LEVEL: ${LOG_LEVEL:-info}
      WORKERS: ${WORKERS:-4}
    ports:
      - "${API_PORT:-4000}:4000"

Compose Watch for Automatic Rebuilds

Docker Compose v2.22+ introduces watch mode for automatic synchronization:

services:
  frontend:
    build: ./frontend
    develop:
      watch:
        - action: sync
          path: ./frontend/src
          target: /app/src
        - action: sync
          path: ./frontend/public
          target: /app/public
        - action: rebuild
          path: ./frontend/package.json

Profiles for Optional Services

services:
  api:
    build: ./api
 
  debug-tools:
    image: busybox
    profiles:
      - debug
    command: sleep infinity
    volumes:
      - api_data:/data
 
  monitoring:
    image: prom/prometheus
    profiles:
      - monitoring
    ports:
      - "9090:9090"
# Start only core services
docker compose up -d
 
# Start with debug tools
docker compose --profile debug up -d
 
# Start with monitoring
docker compose --profile monitoring up -d

Testing Strategies

Docker Compose excels at creating isolated test environments:

# compose.test.yml
services:
  test-db:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: test_db
      POSTGRES_USER: test
      POSTGRES_PASSWORD: test
    tmpfs:
      - /var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U test -d test_db"]
      interval: 2s
      timeout: 2s
      retries: 10
 
  test-redis:
    image: redis:7-alpine
    tmpfs:
      - /data
 
  test:
    build:
      context: ./api
      target: development
    command: npm test
    environment:
      DATABASE_URL: postgres://test:test@test-db:5432/test_db
      REDIS_URL: redis://test-redis:6379
      NODE_ENV: test
    depends_on:
      test-db:
        condition: service_healthy
      test-redis:
        condition: service_started
# Run tests in isolated environment
docker compose -f compose.test.yml up --build --abort-on-container-exit
docker compose -f compose.test.yml down -v

Future Outlook

Docker Compose continues to evolve rapidly. The Compose Specification is now an open standard, enabling tools like Podman, OrbStack, and cloud platforms to interpret Compose files natively. Compose Watch brings automatic rebuilds without external tools. Integration with Docker Desktop's Kubernetes support provides a smooth path from Compose to production orchestration.

The trend toward cloud-native development means Compose files increasingly serve as the starting point for deployment manifests. Tools like Kompose convert Compose files to Kubernetes resources, while Docker Desktop's built-in Kubernetes lets developers test their Compose stacks against a real Kubernetes cluster locally.

Conclusion

Docker Compose transformed multi-container development from a complex orchestration challenge into a single-file, single-command workflow. Its declarative approach ensures environment consistency, accelerates developer onboarding, and provides a clear bridge between local development and production deployment.

Key takeaways:

  1. Define your entire application stack in a single docker-compose.yml for reproducible environments
  2. Use health checks with depends_on conditions to handle service startup ordering correctly
  3. Leverage named volumes for persistent data and anonymous volumes to protect container paths
  4. Implement a reverse proxy pattern for unified request routing and SSL termination
  5. Separate development and production configuration with override files and build targets
  6. Use Compose profiles to keep optional services out of the default startup
  7. Optimize bind mount performance with delegated flags and VirtioFS on macOS
  8. Create isolated test environments with tmpfs volumes for fast, disposable test databases

Start by containerizing your simplest service, then progressively add dependencies to your Compose file. The investment in Docker Compose pays dividends in consistency, speed, and confidence across your entire development team.