Docker Compose

Multi-Container Apps in YAML — and Why It's Not Kubernetes

Docker Compose is the single-host answer to "I have five services and I want them to come up together." You write a YAML file describing each service — image, ports, volumes, environment, dependencies — and docker compose up starts them on the same daemon. Networking, volume creation, healthcheck wiring, and start ordering are all derived from the file.

Compose v2 (the Go binary, since 2021) replaced the original Python docker-compose. It ships as a Docker CLI plugin, runs as docker compose, and tracks the Compose Specification — the open spec maintained jointly with several other tools. Recent versions added profiles, healthcheck conditions, and watch mode for fast inner-loop development.

What a Compose File Becomes

compose.yml docker compose CLI (Go plugin) Project network Volumes Secrets / configs web nginx 3 replicas api node:20 depends_on db db postgres:16 healthcheck redis redis:7 All on one Docker daemon, on one bridge network

Key Numbers

2021
Compose v2 (Go) released
2023
v1 EOL; v2.22 added watch mode
1
host (Compose's design point)
~50 ms
overhead per docker compose invocation
~100
documented top-level config fields
YAML 1.2
spec version (no JSON support)

A Real-World compose.yml

name: myapp
services:
  web:
    image: nginx:alpine
    ports:
      - "80:80"
    depends_on:
      api:
        condition: service_healthy
    networks: [front, back]
    restart: unless-stopped

  api:
    build:
      context: ./api
      target: production       # multi-stage target
    environment:
      DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/app
    env_file:
      - .env.production
    secrets:
      - jwt_signing_key
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 10s
      timeout: 3s
      retries: 5
      start_period: 30s
    networks: [back]
    deploy:
      resources:
        limits: { cpus: '2', memory: 1G }

  db:
    image: postgres:16-alpine
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets: [db_password]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      timeout: 3s
      retries: 5
    networks: [back]

  pgadmin:
    image: dpage/pgadmin4
    ports: ["5050:80"]
    profiles: [tools]          # only with --profile tools
    networks: [back]

networks:
  front:
  back:

volumes:
  pgdata:

secrets:
  db_password:
    file: ./secrets/db_password.txt
  jwt_signing_key:
    file: ./secrets/jwt.pem

Service Networks

# Compose creates a per-project bridge network by default.
# Services on the same network can resolve each other by name.

$ docker network ls --filter label=com.docker.compose.project=myapp
NETWORK ID     NAME             DRIVER    SCOPE
abc123def456   myapp_back       bridge    local
def789abc012   myapp_front      bridge    local

# Inside 'api' container, 'db' resolves to db's IP via Docker's embedded DNS
api$ getent hosts db
172.20.0.5      db

# Cross-network communication: only services on shared networks can talk.
# 'web' is on front+back; 'pgadmin' is on back only — they CAN talk.
# 'web' and a service on a third network CANNOT talk.

Healthchecks and depends_on Conditions

depends_on:
  db:
    condition: service_healthy   # wait for db's healthcheck to pass
  cache:
    condition: service_started   # default: wait for container to start
  init:
    condition: service_completed_successfully  # wait for one-shot to exit 0

# Healthcheck states: starting, healthy, unhealthy
$ docker inspect <cid> --format '{{.State.Health.Status}}'
healthy

# Common patterns
healthcheck:
  test: ["CMD-SHELL", "wget -qO- http://localhost/health || exit 1"]
  interval: 30s        # how often
  timeout: 5s          # max time per check
  retries: 3           # consecutive failures before unhealthy
  start_period: 60s    # grace period during which failures don't count

Profiles

services:
  app:
    image: myapp
  db:
    image: postgres:16
  pgadmin:
    profiles: [tools]
    image: dpage/pgadmin4
  load-test:
    profiles: [test]
    image: myapp-loadtest

# Default: only 'app' and 'db' come up
$ docker compose up

# Add the tools profile
$ docker compose --profile tools up
# Now app + db + pgadmin

# Multiple profiles
$ docker compose --profile tools --profile test up

Watch Mode (v2.22+)

services:
  api:
    build: ./api
    develop:
      watch:
        - action: sync
          path: ./api/src
          target: /app/src
          ignore:
            - node_modules/
        - action: rebuild
          path: ./api/package.json

# Run with watch
$ docker compose up --watch
# Editing api/src/server.js syncs into the container; nodemon restarts.
# Editing api/package.json triggers a full rebuild.

Compose vs Kubernetes

AspectComposeKubernetes
ScopeSingle host (one Docker daemon)Cluster of nodes
NetworkingDocker bridge networksCNI plugin (overlay or eBPF)
ReconciliationImperative (up/down/restart)Declarative (controllers reconcile state)
Service discoveryEmbedded DNS, service namekube-dns + Service resource
StorageNamed volumes, bind mountsPV / PVC / StorageClass
Best forDev, CI, small prodMulti-tenant clusters at scale

Tradeoffs

When Compose wins
  • Local development environments — one command, full stack
  • CI test environments — same compose file as dev
  • Small single-host production deployments
  • Tutorials and reproducible demos
Where it breaks down
  • No multi-host scheduling (swarm exists but is in maintenance mode)
  • No rolling updates beyond restart policies
  • No autoscaling, no HA primitives
  • YAML is ephemeral — no controller-style drift correction

Frequently Asked Questions

What's the difference between Compose v1 and v2?

Compose v1 was a Python tool (docker-compose, with a hyphen) installed separately. Compose v2 (docker compose, with a space) is a Go binary shipped as a Docker CLI plugin since 2021. v2 is faster, supports the unified Compose Specification spec maintained at compose-spec.io, and is what new installations get by default. v1 has been EOL since mid-2023. Compose files (compose.yml, docker-compose.yml) are mostly compatible between them, but only v2 supports newer features like profiles and watch mode.

depends_on doesn't actually wait for readiness — why?

Plain depends_on only waits for the container to be running, not for the app inside to be ready. A web service depending on Postgres will start as soon as the postgres container is up — even if Postgres hasn't finished initializing. Use depends_on with condition: service_healthy combined with a healthcheck on the dependency. Compose then waits until docker reports the container as healthy before starting the dependent. The healthcheck can be any command that exits 0 when the service is ready (e.g., pg_isready, curl localhost/health).

How does Compose differ from Kubernetes?

Compose is a single-host orchestrator: services run on one Docker daemon, networking is just a bridge network the daemon manages. Kubernetes is a cluster orchestrator: services span many nodes, networking is via CNI plugins (Calico, Cilium), scheduling is via the scheduler. Compose has no concept of declarative reconciliation — you 'docker compose up' and that's it. Kubernetes constantly reconciles desired state via controllers. Compose is dramatically simpler and is the right choice for dev environments, CI, and small single-host production deployments. Kubernetes is for fleets.

When to use profiles?

Profiles let you tag services so they only start when explicitly requested. Common pattern: a base compose file defines the core services (app, db); a 'tools' profile defines optional ones (admin UI, metrics dashboard) that are useful for debugging but not for normal runs. 'docker compose up' runs default services; 'docker compose --profile tools up' adds the tagged ones. Cleaner than maintaining multiple compose files for slightly different scenarios.

What does watch mode do?

Watch mode (added in Compose v2.22, 2023) tells Compose to re-sync files into running containers as you edit them on the host, without rebuilding the image. You declare actions per file pattern: 'sync' copies file changes into the container; 'rebuild' triggers a full rebuild for changes that need it (e.g., Dockerfile changes). It's the docker-compose answer to Skaffold or Tilt for Kubernetes — fast inner-loop development without losing the production-like environment.

env_file vs environment vs secrets — which when?

environment hard-codes values into the compose file (visible in 'docker inspect'). env_file references a file that's read at compose time and the values are baked into the container's environment (also visible in inspect). Both are fine for non-sensitive config. For real secrets (API keys, DB passwords), use the secrets feature: secrets are mounted as files inside the container at /run/secrets/<name>, never appearing in the environment. With swarm mode they're encrypted; in single-host they're just bind-mounted from a file or external store.