Software Engineering Wiki

Containers and IaC

Docker Compose

Compose v2 commands, the compose.yaml keys that matter, and the behaviour behind recreation, dependencies and overrides.

Cheatsheet #

TaskCommand
Start, wait for healthydocker compose up -d --wait
Rebuild changed images and startdocker compose up -d --build
Stop and delete the datadocker compose down -v
What is running, with healthdocker compose ps -a
Follow one service’s logsdocker compose logs -f --tail 50 web
Shell in a running servicedocker compose exec web sh
One-off task containerdocker compose run --rm web ./migrate
Resolved, merged configurationdocker compose config
Scale one servicedocker compose up -d --scale worker=3
Force replacementdocker compose up -d --force-recreate
Second isolated copydocker compose -p feature-x up -d
Rebuild on file changedocker 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 -d

Lifecycle #

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 -v

compose.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
KeyBehaviour
depends_on (list form)Start order only; says nothing about readiness
depends_on with conditionservice_healthy, service_started, service_completed_successfully
healthcheck.start_periodFailures during this window do not count towards retries
restartno, always, on-failure, unless-stopped — Compose-managed, not the deploy key
deploy.resourcesHonoured by docker compose up on a single host, despite the Swarm-era name
exposeDocumentation only; ports is what publishes
profilesService is skipped unless its profile is selected
develop.watchSync or rebuild triggers for docker compose watch
extra_hostsExtra /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: warn

Interpolation 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 events

run 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

Last updated 15 September 2026 · Edit this page