Sign in
← Docker
15 min · medium · Lesson 7 of 8

Docker Compose

Describe a stack in yaml, bring it up with one command. depends_on, healthcheck, profiles, the main commands, and where Kubernetes takes over.

Why compose

When you have one service, docker run is enough. When there are two, three, five services, the command balloons into a dozen lines of flags, and reproducing it by hand becomes painful.

Compose solves this by describing the whole stack in one YAML file, and bringing it up with one command.

docker compose up -d

Compose creates the network, attaches the volumes, and starts the containers in the right order.

The main command today is docker compose (the Compose v2 plugin). The old hyphenated docker-compose (v1) hasn't received updates since July 2023; don't install it.

Minimal example

services:
  api:
    build: .
    ports:
      - "127.0.0.1:3000:3000"
    environment:
      DB_HOST: db
      REDIS_HOST: cache
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    restart: unless-stopped

  db:
    image: postgres:16.4-alpine
    environment:
      POSTGRES_PASSWORD: example   # local dev only, use a real secret in prod
      POSTGRES_DB: app
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 10
    restart: unless-stopped

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

volumes:
  pgdata:

What this describes:

Key service fields

FieldWhat it means
imagea ready image from a registry
buildpath to a build context with a Dockerfile (instead of image)
portspublishing ports (see the networking lesson)
environmentenvironment variables, as a map or a list
env_fileload variables from a file
volumesa bind mount or a named volume
depends_onstart order and, optionally, waiting on a healthcheck
networkswhich networks the service joins
restartrestart policy
healthcheckchecks whether the service is ready
commandoverride the image's CMD
entrypointoverride the image's ENTRYPOINT
profileswhich profiles the service is active in

Almost every docker run flag has a matching field: -e is environment, -v is volumes, -p is ports, --memory and --cpus are deploy.resources.limits. Compose resolves relative paths (./app.py) from the folder the compose file is in.

depends_on and healthcheck

On its own, depends_on: [db] only means "start the db container first". It doesn't mean "wait until db is ready to accept connections". On its first start, Postgres spends a few seconds initializing its data directory, and api manages to get a connection refused.

To wait for readiness, you need two things: a healthcheck on the database, and a condition in depends_on on whoever is waiting for it.

services:
  db:
    image: postgres:16.4-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 10

  api:
    image: myapp:1.0
    depends_on:
      db:
        condition: service_healthy   # waits for the healthy status

Possible condition values:

Why -h 127.0.0.1 in pg_isready. While the official postgres image runs the scripts from /docker-entrypoint-initdb.d, it keeps a temporary server that only listens on a unix socket. Without -h, pg_isready connects to the socket and can say "ready" before TCP port 5432 even opens. Over TCP, the check only passes after the real start.

One more detail: ${...} in a compose file is substituted by compose itself, from your shell and the .env file, not by the container. So pg_isready -U ${POSTGRES_USER:-postgres} in a healthcheck picks up the host's variable, not the one set in the service's environment:. If you need the container's variable, write $$POSTGRES_USER.

You can see the readiness status in docker compose ps: a service with a healthcheck shows (healthy), (starting), or (unhealthy) in the STATUS column.

Volumes and networks in compose

A named volume is declared at the top level and used in a service:

services:
  db:
    image: postgres:16.4-alpine
    volumes:
      - pgdata:/var/lib/postgresql/data       # named volume
      - ./initdb:/docker-entrypoint-initdb.d  # bind mount
volumes:
  pgdata:

Networks work the same way. If nothing is specified, compose creates <project>_default and connects every service to it. If you need several networks:

services:
  web:
    networks: [public, db-net]
  db:
    networks: [db-net]
  admin:
    networks: [public]
networks:
  public:
  db-net:

An external network is for talking between different compose projects:

networks:
  shared:
    external: true                # the network already exists, compose doesn't create it

Before that, create it manually: docker network create shared.

