04-compose-and-debugging

Docker Compose Today: Versions, depends_on and Watch

Clear up the v1/v2/v5 confusion in older tutorials, then master the depends_on subtlety behind most flaky local stacks.

September 13, 2026
dockercomposedepends_onhealthcheckprofilescompose-watchenv-files

First, the Version Confusion

Compose has two separate version numbers, and older tutorials conflate them constantly. Untangling this takes two minutes and saves a lot of head-scratching.

The tool. The original docker-compose — hyphenated, written in Python — is v1 and is end-of-life. The current tool is a Go-based plugin invoked as docker compose, with a space. Its CLI version numbering jumped from v2 straight to v5, deliberately skipping 3 and 4 so the tool version could never be mistaken for a file format version.

The file format. Compose files once carried a top-level version: key — version: "2", version: "3.8". That key is now obsolete. The official reference is explicit: it is "only informative and you'll receive a warning message that it is obsolete if used," and Compose "always uses the most recent schema to validate the Compose file, regardless of the version field."

So when you see this at the top of a tutorial:

yaml
version: "3.8"      # delete this line
services:
  ...

Delete it. It does nothing except produce a warning.

💡

Two quick rules for reading any Compose material you find: if it uses docker-compose with a hyphen, it's at least several years old and worth cross-checking. If it tells you to pick a version: value to unlock a feature, that advice no longer applies — every feature is available, and compatibility is determined by your Compose version, not a number in the file.

A Realistic Compose File

yaml
name: myapp
 
services:
  db:
    image: postgres:17
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?required}
      POSTGRES_DB: myapp
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 10s
 
  cache:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      retries: 10
 
  api:
    build:
      context: .
      target: runtime
    environment:
      DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD}@db:5432/myapp
      REDIS_URL: redis://cache:6379
    ports:
      - "127.0.0.1:3000:3000"
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy
 
volumes:
  pgdata:

Several things from earlier phases are quietly at work here. Compose creates a user-defined network for the project automatically, which is why db and cache resolve as hostnames — that's the DNS behaviour from phase 3, set up for you. The named volume is mounted at Postgres's real data directory. The port binds to 127.0.0.1 rather than every interface. And target: runtime builds the runtime stage of the multi-stage Dockerfile from phase 2.

The depends_on Trap

This is the highest-value detail in the guide, and it causes more flaky local stacks than anything else.

Plain depends_on looks like it waits for a dependency to be ready. It does not.

yaml
api:
  depends_on:
    - db          # waits only for the db CONTAINER to start

The short form is equivalent to condition: service_started — Compose waits for the container to be started, not for the application inside it to be ready to accept connections. Postgres takes a few seconds to initialise after its container starts. Your API connects during that gap, fails, and exits.

The symptom is unmistakable once you know it: the stack works most of the time, and fails on a cold start or a slower machine. It's a race, and races are intermittent.

The fix pairs a health check on the dependency with a condition on the dependent:

yaml
depends_on:
  db:
    condition: service_healthy

Compose supports three conditions:

ConditionWaits for
service_startedThe container to start — the default, and rarely what you want
service_healthyThe service's health check to pass
service_completed_successfullyThe service to run to completion and exit 0

That third one is how you sequence a migration job before the application starts:

yaml
  migrate:
    build: .
    command: ["npm", "run", "migrate"]
    depends_on:
      db:
        condition: service_healthy
 
  api:
    build: .
    depends_on:
      migrate:
        condition: service_completed_successfully
⚠️

condition: service_healthy only works if the dependency actually defines a healthcheck. Without one there's no health status to wait for. This is the other half of the fix people miss — they add the condition, get an error or no improvement, and conclude the feature is broken.

Check yourself

A stack with `depends_on: [db]` works on a developer's laptop but fails roughly one CI run in four, with the API exiting on a database connection error. What's the cause?

Environment Variables and .env

Compose interpolates ${VAR} in your Compose file from the shell environment and from a .env file sitting beside it. This is distinct from the environment: block, which sets variables inside a container — a distinction worth holding onto:

yaml
environment:
  # Set inside the container; the value comes from .env or the shell
  DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD}@db:5432/myapp

Useful interpolation forms:

yaml
${VAR}              # empty if unset
${VAR:-default}     # use "default" if unset or empty
${VAR:?message}     # fail with this error if unset — good for required secrets

That third form is worth adopting for anything mandatory. Failing loudly at startup beats booting with an empty password and discovering it later.

🚨

.env belongs in both .gitignore and .dockerignore. Committed .env files are one of the most common credential leaks in container work, and as phase 2 showed, a .dockerignore omission can also bake them into your image where docker history will find them. Commit a .env.example with placeholder values instead.

Profiles: Optional Services

Not every service should start every time. Profiles let you keep optional ones in the same file without running them by default:

yaml
  mailhog:
    image: mailhog/mailhog
    profiles: ["dev"]
 
  loadtest:
    image: grafana/k6
    profiles: ["perf"]

A service with a profile is skipped unless you ask for it:

bash
docker compose up                      # core services only
docker compose --profile dev up        # plus mailhog

This is much tidier than maintaining several near-identical Compose files that drift apart.

Multiple Files and Overrides

Compose merges files, which is the clean way to vary one stack across environments:

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

Later files override earlier ones. A compose.override.yaml sitting beside compose.yaml is picked up automatically, which is the usual home for developer-only settings — bind-mounted source, debug ports, relaxed limits — so the base file stays deployable.

Merging rules have sharp edges, particularly for lists. Before trusting a multi-file setup, run:

docker compose -f compose.yaml -f compose.prod.yaml config

It prints the fully merged, interpolated configuration that Compose will actually use. This is the Compose equivalent of docker inspect — the resolved truth rather than your reading of three files at once.

Compose Watch

For development, watch syncs changes into running containers without a manual rebuild cycle:

yaml
  api:
    build: .
    develop:
      watch:
        - action: sync
          path: ./src
          target: /app/src
          ignore:
            - node_modules/
        - action: rebuild
          path: package.json
bash
docker compose up --watch

Three actions are available:

  • sync — copy changed files into the container. Right for anything with hot reload.
  • rebuild — rebuild the image and replace the container. Right for compiled languages, and for dependency manifests like package.json.
  • sync+restart — sync, then restart the service. Right for config files that are read at startup.

The pattern above covers the common case: fast syncs for source code, a full rebuild when dependencies change.

Check yourself

Which command shows you exactly what configuration Compose will apply, after merging override files and interpolating variables?

Commands Worth Knowing

bash
docker compose up -d              # start in the background
docker compose up --build         # rebuild images first
docker compose ps                 # status, including health
docker compose logs -f api        # follow one service's logs
docker compose exec api sh        # shell into a running service
docker compose config             # the resolved configuration
docker compose down               # stop and remove containers + networks
docker compose down -v            # ...and delete the named volumes
🚨

docker compose down -v deletes your named volumes — the database. It's a reasonable way to reset a dev environment and a catastrophe when run out of habit against something you cared about. Plain down leaves volumes intact.

What's Next

You can now describe a whole stack in one file. The next guide is about what to do when that stack misbehaves: a fixed diagnostic order, what the common exit codes and error messages actually mean, and how to get a shell into a distroless container that doesn't have one.