5 min read
On this page

Docker Compose

Docker Compose runs multi-container applications with a single command. Instead of starting each container manually with docker run and wiring up networks and volumes by hand, you define everything in a compose.yml file. Run docker compose up, and your entire stack -- application, database, cache, message queue -- starts together. It is the standard tool for local development environments and simple production deployments.

When to Use Compose

Compose is the right tool when:

  • You need multiple containers for local development (app + database + cache)
  • You want a reproducible development environment that new team members can start in one command
  • Your production deployment is a single server running a handful of services
  • You need a simple way to run integration tests against real dependencies

Compose is not the right tool when:

  • You need to run across multiple hosts (use Kubernetes or a container orchestration platform)
  • You need auto-scaling, rolling updates, or self-healing in production (use Kubernetes)
  • You have dozens of services that need service discovery and load balancing at scale

The Compose File

Modern Docker Compose uses compose.yml (not the older docker-compose.yml). The file uses the Compose Specification, which replaced the versioned format. You no longer need a version: key at the top.

# compose.yml
services:
  api:
    build: .
    ports:
      - "8080:8080"
    environment:
      DATABASE_URL: postgres://app:secret@db:5432/myapp
      REDIS_URL: redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    volumes:
      - ./src:/app/src

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d myapp"]
      interval: 5s
      timeout: 5s
      retries: 5

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

volumes:
  pgdata:
# Start all services
docker compose up

# Start in the background
docker compose up -d

# View logs
docker compose logs -f api

# Stop everything
docker compose down

# Stop and remove volumes (destroys data)
docker compose down -v

Services

Each entry under services defines a container. A service can be built from a local Dockerfile or pulled from a registry.

Building from a Dockerfile

services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
      target: development    # Use a specific stage from a multi-stage build
    ports:
      - "8080:8080"

Using a Pre-Built Image

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret

Overriding the Command

services:
  api:
    build: .
    command: python -m pytest    # Override CMD from Dockerfile

Networks

Compose creates a default network for your project. All services can reach each other by their service name. In the example above, the api service connects to the database using db as the hostname -- not localhost, not an IP address.

# The api service connects to postgres at: db:5432
# The api service connects to redis at: cache:6379
# No manual network configuration needed for most cases

For more complex setups, you can define custom networks:

services:
  api:
    networks:
      - frontend
      - backend

  db:
    networks:
      - backend

  nginx:
    networks:
      - frontend

networks:
  frontend:
  backend:

In this example, nginx can reach api but not db. The api service bridges both networks.

Volumes

Volumes persist data beyond the lifecycle of a container. Without a volume, stopping a database container destroys all its data.

Named Volumes

services:
  db:
    image: postgres:16-alpine
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:    # Managed by Docker, persists across restarts

Bind Mounts

Bind mounts map a host directory into the container. They are essential for development -- edit code on your host, and the changes are immediately visible inside the container.

services:
  api:
    build: .
    volumes:
      - ./src:/app/src          # Bind mount: host ./src -> container /app/src
      - /app/node_modules       # Anonymous volume: prevent host node_modules from overriding container's

The anonymous volume trick for node_modules prevents a common issue: the host's node_modules (possibly empty or built for a different OS) from overwriting the container's installed dependencies.

Environment Variables

Three ways to pass environment variables:

Inline in compose.yml

services:
  api:
    environment:
      DATABASE_URL: postgres://app:secret@db:5432/myapp
      LOG_LEVEL: debug

From a .env File

services:
  api:
    env_file:
      - .env
# .env
DATABASE_URL=postgres://app:secret@db:5432/myapp
LOG_LEVEL=debug

Variable Substitution

Compose can interpolate environment variables from your shell:

services:
  api:
    image: ghcr.io/myorg/api:${API_VERSION:-latest}
    environment:
      LOG_LEVEL: ${LOG_LEVEL:-info}
API_VERSION=v1.2.3 docker compose up

The :- syntax provides a default value if the variable is not set.

depends_on & Health Checks

depends_on controls startup order. By default, it only waits for the dependency to start, not to be ready. A database container that has started is not necessarily accepting connections yet.

