Containers and IaC
Docker Compose
Compose v2 commands, the compose.yaml keys that matter, and the behaviour behind recreation, dependencies and overrides.
Cheatsheet #
| Task | Command |
|---|---|
| Start, wait for healthy | docker compose up -d --wait |
| Rebuild changed images and start | docker compose up -d --build |
| Stop and delete the data | docker compose down -v |
| What is running, with health | docker compose ps -a |
| Follow one service’s logs | docker compose logs -f --tail 50 web |
| Shell in a running service | docker compose exec web sh |
| One-off task container | docker compose run --rm web ./migrate |
| Resolved, merged configuration | docker compose config |
| Scale one service | docker compose up -d --scale worker=3 |
| Force replacement | docker compose up -d --force-recreate |
| Second isolated copy | docker compose -p feature-x up -d |
| Rebuild on file change | docker compose watch |
docker compose, not docker-compose
Compose v1 (the Python docker-compose script) reached end of life in July 2023. V2 is a CLI plugin. The version: key at the top of a Compose file is obsolete and ignored.
How Compose decides what to do #
Compose hashes the configuration it used to create each container and stores it as a label. On up it compares the hash to the file it just read: matching containers are left alone, changed ones are recreated. A rebuilt base image does not change the Compose configuration, so --build or --force-recreate is what picks it up.
Everything is scoped by project name, which defaults to the directory name and prefixes every container, network and volume. Two checkouts with different -p values run side by side without seeing each other.
Recreating a service reattaches the old volumes, which is why a changed database init script appears to do nothing — the initialisation only runs on an empty data directory. docker compose down -v removes the volumes and the data with them.
docker compose config # merged result after overrides and ${VAR} interpolation
docker compose config --services # names only
docker compose -p feature-x up -d # isolated copy of the same file
COMPOSE_PROJECT_NAME=ci docker compose up -dLifecycle #
docker compose up -d # create or update to match the file
docker compose up -d --wait # block until healthchecked services report healthy
docker compose up -d --no-deps web # just this service, leave dependencies alone
docker compose stop # keep containers, stop processes
docker compose start
docker compose restart web
docker compose down # remove containers and networks
docker compose down -v --remove-orphans # plus volumes and containers no longer in the file--wait only waits for services that define a healthcheck; without one it returns as soon as the container is running, which is not the same as ready. In CI that difference is the sleep 30 people add later.
docker compose up -d --wait --wait-timeout 120
./run_tests
docker compose down -vcompose.yaml #
services:
web:
build:
context: .
dockerfile: Dockerfile
args:
APP_ENV: production
image: registry.example.com/web:1.0 # tag for the built image, or the image to pull
command: ["server", "--port", "8080"] # overrides CMD
entrypoint: ["/app/start.sh"] # overrides ENTRYPOINT
ports:
- "127.0.0.1:8080:8080" # host:container, bound to loopback
environment:
DB_HOST: db
DB_PASSWORD: ${DB_PASSWORD:?set it in .env}
env_file: [.env]
volumes:
- ./src:/app/src:ro
- webdata:/app/data
depends_on:
db:
condition: service_healthy
migrate:
condition: service_completed_successfully
restart: unless-stopped
user: "10001:10001"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/health"]
interval: 10s
timeout: 3s
retries: 3
start_period: 30s
deploy:
resources:
limits: { cpus: "1.5", memory: 512M }
db:
image: postgres:17
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- dbdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
retries: 10
volumes:
webdata:
dbdata:
networks:
default:
name: myapp-net| Key | Behaviour |
|---|---|
depends_on (list form) | Start order only; says nothing about readiness |
depends_on with condition | service_healthy, service_started, service_completed_successfully |
healthcheck.start_period | Failures during this window do not count towards retries |
restart | no, always, on-failure, unless-stopped — Compose-managed, not the deploy key |
deploy.resources | Honoured by docker compose up on a single host, despite the Swarm-era name |
expose | Documentation only; ports is what publishes |
profiles | Service is skipped unless its profile is selected |
develop.watch | Sync or rebuild triggers for docker compose watch |
extra_hosts | Extra /etc/hosts entries; host.docker.internal:host-gateway reaches the host |
Services on the same Compose network resolve each other by service name via Docker’s embedded DNS. Nothing needs links, which is legacy.
Overrides and profiles #
Later -f files are merged over earlier ones: maps merge key by key, scalars replace, and lists replace wholesale unless the key is ports-style additive. compose.override.yaml is picked up automatically when no -f is given.
docker compose -f compose.yaml -f compose.prod.yaml up -d
docker compose --profile debug up -d # include services tagged with that profile
docker compose --env-file .env.staging config # check interpolation before applying# compose.prod.yaml — only the deltas
services:
web:
build: !reset null
image: registry.example.com/web:1.0
environment:
LOG_LEVEL: warnInterpolation reads the shell environment and the .env file next to the Compose file, not env_file (which is passed into the container instead). ${VAR:?message} fails the run when unset, ${VAR:-default} supplies a fallback.
Running commands #
docker compose exec web sh # into an existing container
docker compose run --rm web ./migrate # new container, no ports published
docker compose run --rm --service-ports web # same, but publish the declared ports
docker compose cp web:/app/report.csv .
docker compose logs -f --since 10m web db
docker compose top
docker compose events --json # stream lifecycle eventsrun bypasses ports by default to avoid colliding with the already running service — that is why a run container can reach the stack but nothing can reach it.
Oneliners #
# Everything unhealthy in the project
docker compose ps --format json | jq -r 'select(.Health=="unhealthy") | .Name'
# Wait for a specific service without --wait
until [ "$(docker compose ps -q db | xargs docker inspect -f '{{.State.Health.Status}}')" = healthy ]; do sleep 1; done
# Image digests actually in use
docker compose ps -q | xargs docker inspect -f '{{.Name}} {{.Image}}'
# Diff the merged config between two override sets
diff <(docker compose -f compose.yaml config) <(docker compose -f compose.yaml -f compose.prod.yaml config)
# Tear down every project started from this directory tree
find . -name 'compose.y*ml' -execdir docker compose down -v \;
# Recreate one service with a fresh image
docker compose pull web && docker compose up -d --force-recreate --no-deps web
# Total disk used by this project's volumes
docker volume ls -q -f "label=com.docker.compose.project=$(basename "$PWD")" | xargs docker volume inspect -f '{{.Mountpoint}}' | xargs du -sh