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.
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:
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
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.
api:
depends_on:
- db # waits only for the db CONTAINER to startThe 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:
depends_on:
db:
condition: service_healthyCompose supports three conditions:
| Condition | Waits for |
|---|---|
service_started | The container to start — the default, and rarely what you want |
service_healthy | The service's health check to pass |
service_completed_successfully | The service to run to completion and exit 0 |
That third one is how you sequence a migration job before the application starts:
migrate:
build: .
command: ["npm", "run", "migrate"]
depends_on:
db:
condition: service_healthy
api:
build: .
depends_on:
migrate:
condition: service_completed_successfullycondition: 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:
environment:
# Set inside the container; the value comes from .env or the shell
DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD}@db:5432/myappUseful interpolation forms:
${VAR} # empty if unset
${VAR:-default} # use "default" if unset or empty
${VAR:?message} # fail with this error if unset — good for required secretsThat 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:
mailhog:
image: mailhog/mailhog
profiles: ["dev"]
loadtest:
image: grafana/k6
profiles: ["perf"]A service with a profile is skipped unless you ask for it:
docker compose up # core services only
docker compose --profile dev up # plus mailhogThis 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:
docker compose -f compose.yaml -f compose.prod.yaml up -dLater 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:
api:
build: .
develop:
watch:
- action: sync
path: ./src
target: /app/src
ignore:
- node_modules/
- action: rebuild
path: package.jsondocker compose up --watchThree 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 likepackage.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
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 volumesdocker 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.