Docker Compose: Multi-Container Applications (2026)

Docker Compose defines your entire multi-container application in a single YAML file — services, networks, volumes, environment variables and startup order. Instead of juggling multiple docker run commands, one docker compose up starts everything. Compose V2 (built into Docker CLI as docker compose) is the current standard. This phase covers writing production-grade Compose files, dependency ordering with health checks, environment management, override files for dev/prod parity, profiles for optional services and the new watch mode for live reload.

Compose File Structure

# docker-compose.yml (or compose.yml — Compose V2 prefers the shorter name)
# Top-level keys: services, networks, volumes, configs, secrets

name: myapp   # Project name (default: directory name) — used as prefix for containers

services:
  # Each key under services is a service name
  web:        # → container name: myapp-web-1
    ...
  db:         # → container name: myapp-db-1
    ...
  redis:
    ...

networks:
  backend:    # Named network shared by services
    driver: bridge

volumes:
  postgres_data:   # Named volume — persists between container restarts
  redis_data:

# Compose V2 vs V1:
# V1: docker-compose (hyphen) — Python binary, deprecated
# V2: docker compose (space)  — Go plugin, built into Docker CLI
# Always use V2: docker compose up (not docker-compose up)
# Remove the top-level 'version:' key — it's ignored in V2 and causes warnings

Service Definitions

# Full-featured compose.yml for a Node.js app + Postgres + Redis
name: myapp

services:
  web:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        NODE_ENV: production
      cache_from:
        - myapp-web:latest        # Use previous build as cache source
    image: myapp-web:latest       # Tag the built image
    container_name: myapp-web     # Fixed name (avoid in multi-replica setups)
    restart: unless-stopped
    ports:
      - "3000:3000"
    environment:
      NODE_ENV: production
      DATABASE_URL: postgres://myuser:mypass@db:5432/mydb
      REDIS_URL: redis://redis:6379
    env_file:
      - .env.production
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    networks:
      - frontend
      - backend
    volumes:
      - ./logs:/app/logs          # Bind mount for logs
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s
    deploy:
      resources:
        limits:
          cpus: '0.5'
          memory: 512M

  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: mydb
      POSTGRES_USER: myuser
      POSTGRES_PASSWORD: mypass
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql  # Run on first start
    networks:
      - backend
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myuser -d mydb"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
    volumes:
      - redis_data:/data
    networks:
      - backend

networks:
  frontend:
  backend:
    internal: true    # No external internet access (db/redis can't reach outside)

volumes:
  postgres_data:
  redis_data:

Dependency Ordering

# depends_on controls startup order, but has two modes:

# 1. service_started (default) — waits for container to START, not be ready
#    Problem: Postgres takes 3-5 seconds to accept connections after starting
depends_on:
  db:
    condition: service_started   # Web starts immediately after db container starts
                                 # App may crash if it connects before Postgres is ready

# 2. service_healthy — waits for the healthcheck to pass ✅ Use this
depends_on:
  db:
    condition: service_healthy   # Web only starts after db passes its healthcheck
  redis:
    condition: service_started   # Redis starts fast, no healthcheck needed

# 3. service_completed_successfully — for one-shot init containers
services:
  db-migrate:
    image: myapp-web:latest
    command: npm run db:migrate
    depends_on:
      db:
        condition: service_healthy

  web:
    depends_on:
      db-migrate:
        condition: service_completed_successfully
      db:
        condition: service_healthy

# restart on failure — handle transient startup failures
web:
  restart: on-failure
  # Compose will restart web if it exits with non-zero
  # (handles the case where db isn't quite ready despite healthcheck)

Environment Files

# Three ways to pass env vars to services:

# 1. Inline environment: (committed to git — for non-secrets)
environment:
  NODE_ENV: production
  PORT: "3000"

# 2. env_file: (load from file — file can be gitignored)
env_file:
  - .env               # Always loaded
  - .env.local         # Optional override (doesn't error if missing in Compose V2+)

# 3. Variable substitution — use host shell variables in compose.yml
services:
  web:
    image: myapp:${TAG:-latest}   # ${TAG} from shell, fallback to 'latest'
    environment:
      SECRET_KEY: ${SECRET_KEY}   # Must be set in shell or .env

# .env file (in project root — auto-loaded by Compose for variable substitution)
# This is different from env_file: — it's for compose.yml variable substitution
# .env:
TAG=v1.2.3
POSTGRES_PASSWORD=supersecret

# .env.production (passed to containers via env_file:)
NODE_ENV=production
LOG_LEVEL=info
DATABASE_URL=postgres://user:${POSTGRES_PASSWORD}@db:5432/mydb

# Environment variable precedence (highest → lowest):
# 1. Shell environment variables
# 2. .env file in project directory (for substitution in compose.yml)
# 3. environment: key in compose.yml
# 4. env_file: files listed in compose.yml
# 5. ENV in Dockerfile

Volumes and Mounts

