Skip to content

Docker Compose

Docker Compose is a tool for defining and running multi-container Docker applications. Instead of managing each container with separate docker run commands - each with its own flags for ports, volumes, networks, and environment variables - you declare everything in a single YAML file and bring the entire stack up or down with one command.


Why Compose Exists

Running a web application typically means running at least three containers: the application itself, a database, and maybe a cache or message queue. Managing these by hand means remembering the exact docker run invocation for each one, creating networks, and starting them in the right order. Docker Compose solves this by letting you define the entire stack declaratively.

The benefits go beyond convenience:

  • Reproducible environments: Every developer runs the same stack with docker compose up.
  • Service discovery: Containers on a Compose network reach each other by service name - no IP addresses to manage.
  • Single-command lifecycle: Start, stop, rebuild, and tear down the entire stack in one step.
  • Environment parity: The same compose.yml works in development, CI, and staging.

The Compose File

The core of Docker Compose is a YAML file named compose.yml (or docker-compose.yml for backward compatibility). It defines services, networks, and volumes.

A Production-Style Example

services:
  web:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgres://admin:secret@db:5432/myapp
      REDIS_URL: redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    restart: unless-stopped

  db:
    image: postgres:16
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: admin
      POSTGRES_PASSWORD: secret
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U admin -d myapp"]
      interval: 5s
      timeout: 3s
      retries: 5
    restart: unless-stopped

  cache:
    image: redis:7-alpine
    restart: unless-stopped

volumes:
  pgdata:

depends_on does not wait for readiness

By default, depends_on only waits for a container to start, not for the service inside to be ready. A PostgreSQL container can take several seconds to initialize its database after the process starts. Use condition: service_healthy with a healthcheck to ensure the database is actually accepting connections before the web service starts.

Key Directives

Directive Purpose
services Defines each container in your application stack.
image Use a pre-built image from a registry.
build Build an image from a Dockerfile in the specified context.
ports Map host ports to container ports (HOST:CONTAINER).
volumes Mount named volumes or bind paths into the container.
environment Set environment variables. Supports both mapping and list syntax.
depends_on Control startup order. Use with condition for health-based ordering.
healthcheck Define a command to check if the service is healthy.
restart Restart policy: no, always, on-failure, unless-stopped.
networks Attach the service to specific networks.
command Override the default CMD from the image.
profiles Assign the service to a profile so it only starts when that profile is active.

Environment Variables and .env Files

Hardcoding secrets in compose.yml is fine for local development, but Compose supports .env files for separating configuration from the stack definition.

Variable Substitution

Compose resolves ${VARIABLE} references from the shell environment, a .env file in the same directory, or the environment directive.

services:
  db:
    image: postgres:${POSTGRES_VERSION:-16}
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}
# .env file (same directory as compose.yml)
POSTGRES_VERSION=16
DB_PASSWORD=supersecret

The env_file Directive

For services that need many environment variables, you can point to a file instead of listing them inline:

services:
  web:
    build: .
    env_file:
      - .env
      - .env.local

Files are loaded in order. Later files override earlier ones. Variables set directly in environment override everything.


Volumes: Named vs Bind Mounts

Compose supports two primary volume strategies, and choosing the right one matters.

Named Volumes (Production)

Named volumes are managed by Docker. They persist data across container restarts and removals, and they're the right choice for databases, file uploads, and any data that must survive a docker compose down.

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

volumes:
  pgdata:

Bind Mounts (Development)

Bind mounts map a host directory into the container. They're essential for development because changes to files on the host appear immediately inside the container - no rebuild needed.

services:
  web:
    build: .
    volumes:
      - .:/app           # Source code (live reloading)
      - /app/node_modules # Prevent host node_modules from overriding container's
    ports:
      - "3000:3000"

Named volumes survive docker compose down

Running docker compose down removes containers and networks but keeps named volumes. If you want to delete the data too, use docker compose down -v. This is destructive and irreversible - the database data is gone.


Networking

Compose automatically creates a bridge network for each project. Every service can reach every other service by its service name as a hostname.

services:
  web:
    build: .
    # Can connect to "db" at hostname "db" on port 5432
    # No port publishing needed for service-to-service communication
  db:
    image: postgres:16
    # Only publish if you need direct host access (e.g., for a GUI client)
    # ports:
    #   - "5432:5432"

For more complex setups, you can define multiple networks to isolate groups of services:

services:
  web:
    networks:
      - frontend
      - backend
  db:
    networks:
      - backend
  nginx:
    networks:
      - frontend

networks:
  frontend:
  backend:

In this configuration, nginx can reach web but cannot reach db directly - the database is only accessible on the backend network.


Common Compose Commands

# Start the stack (build images if needed)
docker compose up -d

# Rebuild images and start
docker compose up -d --build

# Stop and remove containers and networks
docker compose down

# Stop, remove containers, networks, AND volumes
docker compose down -v

# View logs from all services
docker compose logs -f

# View logs from a single service
docker compose logs -f web

# List running services
docker compose ps

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

# Run a command in a new container (doesn't attach to the running one)
docker compose run --rm web python manage.py migrate

# Scale a service to multiple replicas
docker compose up -d --scale worker=3

Override Files and Profiles

Override Files

Compose automatically merges compose.yml with compose.override.yml if it exists. This is the standard pattern for separating production defaults from development customizations.

# compose.yml (production defaults)
services:
  web:
    image: my-app:latest
    restart: unless-stopped

# compose.override.yml (development overrides)
services:
  web:
    build: .
    volumes:
      - .:/app
    environment:
      DEBUG: "true"

In development, docker compose up merges both files - building from source with a bind mount. In production, deploy with docker compose -f compose.yml up to skip the override.

Profiles

Profiles let you include optional services that only start when explicitly requested.

services:
  web:
    build: .
  db:
    image: postgres:16
  adminer:
    image: adminer
    ports:
      - "8080:8080"
    profiles:
      - debug
# Normal startup (adminer is NOT started)
docker compose up -d

# Start with the debug profile (includes adminer)
docker compose --profile debug up -d

Interactive Quizzes




Further Reading


Previous: Docker Fundamentals | Next: Dockerfile Best Practices | Back to Index

Comments