Docker Compose

TL;DR

Docker Compose lets you define multi-container applications in a single compose.yaml file. One command (docker compose up) starts your entire stack — API, database, cache, worker — with networking, volumes, and environment variables pre-configured.

Explain Like I'm 12

Imagine you're organizing a school play. You need actors, a stage crew, lighting, and sound — all working together. Instead of calling each person individually, you write one script that says "Actor 1 enters from the left, lights go up, sound plays music." Everyone knows what to do.

Docker Compose is that script for your app. Instead of running 5 separate docker run commands, you write one YAML file that describes all your services. One command starts everything in the right order.

Compose Architecture

A typical web application has multiple services that need to work together. Docker Compose connects them into a single stack with shared networking and managed volumes.

Docker Compose architecture: compose.yaml defines services, networks, and volumes as a unified stack

Anatomy of compose.yaml

A Compose file has three top-level sections: services (your containers), volumes (persistent storage), and networks (communication channels).

services:
  # --- Web API ---
  api:
    build: ./api
    ports:
      - "8000:8000"
    environment:
      - DATABASE_URL=postgres://user:pass@db:5432/myapp
      - REDIS_URL=redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    volumes:
      - ./api:/app        # Bind mount for live reload
    networks:
      - backend

  # --- PostgreSQL Database ---
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: myapp
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user"]
      interval: 5s
      timeout: 5s
      retries: 5
    networks:
      - backend

  # --- Redis Cache ---
  cache:
    image: redis:7-alpine
    networks:
      - backend

volumes:
  db-data:          # Named volume for database persistence

networks:
  backend:          # Custom network for service discovery
Info: The filename compose.yaml is the modern default (Docker Compose v2+). The old docker-compose.yml still works but is legacy. No version: field is needed in modern Compose files.

Essential Commands

CommandWhat It Does
docker compose up -dStart all services in background (detached)
docker compose downStop and remove containers, networks
docker compose down -vAlso remove volumes (destroys data!)
docker compose psList running services and their status
docker compose logs -f apiFollow logs for a specific service
docker compose exec api bashOpen a shell inside a running service
docker compose buildRebuild images (after code or Dockerfile changes)
docker compose up -d --buildRebuild and restart in one step
docker compose configValidate and display the resolved config
Tip: Use docker compose up -d --build as your go-to development command. It rebuilds only services whose Dockerfiles or build context changed, then restarts them.

Service Configuration Deep Dive

Build vs Image

# Build from local Dockerfile
api:
  build:
    context: ./api
    dockerfile: Dockerfile.dev
    args:
      NODE_ENV: development

# Use pre-built image
db:
  image: postgres:16

Port Mapping

ports:
  - "8080:80"       # host:container
  - "5432:5432"     # same port on both sides
  - "127.0.0.1:9090:9090"  # only accessible from localhost

Environment Variables

# Inline
environment:
  - NODE_ENV=production
  - API_KEY=secret

# From .env file
env_file:
  - .env
  - .env.local
Warning: Never commit .env files with real secrets to version control. Use .env.example with placeholder values and add .env to .gitignore.

Health Checks

healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
  interval: 30s
  timeout: 10s
  retries: 3
  start_period: 40s

Dependency Order

depends_on:
  db:
    condition: service_healthy    # Wait until db passes healthcheck
  cache:
    condition: service_started    # Just wait until it starts
Info: depends_on controls startup order, not readiness. Use condition: service_healthy with a healthcheck to ensure a service is actually ready to accept connections.

Common Patterns

Development Overrides

Use a compose.override.yaml for dev-specific settings. Docker Compose automatically merges it with compose.yaml.

# compose.yaml (base)
services:
  api:
    build: ./api
    ports:
      - "8000:8000"

# compose.override.yaml (auto-merged in dev)
services:
  api:
    volumes:
      - ./api:/app          # Live reload
    environment:
      - DEBUG=true

Production with Explicit File

# Use a production-specific file
docker compose -f compose.yaml -f compose.prod.yaml up -d

Running One-Off Commands

# Run database migrations
docker compose exec api python manage.py migrate

# Run tests
docker compose run --rm api pytest

# Open a database shell
docker compose exec db psql -U user myapp

Compose vs Kubernetes

FeatureDocker ComposeKubernetes
Best forDev, testing, small deploymentsProduction at scale
ComplexityOne YAML fileMultiple YAML manifests
Scalingdocker compose up --scale api=3Auto-scaling, rolling updates
Multi-hostSingle machine onlyCluster of machines
Self-healingrestart: alwaysBuilt-in pod restart, rescheduling
Learning curveMinutesWeeks
Tip: Start with Docker Compose for development and small projects. Graduate to Kubernetes when you need multi-host orchestration, auto-scaling, or zero-downtime rolling updates.

Test Yourself

What does docker compose down -v do differently from docker compose down?

docker compose down stops and removes containers and networks. Adding -v also removes named volumes, which permanently deletes persistent data (like database contents). Use -v carefully — it's the equivalent of dropping your database.

How does depends_on with condition: service_healthy differ from plain depends_on?

Plain depends_on only ensures a service starts before the dependent one. But starting doesn't mean ready — a database might take 10 seconds to accept connections. condition: service_healthy waits until the service's healthcheck passes, ensuring it's actually ready.

How do services communicate by name in Docker Compose?

Docker Compose creates a default network for all services. Each service can reach others by service name as hostname. For example, if you have services api and db, the API can connect to postgres://db:5432. Docker's embedded DNS resolves service names to container IPs.

What's the advantage of using compose.override.yaml?

Docker Compose automatically merges compose.override.yaml with compose.yaml. This lets you keep production config in the base file and dev-specific settings (volume mounts for live reload, debug flags, exposed ports) in the override. No -f flag needed — it just works.

When should you use docker compose run vs docker compose exec?

exec runs a command in an already running container. run creates a new, temporary container for a one-off task. Use exec for debugging a running service. Use run --rm for one-off tasks like migrations, tests, or seed scripts.

Interview Questions

How would you handle secrets in Docker Compose for production?

1) Use env_file with a .env that's not in version control. 2) Use Docker secrets (Swarm mode): secrets: in compose.yaml, files mounted to /run/secrets/. 3) Use an external secrets manager (Vault, AWS Secrets Manager) and inject at runtime. Never hardcode secrets in the Compose file or Dockerfile.

Your API starts before the database is ready, causing connection errors. How do you fix this?

Add a healthcheck to the database service and set depends_on with condition: service_healthy. Also implement retry logic in your application — infrastructure-level ordering is a best effort, and your app should handle transient connection failures gracefully.

How do you manage different Compose configurations for dev, staging, and production?

Use Compose file merging: compose.yaml (base) + environment-specific overrides. Dev uses compose.override.yaml (auto-merged). Production uses docker compose -f compose.yaml -f compose.prod.yaml up. This avoids duplication while allowing environment-specific ports, volumes, replicas, and resource limits.