Profiles: optional services

Some services aren't needed on every run: an admin panel, a worker for heavy jobs, debugging tools. Profiles are for that:

services:
  api:
    image: myapp:1.0
  db:
    image: postgres:16.4-alpine
  adminer:
    image: adminer:4.8.1
    profiles: [admin]
  debug:
    image: alpine:3.20.3
    command: ["sleep", "infinity"]
    profiles: [debug]
    depends_on: [db]

By default, docker compose up brings up only api and db. To turn on the admin panel:

docker compose --profile admin up -d

Several profiles at once:

docker compose --profile admin --profile debug up -d

Main commands

CommandWhat it does
docker compose up [-d]create and start the stack; if a service's configuration changed, recreate its container
docker compose downstop and remove the containers and networks
docker compose down -vthe same, plus remove the project's volumes
docker compose psstatus of the services
docker compose logs [-f] [service]logs; -f prints new lines as they appear
docker compose exec <svc> shget a shell inside, like docker exec
docker compose pullpull newer image versions
docker compose buildrebuild images from build:
docker compose configshow the final YAML after merging files and substituting variables
docker compose restart [service]restart containers without recreating them

restart doesn't apply edits to the compose file: the container restarts with the old configuration. After editing the file, you need docker compose up -d.

A typical update on a server:

docker compose pull && docker compose up -d

pull fetches new images by tag, up -d recreates containers with the new images. The volumes stay, downtime is minimal. The same in one command: docker compose up -d --pull always.

Several compose files

Without -f, compose looks in the current folder for compose.yaml (the main name; it also understands compose.yml, docker-compose.yaml, and docker-compose.yml). You can name files explicitly and merge several:

docker compose -f compose.yaml -f compose.prod.yaml up -d

Each next file overrides values from the previous one. A typical scheme:

Important detail: as soon as you specify -f, compose reads only the listed files, and compose.override.yaml doesn't make it into the run.

Variables like ${WEB_PORT:-8080} compose takes from the shell environment and from the .env file in the project folder; the value after :- is used only if the variable isn't set. It's easier to check the result with the docker compose config command than in your head: it prints exactly what the containers will get.

Where Kubernetes takes over

Compose is built for one machine. Bring up the stack locally or on one server, and use it. When it's worth looking further:

There's a myth around the deploy: field that it only works in Swarm. Compose v2 applies part of it itself: deploy.replicas, deploy.resources.limits and reservations, deploy.restart_policy. Only the keys that make sense in a Swarm cluster are ignored: placement, endpoint_mode, mode: global.

services:
  web:
    image: myapp:1.0
    deploy:
      replicas: 3
      resources:
        limits:
          cpus: "0.5"
          memory: 512M

You can bring up the same number of copies without editing the file:

docker compose up -d --scale web=3

The trap here is different. If a service has a fixed container_name or a published static port (- "127.0.0.1:8080:80"), several copies won't come up: the name and the port are taken by the first one. With container_name, compose prints a warning and keeps one copy; with a static port, the second copy fails with port is already allocated. To scale, drop container_name, and publish the port as a range or not at all, routing traffic through a reverse proxy.

You can convert compose into Kubernetes manifests with kompose. It's a starting point, not a final result: kompose often generates outdated apiVersion values, and the move still involves rewriting things.

Common mistakes

What you'll need for the tasks

Self-check

Type the command or key line. Case and spaces don't matter.

1. Bring up the whole stack in the background (the compose file is in the current folder).
2. Update the images for all services and restart the stack.
3. Stop the stack and remove the volumes along with the containers (a full reset).

Done reading?

Mark the lesson as read to move your track progress forward.

Go through it with an AI agent

The agent explains anything unclear in plain words, gives you 3 labs for this lesson and drills you on interview questions. $49 one-time, lifetime access.

Get access for $49

Tell the agent: use MCP izi, give me lesson docker-basics-07-compose