Docker Compose

Define, run, and manage multi-container applications with a single YAML file - from local dev stacks to production-like environments.

1. What is Docker Compose?

Docker Compose is a tool for defining and running multi-container Docker applications. You describe your stack in a compose.yaml file - services, networks, volumes, environment variables - and Compose creates and wires everything together with a single command.

Compose is ideal for:

┌─────────────────────────────────────────────────────────────────┐
│  docker compose up                                              │
│       │                                                         │
│       ├── creates network: myapp_default                        │
│       ├── creates volume: myapp_postgres_data                   │
│       ├── starts container: myapp-postgres-1                    │
│       ├── starts container: myapp-redis-1                       │
│       └── starts container: myapp-api-1  (depends on above)     │
│              │                                                  │
│              └── DNS: api, postgres, redis resolve on network   │
└─────────────────────────────────────────────────────────────────┘
Compose v2: Use docker compose (space, not hyphen). The legacy standalone docker-compose Python tool is deprecated. Compose v2 is a Go-based Docker CLI plugin integrated with Buildx and Docker contexts.

Quick start

# Project layout
myapp/
├── compose.yaml
├── api/
│   └── Dockerfile
└── .env

# Start everything in the foreground
docker compose up

# Start detached, rebuild images
docker compose up -d --build

# Tear down containers, networks, and named volumes
docker compose down -v

2. The Compose File

Modern Compose files follow the Compose Specification (compose-spec.io). The top-level version field is no longer required - Compose v2 infers the schema automatically.

Top-level keys

KeyPurpose
servicesContainer definitions (required)
networksCustom bridge/overlay networks
volumesNamed or external volume declarations
secretsFile-based secrets mounted into containers
configsNon-sensitive config files (Swarm-oriented, limited in standalone)
nameOverride the default project name (directory name)

Minimal compose.yaml

name: myapp

services:
  web:
    image: nginx:1.27-alpine
    ports:
      - "8080:80"
    volumes:
      - ./html:/usr/share/nginx/html:ro

  api:
    build:
      context: ./api
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://app:secret@db:5432/appdb
    depends_on:
      db:
        condition: service_healthy

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

volumes:
  postgres_data:

Build context options

services:
  api:
    build:
      context: .                    # directory sent to docker build
      dockerfile: docker/api.Dockerfile
      target: production            # multi-stage build target
      args:
        NODE_VERSION: "20"
      cache_from:
        - myregistry/api:cache
      labels:
        org.opencontainers.image.source: https://github.com/org/repo
    image: myregistry/api:${TAG:-latest}   # tag the built image

3. Services in Depth

Each entry under services maps to one or more containers (replicas with deploy.replicas in Swarm mode; standalone Compose runs one container per service).

Image vs build

ApproachWhen to use
image: nginx:1.27Pull a pre-built image from a registry
build: ./apiBuild locally from a Dockerfile
BothBuild locally and tag with image for push/reuse

Common service options

services:
  worker:
    image: myapp/worker:2.1.0
    restart: unless-stopped       # no | always | on-failure | unless-stopped
    user: "1000:1000"               # run as non-root
    working_dir: /app
    command: ["node", "worker.js"]  # override image CMD
    entrypoint: ["/entrypoint.sh"]  # override image ENTRYPOINT
    init: true                      # use tini as PID 1 (reap zombies)
    stop_grace_period: 30s
    stop_signal: SIGTERM
    read_only: true                 # read-only root filesystem
    tmpfs:
      - /tmp
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE
    deploy:                         # ignored by standalone compose (Swarm only)
      resources:
        limits:
          cpus: "0.5"
          memory: 512M
        reservations:
          cpus: "0.25"
          memory: 256M
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

Profiles

Profiles let you opt-in to optional services (debug tools, local mail catcher, etc.).

services:
  api:
    image: myapp/api
    profiles: []          # always started (default profile)

  mailhog:
    image: mailhog/mailhog
    profiles: ["debug"]   # only with --profile debug

  adminer:
    image: adminer
    profiles: ["tools"]

# Start api only (default)
docker compose up -d

# Include debug services
docker compose --profile debug up -d

4. Networking

Compose creates a default bridge network per project. Every service joins it automatically and is reachable by service name as a DNS hostname.

# From inside the api container:
curl http://redis:6379        # resolves to redis service
psql -h db -U app appdb       # resolves to db service

Port publishing

