Skip to content
Docker
Lab 8 of 11·40mIntermediate

Declare the stack with Compose

Replace eight docker run flags with one file, then add a healthcheck so the app waits for the database instead of crash-looping.

You need

  • Docker Engine 24+ with the Compose v2 plugin (docker compose version)

Do first

Lab 3 wired two containers together by hand. Compose writes that down. The part worth learning properly is depends_on with a condition, because the naive version does not do what its name suggests.

1. The stack from lab 3, declared

mkdir -p ~/labs/compose && cd ~/labs/compose
cat > compose.yaml <<'YAML'
services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: labpass
      POSTGRES_DB: labdb
    volumes:
      - dbdata:/var/lib/postgresql/data
    # No ports: only other services need to reach it.

  app:
    image: postgres:16-alpine
    depends_on:
      - db
    environment:
      PGPASSWORD: labpass
    command: >
      sh -c "psql -h db -U postgres -d labdb -c 'select 1' && echo APP_OK"

volumes:
  dbdata:
YAML
docker compose config --quiet && echo "valid"
docker compose up

It fails. depends_on with a plain list waits for the container to start, not for the service inside it to be ready — and Postgres takes a few seconds to accept connections after its process exists.

This is the single most common Compose bug, and "add a sleep" is the single most common wrong fix.

Verify

docker compose up 2>&1 | grep -c "could not connect\|Connection refused" || echo "0 (db was fast)" # 1 or more on most machines

2. Fix it with a healthcheck and a condition

cat > compose.yaml <<'YAML'
services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: labpass
      POSTGRES_DB: labdb
    volumes:
      - dbdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d labdb"]
      interval: 2s
      timeout: 3s
      retries: 15
      start_period: 5s

  app:
    image: postgres:16-alpine
    depends_on:
      db:
        condition: service_healthy
    environment:
      PGPASSWORD: labpass
    command: >
      sh -c "psql -h db -U postgres -d labdb -c 'select 1' && echo APP_OK"

volumes:
  dbdata:
YAML
docker compose up

Now app starts only once db reports healthy. start_period is the grace window during which a failing check does not count against retries — for a database that initialises on first boot, omitting it is why the healthcheck itself fails.

Verify

docker compose up 2>&1 | grep -c APP_OK # 1

3. Read the state

docker compose up -d db
sleep 8
docker compose ps
docker compose ps --format json | head -c 300; echo
docker inspect "$(docker compose ps -q db)" \
  --format '{{.State.Health.Status}}'
docker inspect "$(docker compose ps -q db)" \
  --format '{{range .State.Health.Log}}{{.ExitCode}} {{end}}'

docker compose ps shows health alongside status. The Health.Log is the last few probe results with exit codes — which is where you look when a service is stuck in starting and you need to know what the probe is actually returning.

Verify

docker inspect "$(docker compose ps -q db)" --format '{{.State.Health.Status}}' # healthy

4. Configuration: env files, not hardcoded values

cat > .env <<'ENV'
POSTGRES_PASSWORD=labpass
POSTGRES_DB=labdb
PGPORT=5432
ENV
cat > compose.override.yaml <<'YAML'
services:
  db:
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB:-labdb}
    ports:
      - "${PGPORT}:5432"
YAML
docker compose config | head -n 20

Two substitution forms worth knowing. ${VAR:?message} fails the config if the variable is unset — use it for anything required, so a missing secret is a startup error rather than a silent empty string. ${VAR:-default} supplies a fallback.

compose.override.yaml is merged automatically on top of compose.yaml. That is the supported way to add development-only settings — the port exposure above — without touching the file you deploy.

Always check the merged result:

docker compose config | grep -A3 "ports:"

Verify

POSTGRES_PASSWORD= docker compose config 2>&1 | grep -c "set POSTGRES_PASSWORD" # 1 — the required variable is enforced

5. Logs, restart, and tearing down properly

docker compose logs --tail 5 db
docker compose restart db
docker compose down
docker volume ls | grep -c compose_dbdata
docker compose down --volumes
docker volume ls | grep -c compose_dbdata || echo "0 — volume removed"

down stops and removes containers and the network, and keeps volumes. That is the right default: it means down followed by up does not lose your database. --volumes is the explicit "and delete the data" — the flag to be careful with, and the one you want in CI.

Verify

docker compose ps --all --format '{{.Name}}' | wc -l # 0

What Compose does not do

It is a single-host tool. There is no scheduling, no rolling update, no restart across a fleet. It is excellent for development, integration tests, and a small single-server deployment; the moment you need more than one host, the concepts transfer to Kubernetes but the file does not.

Clean up

docker compose down --volumes 2>/dev/null
cd ~ && rm -rf ~/labs/compose

Where this goes next

The stack is declared. Next: the routine for when one of these containers will not start at all.