Docker Compose

What Compose actually is

Everything Compose does, docker CLI commands could do manually: docker network create, docker volume create, then a docker run per service with the right --network, -v, -e, and -p flags, in the right order. Compose's actual job is to read one YAML file describing that desired end state — which services exist, what they need, how they're connected — and reconcile the running containers to match it, so none of that has to be typed by hand or scripted separately.

The single most important mechanical fact: Compose automatically creates one shared custom network for the whole project and attaches every service to it. This is why, inside a Compose project, a service named api can reach a service named db at the hostname db with zero extra configuration — it's the exact custom-bridge-network DNS mechanism from the previous page, just wired up automatically instead of by hand.

Anatomy of a compose file

services:
  api:
    build: .
    ports:
      - "8000:8000"
    environment:
      - DATABASE_URL=postgresql://user:pass@db:5432/appdb
    depends_on:
      db:
        condition: service_healthy
    networks:
      - app-net

  db:
    image: postgres:16
    environment:
      - POSTGRES_PASSWORD=pass
      - POSTGRES_DB=appdb
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user -d appdb"]
      interval: 5s
      timeout: 3s
      retries: 5
    networks:
      - app-net

volumes:
  db-data:

networks:
  app-net:
graph TD subgraph project["Compose project (one shared network: app-net)"] api["api service<br/>reachable as 'api'"] db["db service<br/>reachable as 'db'"] api -->|"DATABASE_URL=...@db:5432"| db end vol["db-data volume<br/>(named volume, survives 'compose down')"] db --- vol host["host port 8000"] --> api

Reading the file top to bottom: build: . tells Compose to build an image from the Dockerfile in the current directory for the api service, rather than pulling a pre-built one (db uses image: instead, pulling postgres:16 directly — a service uses one or the other, not usually both). ports publishes a port to the host, exactly like docker run -p. environment sets environment variables inside the container — here, api's database connection string references db by its service name, which resolves via the automatic network DNS. The top-level volumes: and networks: blocks declare the named volume/network that services reference by name.

depends_on doesn't mean "wait until ready" — unless told to

The problem: plain depends_on: [db] (a list, not the map form above) only waits for the db container to start — not for PostgreSQL inside it to finish initializing and start accepting connections. Postgres takes a moment after its process starts before it's actually ready to accept queries. Without more, api can start, immediately try to connect, and crash on the very first connection attempt — a classic "works on the second docker compose up, fails on the first" bug, because the second run's db might already be warm from before.

The fix, shown in the compose file above: a healthcheck on db that actually probes readiness (pg_isready), combined with depends_on: db: condition: service_healthy on api. Now Compose delays starting api until db's healthcheck reports healthy, not just "container started" — the same distinction a Dockerfile HEALTHCHECK exists to make visible in the first place, used here to gate startup ordering instead of just external monitoring.

.env files and variable substitution

# .env
POSTGRES_PASSWORD=pass
environment:
  - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}

Compose automatically reads a .env file in the project directory and substitutes ${VAR} references in the compose file itself (not just inside environment: — anywhere in the YAML). This keeps environment-specific or secret values out of the committed compose file — but the .env file itself must then not be committed either (add it to .gitignore), or the secret just moved files instead of actually being protected.

Common commands

docker compose up -d        # start everything, detached
docker compose logs -f api  # follow logs for one service
docker compose exec api sh  # shell into a running service's container
docker compose down         # stop and remove containers + the auto-created network
docker compose down -v      # also remove named volumes (data loss — deliberate)

down (without -v) removes containers and networks but leaves named volumes intact — restarting with up -d again reattaches to the same db-data volume, and Postgres finds its data still there. This is the correct default: containers are disposable, volumes are not, unless -v explicitly says otherwise.

Common pitfall

Using bare depends_on: [db] (list form) on a service whose startup depends on another service being functionally ready, not just running, is the single most common Compose bug — it looks correct, works often enough in casual testing to seem fine, and then fails intermittently in CI or on a cold start specifically because timing varies. If a service crashes on startup with a connection-refused error against a dependency that "should" be up, check whether depends_on has a condition: service_healthy backed by a real healthcheck, not just the container-started list form.