Multi-Service Stack Structure#

A typical local development stack has an application, a database, and maybe a cache or message broker. The compose file should read top-to-bottom like a description of your system.

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8080:8080"
    env_file:
      - .env
    volumes:
      - ./src:/app/src
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started

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

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

volumes:
  pgdata:

depends_on and Healthchecks#

The depends_on field controls startup order, but without a condition it only waits for the container to start, not for the service inside to be ready. A Postgres container starts in under a second, but the database process takes several seconds to accept connections. Use condition: service_healthy paired with a healthcheck to block until the dependency is actually ready.

Common healthcheck patterns for popular services:

# PostgreSQL
healthcheck:
  test: ["CMD-SHELL", "pg_isready -U myapp"]
  interval: 5s
  timeout: 3s
  retries: 5

# MySQL
healthcheck:
  test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
  interval: 5s
  timeout: 3s
  retries: 5

# Redis
healthcheck:
  test: ["CMD", "redis-cli", "ping"]
  interval: 5s
  timeout: 3s
  retries: 3

# HTTP service
healthcheck:
  test: ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"]
  interval: 10s
  timeout: 5s
  retries: 3
  start_period: 15s

The start_period field is important for services with slow startup. During the start period, failed healthchecks do not count toward the retry limit.

Volume Mounts for Live Reload#

Bind mounts let you edit code on your host and see changes immediately inside the container. The key is mounting only the source directory, not the entire project (which would overwrite node_modules or other build artifacts inside the container).

services:
  frontend:
    build: ./frontend
    volumes:
      - ./frontend/src:/app/src
      - ./frontend/public:/app/public
      # Prevent host node_modules from overwriting container's
      - /app/node_modules
    command: npm run dev

The anonymous volume /app/node_modules tells Docker to preserve the container’s node_modules directory even though the parent /app directory has host-mounted content. Without it, the host’s potentially empty or OS-mismatched node_modules would shadow the container’s.

For Go or compiled languages, pair the mount with a file watcher like air or watchexec running inside the container.

Networking#

Compose creates a default bridge network for each project. Services can reach each other by service name as hostname. If your app config connects to db:5432, that resolves within the compose network automatically.

For more control, define explicit networks:

services:
  app:
    networks:
      - frontend
      - backend

  db:
    networks:
      - backend

  nginx:
    networks:
      - frontend

networks:
  frontend:
  backend:

This isolates db from nginx — the proxy can reach app but not the database directly. Network aliases let a single service answer to multiple hostnames:

services:
  db:
    networks:
      backend:
        aliases:
          - postgres
          - database

Environment Files#

Keep secrets and configuration out of the compose file. Use .env files per service or per environment:

services:
  app:
    env_file:
      - .env
      - .env.local  # overrides values from .env
    environment:
      # Inline values override env_file values
      LOG_LEVEL: debug

The .env file in the project root is special: Compose automatically reads it for variable interpolation in the compose file itself (not inside containers). To use it for interpolation:

services:
  db:
    image: postgres:${POSTGRES_VERSION:-16}-alpine

Always add .env.local and any secret-containing env files to .gitignore. Commit a .env.example with placeholder values.

Profiles for Optional Services#

Profiles let you define services that only start when explicitly requested. This keeps the default docker compose up fast while allowing optional tools on demand.

services:
  app:
    build: .
    ports:
      - "8080:8080"

  db:
    image: postgres:16-alpine

  mailhog:
    image: mailhog/mailhog
    ports:
      - "8025:8025"
    profiles:
      - debug

  pgadmin:
    image: dpage/pgadmin4
    ports:
      - "5050:80"
    environment:
      PGADMIN_DEFAULT_EMAIL: admin@local.dev
      PGADMIN_DEFAULT_PASSWORD: admin
    profiles:
      - debug

Start with profiles: docker compose --profile debug up. Services without a profiles key always start. Services with a profile only start when that profile is activated.

Override Files#

The file docker-compose.override.yml is automatically merged with docker-compose.yml when you run docker compose up. Use this for developer-specific settings that should not affect CI or production builds.

# docker-compose.override.yml
services:
  app:
    build:
      target: development
    volumes:
      - ./src:/app/src
    environment:
      DEBUG: "true"
    ports:
      - "9229:9229"  # debugger port

For CI, explicitly specify only the base file: docker compose -f docker-compose.yml up. This skips the override file entirely.

For more complex setups, chain multiple files:

docker compose -f docker-compose.yml -f docker-compose.ci.yml up

Later files override earlier ones. This lets you maintain a base config, a dev override, and a CI override without duplicating shared service definitions.

Useful Commands#

# Start in background
docker compose up -d

# Rebuild images and start
docker compose up --build

# View logs for a specific service
docker compose logs -f app

# Run a one-off command in a service
docker compose exec db psql -U myapp

# Tear down everything including volumes
docker compose down -v

# View resource usage
docker compose top