services:
  web:
    ports:
      - "8080:80"              # host:container (bind all interfaces)
      - "127.0.0.1:3000:3000"  # localhost only
      - "80"                   # ephemeral host port

  internal-api:
    expose:
      - "8080"                 # visible to other services, NOT published to host

Custom networks

services:
  frontend:
    networks:
      - frontend_net

  api:
    networks:
      - frontend_net
      - backend_net

  db:
    networks:
      - backend_net    # db not reachable from frontend

networks:
  frontend_net:
    driver: bridge
  backend_net:
    driver: bridge
    internal: true     # no external routing (isolated subnet)

External networks

networks:
  shared_proxy:
    external: true     # must exist: docker network create shared_proxy

services:
  web:
    networks:
      - shared_proxy
      - default
DNS tip: Service names resolve within the same network. If frontend and db are on different networks with no shared network, db is not reachable from frontend.

5. Volumes

Compose supports three mount types: named volumes, bind mounts, and tmpfs.

TypeSyntaxUse case
Named volumepostgres_data:/var/lib/postgresql/dataDatabase data, managed by Docker
Bind mount./src:/app/srcLive code reload in dev
Anonymous volume/app/node_modulesPreserve container-only files over bind mount
External volumeexternal: true in top-level volumesShare data across Compose projects
services:
  api:
    volumes:
      - ./api/src:/app/src:cached     # macOS performance hint
      - /app/node_modules             # anonymous: shadow host node_modules
      - api_logs:/var/log/app

  db:
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./init-scripts:/docker-entrypoint-initdb.d:ro

volumes:
  postgres_data:
    driver: local
  api_logs:
  legacy_data:
    external: true
    name: prod_postgres_data

Dev vs prod volume strategy

# Inspect volumes for a project
docker compose ps
docker volume ls --filter label=com.docker.compose.project=myapp
docker volume inspect myapp_postgres_data

6. Environment & Secrets

Environment variables

Compose supports inline env vars, .env files, and variable interpolation in the compose file itself.

# .env (loaded automatically from project directory)
TAG=2.1.0
DB_PASSWORD=localdev_secret
API_PORT=3000

# compose.yaml
services:
  api:
    image: myapp/api:${TAG}
    ports:
      - "${API_PORT}:3000"
    environment:
      NODE_ENV: development
      LOG_LEVEL: ${LOG_LEVEL:-info}    # default if unset
    env_file:
      - ./api/.env.local
      - ./api/.env.shared

Interpolation and defaults

image: myregistry/api:${TAG:-latest}
ports:
  - "${API_PORT:-3000}:3000"

# Required variable (Compose fails if unset)
image: myregistry/api:${TAG?TAG is required}

Secrets (file-based)

Secrets are mounted as files under /run/secrets/<secret_name>. Never commit secret files - add them to .gitignore.

services:
  api:
    secrets:
      - db_password
      - api_tls_cert
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt
  api_tls_cert:
    file: ./secrets/api.crt

# Application reads:
# cat /run/secrets/db_password
Security: .env files are convenient for local dev but are not encrypted. In production, inject secrets via your orchestrator, a secrets manager, or Docker Swarm secrets - never bake credentials into images or commit them to git.

7. Health Checks & Dependencies

Without health checks, depends_on only waits for the container to start, not for the application inside to be ready. Use condition: service_healthy for reliable startup ordering.

Health check syntax

services:
  api:
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 30s   # grace period before failures count

  db:
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 5s
      timeout: 3s
      retries: 5

depends_on with conditions

services:
  api:
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    # api starts only after db healthcheck passes

  worker:
    depends_on:
      api:
        condition: service_healthy
ConditionBehaviour
service_startedContainer is running (default)
service_healthyHealthcheck reports healthy
service_completed_successfullyOne-shot container exited 0 (migrations)

One-shot migration pattern

services:
  migrate:
    image: myapp/api:${TAG}
    command: ["npm", "run", "db:migrate"]
    depends_on:
      db:
        condition: service_healthy
    restart: "no"

  api:
    depends_on:
      migrate:
        condition: service_completed_successfully
      db:
        condition: service_healthy
# Check health status
docker compose ps
docker inspect --format='{{.State.Health.Status}}' myapp-api-1

8. Override Files

Compose automatically merges compose.yaml with compose.override.yaml if it exists. Override files let you keep a shared base and layer environment-specific changes without duplicating the entire stack.

Merge order

