Docker Compose
- The file is
compose.yamland the command isdocker compose(no hyphen). The olddocker-composebinary and v1/v2version:keys are gone from the spec. depends_onalone only waits for the container to start, not for the service to be ready. Combine it withcondition: service_healthyand a real healthcheck.- Environment precedence is: shell >
--env-file>env_file:in the service >environment:in the service > DockerfileENV. Know which one you are touching. compose.override.yamlis read automatically for dev. Prod uses explicit-f compose.yaml -f compose.prod.yaml.- Profiles keep opt-in services (admin UIs, seed jobs) out of the default startup.
- Named volumes are almost always the right answer for stateful containers; bind mounts are for code you are actively editing.
v3 vs the modern Compose spec
The version: "3.8" you remember is a Docker Swarm artefact. The current reality:
- There is one Compose specification, maintained at compose-spec.io. It supersedes all the
version: "x.y"dialects. - The top-level
version:key is ignored by modern Docker and Podman. Delete it. - The filename is
compose.yaml(orcompose.yml);docker-compose.ymlstill works but is the old name. - The CLI is
docker compose, a plugin of the Docker CLI. The old Pythondocker-composebinary is EOL. - Podman can run the same file via
podman compose(a shim overdocker-composeor native) orpodman-compose.
Services, networks, volumes
The three top-level keys you will use 99% of the time:
services:
web:
image: nginx:1.27-alpine
ports:
- "8080:80"
networks: [frontend]
depends_on:
app:
condition: service_healthy
app:
build: ./app # or image: myrepo/app:tag
networks: [frontend, backend]
environment:
DATABASE_URL: postgres://app:${DB_PASS}@db:5432/app
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:3000/healthz"]
interval: 10s
timeout: 3s
retries: 5
start_period: 20s
db:
image: postgres:16-alpine
networks: [backend]
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASS}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
timeout: 3s
retries: 10
networks:
frontend: {}
backend:
internal: true # no egress to the outside world
volumes:
pgdata: {}
Three principles hiding in that file:
- Service names are hostnames. Inside the Compose network,
appcan reach the DB asdb:5432. No IPs, no/etc/hosts. - Network segmentation is cheap. Split
frontend(receives traffic) frombackend(the DB's private net). Markbackend: internal: trueso nothing there can reach the internet by accident. - Named volumes outlive containers.
docker compose downdoes not remove them;docker compose down -vdoes.
depends_on and service_healthy
The short form does not do what you think:
services:
app:
depends_on:
- db # starts db first, but does NOT wait for db to be ready
The kernel has started Postgres, but Postgres may still be running crash-recovery. Your app will get connection refused or FATAL: database system is starting up. Use the long form with a condition:
services:
app:
depends_on:
db:
condition: service_healthy # wait until db's healthcheck passes
migrations:
condition: service_completed_successfully # wait until a one-shot job exits 0
The conditions you can pass are:
service_started(the default; often wrong)service_healthy— waits for the target's healthcheckservice_completed_successfully— for one-shot jobs (migrations, seeds)
Environment: env_file, environment, precedence
services:
app:
env_file:
- .env.defaults # checked in, sensible defaults
- .env.local # not checked in, operator overrides
environment:
LOG_LEVEL: ${LOG_LEVEL:-info} # falls back to 'info' if unset
Precedence from lowest to highest (higher wins):
- Dockerfile
ENV env_file:(later files override earlier)environment:in the service--env-fileon the CLI- Shell environment at
docker compose uptime
${X} in compose.yaml is interpolated at parse time from the shell (and .env next to the file). environment: {X: ...} is passed to the container at run time. Two different layers.
Profiles: opt-in services
Profiles keep admin tooling (pgAdmin, a seeder, a debug shell) in the Compose file without starting them on docker compose up.
services:
pgadmin:
image: dpage/pgadmin4
profiles: [tools]
depends_on: [db]
environment:
PGADMIN_DEFAULT_EMAIL: a@b.c
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASS}
seed:
build: ./seed
profiles: [seed]
depends_on:
db: { condition: service_healthy }
docker compose up # normal stack, pgadmin & seed skipped
docker compose --profile tools up # add pgadmin
docker compose run --rm seed # run the one-shot seed job
extends and DRY
When three services share the same healthcheck, logging, or restart policy, pull the common bits into a fragment and extends::
# compose.common.yaml
services:
_base:
restart: unless-stopped
logging:
driver: journald
options: { tag: "{{.Name}}" }
deploy:
resources:
limits: { memory: 512M }
# compose.yaml
services:
app:
extends:
file: compose.common.yaml
service: _base
build: ./app
worker:
extends:
file: compose.common.yaml
service: _base
build: ./worker
For simple overrides within a single file, YAML anchors (&name/*name) are often clearer than extends.
Override files for dev vs prod
Compose reads compose.yaml and compose.override.yaml automatically. Convention:
compose.yaml— canonical. Works everywhere.compose.override.yaml— dev conveniences. Bind-mount source code, expose a debugger port, drop CPU limits.compose.prod.yaml— prod specifics. Use with-f compose.yaml -f compose.prod.yaml. Never loaded by default.
# compose.override.yaml — dev only
services:
app:
build:
context: ./app
target: dev # stop at a dev stage of the Dockerfile
volumes:
- ./app/src:/app/src # live-reload bind mount
environment:
LOG_LEVEL: debug
ports:
- "9229:9229" # node inspector
docker compose up # uses override, dev mode
docker compose -f compose.yaml -f compose.prod.yaml up -d # prod
Tuning healthchecks
A bad healthcheck is worse than none. The four knobs:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s # probe cadence when the container is up
timeout: 3s # each probe's timeout; must be < interval
retries: 10 # consecutive failures before going 'unhealthy'
start_period: 20s # grace window: failures here don't count
# (use for DBs doing crash-recovery or JVM warmup)
Rules of thumb:
- Healthcheck should exercise the real readiness signal (DB ready to query, HTTP endpoint returning 200), not just "the process exists".
start_periodis the difference between a working compose stack and one that gives up during DB crash recovery. Set it generously.- Prefer
CMDarray form overCMD-SHELLwhere possible — one fewer process per probe. - The healthcheck image must contain the binary you invoke.
curlis not in most distroless images; use the app's own/healthzendpoint or a statically linked probe.
Common pitfalls
Docker network IP clashes
Docker's default bridge is 172.17.0.0/16. Compose makes per-project networks in 172.18.0.0/16 and up. If your corporate VPN uses the same range, containers can't reach the VPN (or vice-versa). Fix in /etc/docker/daemon.json:
{
"default-address-pools": [
{ "base": "10.201.0.0/16", "size": 24 }
]
}
Restart dockerd, docker compose down/up.
Bind-mount permissions
On Linux, a container writing to a host-mounted ./data will create files owned by the container's UID, not yours. Under rootless Podman, that UID maps into your sub-UID range and looks like a four-digit host UID you don't recognise. Fixes:
- Use a named volume for anything the container owns.
- If you must bind-mount, set the container's
user: "${UID}:${GID}"so it writes as you. - Under rootless Podman,
podman unshare chown -R 1000:1000 ./data.
Volume vs bind: ownership surprises
Named volumes are pre-chowned to the container's first-run user. Bind mounts are not — whatever is already on the host wins.
Don't commit .env
Compose reads .env automatically for interpolation. Put secrets there and you will commit them. Either use .env.defaults + .env.local and gitignore .env.local, or pull secrets from your cloud's secret manager via entrypoint at container start.
A complete example: nginx + app + postgres
Works end-to-end. App is a trivial Node service behind nginx with a Postgres DB and a one-shot migrations container.
# compose.yaml
services:
nginx:
image: nginx:1.27-alpine
ports: ["80:80"]
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
app: { condition: service_healthy }
networks: [edge]
app:
build: ./app
environment:
DATABASE_URL: postgres://app:${DB_PASS}@db:5432/app
LOG_LEVEL: ${LOG_LEVEL:-info}
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/healthz"]
interval: 10s
timeout: 3s
retries: 5
start_period: 15s
depends_on:
migrate: { condition: service_completed_successfully }
networks: [edge, data]
migrate:
build: ./app
command: ["npm", "run", "migrate"]
environment:
DATABASE_URL: postgres://app:${DB_PASS}@db:5432/app
depends_on:
db: { condition: service_healthy }
networks: [data]
restart: "no"
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASS}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
networks: [data]
networks:
edge: {}
data:
internal: true # DB never touches the outside world
volumes:
pgdata: {}
DB_PASS=changeme docker compose up -d
docker compose ps
docker compose logs -f app
docker compose exec db psql -U app -d app
docker compose down # keeps the volume
docker compose down -v # wipes the db
Next: when one host isn't enough, escalate to Kubernetes Light. For the "why" under all of this, see Containers 101.