# Named volumes — managed by Docker, persist between compose down/up
volumes:
  postgres_data:          # Empty = default bridge driver, data on Docker host
  postgres_data:
    driver: local
    driver_opts:
      type: nfs
      o: addr=nfsserver,rw
      device: ":/exports/data"   # NFS volume example

# Bind mounts — map host directory into container (good for development)
services:
  web:
    volumes:
      - ./src:/app/src          # Live code reload — host file changes appear in container
      - ./config:/app/config:ro # Read-only bind mount

# tmpfs — in-memory, not persisted (good for sensitive temp files)
services:
  web:
    tmpfs:
      - /tmp
      - /run

# Anonymous volumes — created automatically, deleted with container
services:
  web:
    volumes:
      - /app/node_modules       # Don't overwrite node_modules from host bind mount!
                                # This is the classic Node.js volume trick:
# services:
#   web:
#     volumes:
#       - .:/app                # Bind mount entire project
#       - /app/node_modules     # Anonymous volume "shadows" node_modules — uses container's version

# compose down volume behavior
docker compose down          # Removes containers + networks; keeps named volumes
docker compose down -v       # Also removes named volumes (data lost!)
docker compose down --remove-orphans  # Also remove containers not in compose.yml

Override Files

# Compose merges multiple files — use this for dev/prod parity without duplication.
# Default merge order: compose.yml → compose.override.yml (auto-loaded)

# compose.yml — base (shared config, committed to git)
services:
  web:
    image: myapp-web:${TAG:-latest}
    environment:
      NODE_ENV: production
    networks:
      - backend
  db:
    image: postgres:16-alpine
    volumes:
      - postgres_data:/var/lib/postgresql/data

# compose.override.yml — development overrides (auto-merged, can be gitignored)
services:
  web:
    build: .             # Build locally instead of using registry image
    ports:
      - "3000:3000"      # Expose ports locally
    volumes:
      - .:/app           # Live reload: mount source into container
      - /app/node_modules
    environment:
      NODE_ENV: development
      LOG_LEVEL: debug
  db:
    ports:
      - "5432:5432"      # Expose DB port for local tools (TablePlus, pgAdmin)

# compose.prod.yml — production overrides (explicit, not auto-merged)
services:
  web:
    restart: unless-stopped
    deploy:
      replicas: 3
      resources:
        limits:
          cpus: '0.5'
          memory: 512M
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

# Usage:
docker compose up                         # Uses compose.yml + compose.override.yml
docker compose -f compose.yml -f compose.prod.yml up -d   # Production

Profiles

# Profiles let you define optional services that only start when requested.
# Use for: monitoring tools, admin UIs, test databases, mock services.

services:
  web:
    # No profile — always starts
    image: myapp-web:latest

  db:
    image: postgres:16-alpine
    # No profile — always starts

  pgadmin:
    image: dpage/pgadmin4
    profiles: [tools]        # Only starts when 'tools' profile is active
    ports:
      - "5050:80"
    environment:
      PGADMIN_DEFAULT_EMAIL: admin@example.com
      PGADMIN_DEFAULT_PASSWORD: admin

  prometheus:
    image: prom/prometheus
    profiles: [monitoring]
    ports:
      - "9090:9090"

  grafana:
    image: grafana/grafana
    profiles: [monitoring]
    ports:
      - "3001:3000"

  mailhog:
    image: mailhog/mailhog
    profiles: [tools, dev]   # Starts with either 'tools' or 'dev' profile
    ports:
      - "8025:8025"          # Web UI for catching dev emails

# Usage:
docker compose up                              # Only web + db
docker compose --profile tools up             # web + db + pgadmin + mailhog
docker compose --profile monitoring up        # web + db + prometheus + grafana
docker compose --profile tools --profile monitoring up  # Everything

Essential Commands

# Start everything (detached)
docker compose up -d

# Start and rebuild images
docker compose up -d --build

# Start specific services only
docker compose up -d web redis

# Stop everything (keep containers)
docker compose stop

# Stop and remove containers + networks
docker compose down

# View logs
docker compose logs -f             # All services
docker compose logs -f web         # Single service
docker compose logs --tail 50 web

# Run a one-off command in a service
docker compose run --rm web npm run db:seed
docker compose run --rm web sh     # Debug shell

# Execute in a running service
docker compose exec web sh
docker compose exec db psql -U myuser -d mydb

# Scale a service
docker compose up -d --scale web=3

# Pull latest images for all services
docker compose pull

# See what's running
docker compose ps
docker compose ps --services     # Just service names

# Restart a single service
docker compose restart web

# Watch mode — live sync for development (Compose V2.22+)
# compose.yml:
# services:
#   web:
#     develop:
#       watch:
#         - action: sync
#           path: ./src
#           target: /app/src
#         - action: rebuild
#           path: package.json
docker compose watch   # Auto-syncs file changes, rebuilds on Dockerfile/package.json changes
Next: Phase 5 — Docker Networking covers bridge, host and overlay networks, DNS resolution between containers, network isolation patterns and exposing services securely.