Compose for Multi-Service Stacks

Declare a whole stack in one file, gate startup on real health rather than depends_on, and make the same definition serve development, CI and a small production host.

advanced 22 min lesson hands-on task included

One docker run line with fifteen flags is already hard to review. Four of them, in the right order, with a shared network and two volumes, is not something anyone should be typing. Compose puts the whole stack in a YAML file that lives in your repository.

Note the modern spelling: docker compose (a subcommand of Docker, v2) rather than the old standalone docker-compose Python tool. The old form still works in many places and the file format is compatible, but v2 is what you should be writing against.


Topic 1: A Complete Stack in One File

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: appdb
      POSTGRES_USER: app
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets: [db_password]
    volumes:
      - pgdata:/var/lib/postgresql/data
    networks: [backend]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    restart: unless-stopped

  api:
    build:
      context: .
      dockerfile: Dockerfile
      target: runtime
    environment:
      DATABASE_URL: postgres://app@db:5432/appdb
    depends_on:
      db:
        condition: service_healthy
    networks: [backend, frontend]
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
      interval: 15s
      start_period: 20s
    restart: unless-stopped

  proxy:
    image: nginx:1.27-alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      api:
        condition: service_healthy
    networks: [frontend]
    restart: unless-stopped

volumes:
  pgdata:

networks:
  frontend:
  backend:

secrets:
  db_password:
    file: ./secrets/db_password.txt

Everything in the previous six lessons is in there: images and tags, a build context, named volumes, user-defined networks, healthchecks, restart policies, port publishing. Compose is not a new set of concepts — it is a declarative front end for the ones you already have.

Two design points in that file are worth stating explicitly, because they are the difference between a demo and something defensible:

The database port is not published. api reaches db:5432 over the backend network by service name. Publishing 5432 would expose the database to anything that can reach the host, for no benefit at all.

proxy is not on backend. It cannot reach the database even if it is compromised. Two networks and three services is the smallest useful piece of segmentation, and it costs four lines.


Topic 2: The Commands

docker compose up -d                  # build if needed, create networks/volumes, start everything
docker compose ps                     # status and health of each service
docker compose logs -f api            # follow one service's logs
docker compose logs -f                # all services, interleaved and colour-coded
docker compose exec api sh            # shell into a running service
docker compose run --rm api npm test  # one-off container with the service's config
docker compose build --no-cache api   # rebuild one service from scratch
docker compose restart api
docker compose stop                   # stop, keep containers and volumes
docker compose down                   # stop and remove containers and networks
docker compose down -v                # ...and DELETE the named volumes
docker compose config                 # render the fully resolved file — the debugging command

docker compose config is the one people discover too late. It prints the file after variable substitution, extends resolution and override merging, which turns “why is it using the wrong port” into a five-second answer.

docker compose down -v deletes your volumes. It is the correct way to reset a development database and a genuinely bad thing to type on a host running anything you care about.


Topic 3: depends_on Does Less Than You Think

Compose starts services in dependency order — that part is true and it has been true since v1, driven by depends_on, links and volumes_from.

What plain depends_on does not do is wait for a service to be ready. It waits for the container to be started. Postgres takes several seconds after container start before it accepts connections, so a plain depends_on: [db] reliably gives you an API that crashes on its first connection attempt.

There are two correct fixes and one workaround:

depends_on:
  db:
    condition: service_healthy      # requires a healthcheck on db. This is the right answer.
depends_on:
  migrate:
    condition: service_completed_successfully   # for one-shot init/migration containers

And the workaround worth keeping anyway: make your application retry its dependencies. Even with health gating, a database can restart at any point in the container’s life, and an application that dies permanently the first time a connection fails is fragile regardless of startup ordering. Health gating fixes startup; retry logic fixes the other 99% of the uptime.

This is also the practical reason HEALTHCHECK from lesson 6 earns its place. A healthcheck that nothing reads is decoration; a healthcheck that gates dependent services is infrastructure.


Topic 4: One Definition, Several Environments

You do not want three unrelated compose files that drift. Compose gives you three mechanisms to avoid that.

Variable substitution with a .env file:

services:
  api:
    image: acme/api:${API_VERSION:-1.4.2}
    ports:
      - "${API_PORT:-8080}:8080"

The :-default syntax matters — without it, an unset variable silently becomes an empty string and produces confusing errors.

Override files. docker compose up automatically merges compose.yaml with compose.override.yaml if the latter exists. Keep the shared definition in the base and the development conveniences in the override:

# compose.override.yaml — development only, not committed to prod deploys
services:
  api:
    build:
      target: dev
    volumes:
      - ./src:/app/src              # live source bind mount (lesson 8)
    environment:
      LOG_LEVEL: debug
    ports:
      - "9229:9229"                 # debugger port

For anything else, be explicit:

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

Profiles keep optional services out of the default run:

services:
  mailhog:
    image: mailhog/mailhog
    profiles: [dev]
docker compose --profile dev up -d      # includes mailhog
docker compose up -d                    # does not

Topic 5: Taking Compose to Production

Compose in production is a legitimate choice on a single host — an internal tool, a small service, a staging environment. It is not an orchestrator: no scheduling across machines, no rolling updates, no self-healing beyond restart policies. If you need those, the answer is Kubernetes.

Within that boundary, the changes a compose file needs before it leaves a laptop:

Pin every image. No :latest, no floating tags. Version or digest.

Set restart policies. restart: unless-stopped on every long-running service, for the reasons in lesson 3.

Set resource limits. Compose’s deploy.resources section applies with docker compose up in v2:

    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
        reservations:
          memory: 256M

Bind published ports deliberately. "127.0.0.1:8080:8080" rather than "8080:8080" for anything that sits behind a proxy on the same host.

Configure logging. The default json-file driver grows without limit and has filled more disks than any other single Docker default:

    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

Get secrets out of the file. environment: values are visible in docker inspect, in docker compose config, and in the process environment of anything inside the container. Use Compose secrets (mounted as files under /run/secrets/, as in the Topic 1 example) or inject from a real secret manager. Never commit a password to a compose file, and never commit the .env that holds one — add it to .gitignore and .dockerignore both.

Add a log aggregator or shipping sidecar if this host is one you will actually have to debug remotely.

Try it yourself: Take the Topic 1 file, remove the condition: service_healthy from api, and run docker compose up. Watch the API fail its first database connection. Add the condition back and watch Compose wait. That is the whole difference between “started” and “ready”, demonstrated in two runs.

Common mistake: Treating docker compose down as the way to stop a stack for the night. It removes containers and networks, which is usually fine — but down -v is one keystroke away and it removes your volumes. Use docker compose stop when you mean stop.