# Default: compose.yaml + compose.override.yaml
docker compose up

# Explicit file list (later files override earlier)
docker compose -f compose.yaml -f compose.prod.yaml up -d

# Dev-only override
docker compose -f compose.yaml -f compose.dev.yaml up

compose.yaml (shared base)

services:
  api:
    build: ./api
    environment:
      NODE_ENV: production
    depends_on:
      db:
        condition: service_healthy

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

compose.override.yaml (local dev, gitignored or committed)

services:
  api:
    environment:
      NODE_ENV: development
      DEBUG: "app:*"
    volumes:
      - ./api/src:/app/src
    ports:
      - "3000:3000"

  db:
    ports:
      - "5432:5432"   # expose DB to host tools (pgAdmin, DBeaver)

compose.prod.yaml (production)

services:
  api:
    image: myregistry/api:${TAG}
    build: !reset null          # do not build on prod host
    restart: unless-stopped
    ports: !reset []           # no published ports; use reverse proxy
    volumes: !reset []         # no source bind mounts

  nginx:
    image: nginx:1.27-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./certs:/etc/nginx/certs:ro
    depends_on:
      api:
        condition: service_healthy
YAML anchors and extensions: Use !reset to remove keys inherited from the base file, and extends (deprecated in spec) sparingly. Prefer explicit multi-file merges for clarity.

9. Compose CLI

All Compose commands are subcommands of docker compose. The project name defaults to the directory name and prefixes all resources (myapp-api-1, myapp_default).

Lifecycle commands

docker compose up -d              # create and start in background
docker compose up -d --build      # rebuild images before starting
docker compose up --watch         # sync files and rebuild (Compose v2.22+)
docker compose stop               # stop without removing
docker compose start              # start stopped containers
docker compose restart api        # restart one service
docker compose down               # stop and remove containers + networks
docker compose down -v            # also remove named volumes
docker compose down --rmi local   # remove images built by compose

Inspection and debugging

docker compose ps                   # running services
docker compose ps -a                # include stopped
docker compose logs -f api          # follow api logs
docker compose logs --tail=50 db    # last 50 lines
docker compose exec api sh          # shell into running container
docker compose run --rm api npm test  # one-off command (new container)
docker compose top api              # show processes in service
docker compose config               # render merged compose file
docker compose config --services    # list service names
docker compose images               # images used by services
docker compose port api 3000        # show host port mapping

Project and context

docker compose -p myproject up -d   # override project name
docker compose --env-file .env.staging up -d

# Use a different Docker context (remote host)
docker context use production
docker compose up -d

10. Production Patterns

Compose on a single host can run production workloads, but understand the limits: no built-in rolling updates, no auto-scaling across nodes, and the host is a single point of failure. For multi-node production, use Kubernetes, Docker Swarm, or a PaaS.

Production checklist

Reverse proxy with Traefik

services:
  traefik:
    image: traefik:v3.0
    command:
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--entrypoints.web.address=:80"
      - "--entrypoints.websecure.address=:443"
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./certs:/certs:ro

  api:
    image: myregistry/api:2.1.0
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.api.rule=Host(`api.example.com`)"
      - "traefik.http.routers.api.entrypoints=websecure"
      - "traefik.http.routers.api.tls=true"
    expose:
      - "3000"

Watchtower (automated image updates)

services:
  watchtower:
    image: containrrr/watchtower
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    environment:
      WATCHTOWER_POLL_INTERVAL: 300
      WATCHTOWER_CLEANUP: "true"
    # Only update labeled containers
    command: --label-enable

Compose vs Kubernetes

ConcernComposeKubernetes
Scale across nodesNoYes
Rolling updatesManual / third-partyBuilt-in
Self-healingRestart policy onlyReplicaSets, probes
Secrets managementFile mountsSecrets API, external operators
Best fitDev, CI, small single-host prodMulti-node production

11. Cheat Sheet

TaskCommand
Start stackdocker compose up -d
Rebuild and startdocker compose up -d --build
Stop and removedocker compose down -v
View logsdocker compose logs -f <service>
Shell into servicedocker compose exec <service> sh
Run one-off commanddocker compose run --rm <service> <cmd>
Validate compose filedocker compose config
List servicesdocker compose config --services
Service DNS name<service_name> on default network
Multi-file mergedocker compose -f a.yaml -f b.yaml up

Full cheat sheet: Docker CLI Cheat Sheet