services:
  api:
    depends_on:
      db:
        condition: service_healthy    # Wait for health check to pass
      cache:
        condition: service_started    # Just wait for container to start

  db:
    image: postgres:16-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d myapp"]
      interval: 5s
      timeout: 5s
      retries: 5
      start_period: 10s

Without condition: service_healthy, your application may crash on startup because the database is not ready. This is one of the most common issues with Docker Compose setups.

A Complete Local Development Setup

Here is a realistic Compose file for a web application with a database, cache, background worker, and reverse proxy:

# compose.yml
services:
  api:
    build:
      context: .
      target: development
    ports:
      - "8080:8080"
    environment:
      DATABASE_URL: postgres://app:secret@db:5432/myapp
      REDIS_URL: redis://cache:6379
      ENV: development
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    volumes:
      - ./src:/app/src
    command: python -m uvicorn main:app --host 0.0.0.0 --reload

  worker:
    build:
      context: .
      target: development
    environment:
      DATABASE_URL: postgres://app:secret@db:5432/myapp
      REDIS_URL: redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    volumes:
      - ./src:/app/src
    command: python -m celery -A tasks worker --loglevel=info

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp
    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 app -d myapp"]
      interval: 5s
      timeout: 5s
      retries: 5

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

  mailhog:
    image: mailhog/mailhog:latest
    ports:
      - "1025:1025"
      - "8025:8025"

volumes:
  pgdata:
# One command to start everything
docker compose up

# New team member onboarding:
git clone git@github.com:org/repo.git
cd repo
docker compose up
# Done. Full development environment running.

Compose for Testing

Compose is excellent for running integration tests against real dependencies:

# compose.test.yml
services:
  test:
    build:
      context: .
      target: test
    environment:
      DATABASE_URL: postgres://test:test@db:5432/testdb
    depends_on:
      db:
        condition: service_healthy
    command: python -m pytest --tb=short

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: test
      POSTGRES_PASSWORD: test
      POSTGRES_DB: testdb
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U test -d testdb"]
      interval: 2s
      timeout: 2s
      retries: 10
# Run tests and exit
docker compose -f compose.test.yml run --rm test

# In CI
docker compose -f compose.test.yml up --abort-on-container-exit --exit-code-from test

The --exit-code-from test flag makes the docker compose command return the exit code of the test service, so your CI pipeline fails when tests fail.

When Compose Is Enough & When You Need More

Compose is enough when:

  • Single-server deployment (one host, a few containers)
  • Local development environments
  • Integration testing in CI
  • Small projects with predictable, stable load
  • Internal tools with a handful of users

You need more (Kubernetes, ECS, or similar) when:

  • You need to run across multiple hosts for availability
  • You need auto-scaling based on load
  • You need rolling updates with zero downtime
  • You have dozens of services with complex networking
  • You need fine-grained resource management across teams
  • You need self-healing (automatic restart on different hosts when hardware fails)

Many successful companies run production on Docker Compose for years before needing an orchestrator. Do not prematurely graduate to Kubernetes.

Common Pitfalls

  • Hardcoding secrets in compose.yml -- Use .env files (excluded from Git) or Docker secrets. Never commit passwords to source control, even in a compose file.
  • Forgetting health checks on depends_on -- Without condition: service_healthy, your app starts before the database is ready and crashes. Always pair depends_on with health checks.
  • Using latest tags -- image: postgres:latest today might be Postgres 16. Next month it might be 17 with breaking changes. Pin versions.
  • Not using volumes for database data -- Without a named volume, docker compose down destroys your database. Always define named volumes for stateful services.
  • Bind-mounting node_modules -- Host node_modules overwrites the container's. Use an anonymous volume to prevent this.
  • Running Compose in production without thinking about restarts -- Add restart: unless-stopped to production services so they survive host reboots and crashes.

Key Takeaways

  • Docker Compose defines multi-container applications in a single YAML file and starts them with one command
  • Use the modern compose.yml format (no version key, Compose Specification syntax)
  • Services communicate by service name on the default network -- no manual networking needed for most cases
  • depends_on with condition: service_healthy prevents startup race conditions
  • Bind mounts enable live-reload development; named volumes persist data across restarts
  • Compose is sufficient for local development, CI testing, and single-server production deployments
  • Graduate to an orchestrator when you need multi-host, auto-scaling, or zero-downtime deployments