Software Engineering WikiSE Wiki

Docker Compose

Run and debug multi-container stacks with Compose v2: commands, the compose.yaml keys that matter, and how recreation, dependencies and overrides behave.

Reviewed MarkdownEdit

On this page

Cheatsheet#

TaskCommand
Start detached, wait until running or healthydocker compose up -d --wait
Rebuild images and startdocker compose up -d --build
Pull newer images and recreate what changeddocker compose pull && docker compose up -d
Stop and remove containers and networksdocker compose down
Also delete the project’s volumes (data loss)docker compose down -v
Status and health, including stoppeddocker 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
Recreate even if nothing changeddocker compose up -d --force-recreate
Second isolated copy of the stackdocker compose -p feature-x up -d
Sync or rebuild on file changedocker compose watch

docker compose, not docker-compose

Compose v1 (the Python docker-compose script) stopped receiving updates in July 2023. v2 is a Docker CLI plugin written in Go. The top-level version: key is obsolete; Compose ignores it and prints a warning. The file format is the Compose Specification.

For the underlying container, image, volume and network behaviour, see Docker.

How Compose decides what to change#

Compose stores a hash of each service’s resolved configuration in a container label (com.docker.compose.config-hash). On up it compares that hash, and the image the container was created from, with what it just read. Unchanged containers are left running. A container whose configuration or image changed is stopped and recreated, with its volumes reattached.

up does not pull or rebuild on its own when an image already exists locally. A new upstream tag is only picked up after docker compose pull (or up --pull always), and changed source only after --build.

Every resource is scoped by project name, which prefixes containers, networks and volumes and is recorded in the com.docker.compose.project label. The name comes from, in order: -p, COMPOSE_PROJECT_NAME, the top-level name: key, then the directory containing the Compose file. Two checkouts with different project names run side by side without seeing each other.

docker compose config                     # merged result after overrides and ${VAR} interpolation
docker compose config --services          # service names only
docker compose -p feature-x up -d         # isolated copy of the same file
docker compose ls                         # every project running on this daemon

Recreating a database service reattaches its old volume, which is why a changed init script (for example /docker-entrypoint-initdb.d in the postgres image) appears to do nothing: it only runs against an empty data directory.

Lifecycle commands#

docker compose up -d                      # create or update to match the file
docker compose up -d --wait --wait-timeout 120   # block until running, or healthy where a healthcheck exists
docker compose up -d --no-deps web        # only this service, leave its dependencies alone
docker compose stop                       # stop processes, keep containers
docker compose start
docker compose restart web                # restarts the process; does not apply file changes
docker compose down                       # remove containers and the default network
docker compose down --remove-orphans      # also remove containers for services no longer in the file

down -v deletes data

docker compose down -v removes named volumes declared in the file and anonymous volumes attached to the containers. Named volumes marked external: true are kept.

--wait waits for healthy on services with a healthcheck and only for running on the rest. Running is not ready, so give every service that others depend on a healthcheck. A CI pattern:

docker compose up -d --wait --wait-timeout 120
./run-tests
docker compose down -v

restart only restarts the process with the existing container configuration. To apply an edited compose.yaml or .env, run up -d again.

compose.yaml reference#

name: my-app

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"]  # replaces the image CMD
    ports:
      - "127.0.0.1:8080:8080"              # host_ip:host_port:container_port
    environment:
      DB_HOST: db
      DB_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
    env_file:
      - path: ./web.env
        required: false                   # skip silently when the file is missing
    volumes:
      - ./src:/app/src:ro,Z                # bind mount; Z relabels for SELinux
      - webdata:/app/data
    depends_on:
      db:
        condition: service_healthy
        restart: true                      # restart web when db is restarted by Compose
      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 }

  migrate:
    image: registry.example.com/web:1.0
    command: ["./migrate"]
    depends_on:
      db:
        condition: service_healthy

  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:
KeyBehaviour
depends_on (list form)Start and stop order only; says nothing about readiness
depends_on.<svc>.conditionservice_started, service_healthy or service_completed_successfully
depends_on.<svc>.restarttrue restarts this service after Compose updates (recreates or restarts) the dependency (2.17+)
depends_on.<svc>.requiredfalse downgrades a missing dependency to a warning (2.20+)
healthcheck.start_periodFailures in this window do not count towards retries
healthcheck.start_intervalCheck interval during start_period (Compose 2.20.2+)
restartno, always, on-failure[:max], unless-stopped; enforced by the Docker daemon
deploy.resources.limitsApplied by docker compose up on a single host despite the Swarm-era name
ports vs exposeports publishes on the host; expose is metadata only
profilesThe service starts only when one of its profiles is active
develop.watchSync, rebuild or restart rules for docker compose watch
extra_hostsAdds /etc/hosts entries; host.docker.internal:host-gateway reaches the host
external: true (volumes, networks)Compose uses an existing resource and never creates or deletes it

Compose creates a <project>_default network. Services on it resolve each other by service name through Docker’s embedded DNS; links is legacy and unnecessary.

Environment files and interpolation#

Two different mechanisms share the word “env”:

MechanismUsed forSource
Interpolation ${VAR}Substituting values into the Compose file itselfShell environment, then .env in the project directory (the first -f file’s directory by default), or files given with --env-file instead
environment: and env_file:Setting variables inside the containerThe Compose file; env_file contents are not used for interpolation

Shell variables override .env. Syntax: ${VAR:-default} uses the default when unset or empty, ${VAR-default} only when unset, ${VAR:?message} fails the command when unset or empty. Write $$ for a literal $.

docker compose --env-file .env.staging config     # check interpolation before applying
docker compose config --environment               # show the variables used for interpolation

Overrides, merging and profiles#

With no -f, Compose reads compose.yaml and then compose.override.yaml if it exists. With -f, only the files given are read, in order, each merged over the previous. See merge rules.

Kind of valueMerge result
Mappings (environment, labels, deploy)Merged key by key; later file wins on conflict
Scalars (image, restart)Replaced
Sequences in general (dns, cap_add)Appended
command, entrypoint, healthcheck.testReplaced
portsMerged by {ip, target, published, protocol}; new entries appended
volumes, secrets, configsMerged by container target path

Two YAML tags change this: !reset removes a value inherited from an earlier file, and !override replaces it instead of merging.

docker compose -f compose.yaml -f compose.prod.yaml up -d
docker compose --profile debug up -d        # also start services tagged with the debug profile
# compose.prod.yaml: only the differences
services:
  web:
    build: !reset null                       # use the registry image, never build in prod
    ports: !override
      - "8080:8080"
    environment:
      LOG_LEVEL: warn

include: pulls in another Compose file as a separate application with its own project directory, which avoids the relative-path surprises of stacking -f files from different directories.

Profiles#

A service with profiles: is created only when one of those profiles is active. Services without the key always start. Profiles select optional parts of one stack (debug tooling, a local database instead of a managed one, load generators) without a second file.

services:
  web:
    image: registry.example.com/web:1.0
  db:
    image: postgres:17
    profiles: [local-db]
  pgadmin:
    image: dpage/pgadmin4:9
    profiles: [debug, local-db]
  loadgen:
    image: grafana/k6:1.0.0
    profiles: [test]
    depends_on: [web]
docker compose up -d                         # web only
docker compose --profile local-db up -d      # web, db, pgadmin
COMPOSE_PROFILES=debug,test docker compose up -d
docker compose --profile '*' config --services   # every service regardless of profile
docker compose run --rm loadgen run /scripts/smoke.js   # naming a profiled service on the command line activates it implicitly
docker compose --profile local-db down       # down without the profile leaves db running and warns about orphans

A dependency listed under depends_on must be active too, otherwise up fails with a message about the missing service; give the dependency the same profile or none. down, stop and ps only see services in active profiles, which is why a down after a profiled up can leave containers behind.

include and extends#

include composes whole applications; extends reuses one service definition. They solve different problems and are often used together.

# compose.yaml
include:
  - path: ../shared/observability/compose.yaml      # its relative paths resolve against its own directory
    project_directory: ../shared/observability
    env_file: ../shared/observability/.env           # interpolation for that file only
  - ./compose.db.yaml                                 # short form

services:
  web:
    extends:
      file: ./common.yaml
      service: app-base
    image: registry.example.com/web:1.0
    command: ["server"]
  worker:
    extends:
      file: ./common.yaml
      service: app-base
    command: ["worker"]
# common.yaml: never run directly
services:
  app-base:
    restart: unless-stopped
    user: "10001:10001"
    read_only: true
    tmpfs: [/tmp]
    logging:
      driver: local
      options: { max-size: 20m }
    env_file:
      - path: ./app.env
        required: false

Included files become part of the same project (one network, one project name), but a service name defined twice is an error rather than a merge; use -f stacking when a later file should override an earlier one. Files pulled in through include are interpolated with their own .env unless env_file says otherwise. extends copies every key except depends_on and volumes_from, and the extending service’s own keys merge over the copy with the same rules as override files. Relative paths in an extended service (build.context, bind mounts, env_file) are resolved against the file that defines them, then rewritten, so ./app.env in common.yaml means the file beside common.yaml.

YAML anchors are the third reuse tool and need no Compose support: x- top-level keys are ignored by Compose, so x-logging: &logging {driver: local} then logging: *logging in each service keeps repeated fragments in one place inside one file.

depends_on conditions in practice#

depends_on controls three things: creation and start order, stop order (dependents stop first), and, with a condition, what Compose waits for before starting the dependent. It does not restart a dependent when its dependency changes unless restart: true is set, and it is not enforced by the Docker daemon after up returns.

Conditionup proceeds whenUse for
service_started (default)The dependency’s container is runningOrder only; the application retries connections itself
service_healthyThe dependency reports healthyDatabases, brokers, anything with a real readiness probe
service_completed_successfullyThe dependency exited with code 0Migrations, seeders, certificate bootstrap
services:
  migrate:
    image: registry.example.com/web:1.0
    command: ["./migrate", "up"]
    restart: "no"                        # a one-shot must not restart, or it never "completes"
    depends_on:
      db: { condition: service_healthy }
  web:
    depends_on:
      migrate: { condition: service_completed_successfully }
      db: { condition: service_healthy, restart: true }
      cache: { condition: service_started, required: false }   # optional dependency (2.20+)

A healthcheck that never passes blocks up until --wait-timeout or forever without --wait; docker compose ps shows the dependency as (health: starting) while up hangs. A service_completed_successfully dependency with restart: unless-stopped never completes because the daemon restarts it. up --no-deps web skips the dependency logic entirely, and run respects it unless --no-deps is given.

Environment precedence#

Values reach a container from several places, and the same variable set in two of them is resolved in a fixed order. From highest to lowest priority:

  1. docker compose run -e VAR=value (or -e VAR to pass the shell’s value).
  2. environment: in the Compose file, after interpolation. A bare - VAR entry passes through the shell’s value.
  3. env_file: files, in the order listed; later files win over earlier ones.
  4. ENV instructions in the image’s Dockerfile.

Interpolation is separate and happens before any of that: ${VAR} in the file is replaced from the shell environment first, then .env (or the --env-file files), and the result is what step 2 sees. env_file contents are never used for interpolation, and .env is never passed into containers unless a service lists it under env_file.

docker compose config | grep -A5 'environment:'     # the resolved values that will reach the container
docker compose run --rm web env | sort               # what the process sees, including image ENV
docker compose --env-file .env.staging config --environment   # interpolation variables and where they came from
COMPOSE_ENV_FILES=.env,.env.local docker compose up -d        # several interpolation files (2.24+)

environment: entries with a value override env_file regardless of order in the YAML. Compose 2.24+ interprets .env files with the same rules as env_file (quotes stripped, # comments, no shell expansion of $(...)); quote values that contain # or spaces.

Hardening and resource keys#

The Compose equivalents of the docker run security and resource flags from Docker:

services:
  web:
    image: registry.example.com/web@sha256:0123abcd...   # digest pin
    pull_policy: always                                  # or missing (default), never, build, daily, weekly, every_12h
    user: "10001:10001"
    read_only: true
    tmpfs:
      - /tmp:size=64m,noexec,nosuid
    cap_drop: [ALL]
    cap_add: [NET_BIND_SERVICE]
    security_opt:
      - no-new-privileges:true
    init: true                                          # tini-style PID 1 for signal forwarding and zombie reaping
    stop_grace_period: 30s                              # SIGTERM to SIGKILL window; default 10s
    stop_signal: SIGINT
    pids_limit: 256
    ulimits:
      nofile: { soft: 65536, hard: 65536 }
    shm_size: 256m
    deploy:
      resources:
        limits: { cpus: "1.5", memory: 512M, pids: 256 }
        reservations: { memory: 256M }
    logging:
      driver: journald
      options: { tag: "{{.Name}}" }
    networks:
      frontend:
        aliases: [web.internal]
      backend:
    dns: [192.0.2.53]
    extra_hosts:
      - "host.docker.internal:host-gateway"

networks:
  frontend:
  backend:
    internal: true                                      # no route to the outside; only services on it can talk

deploy.resources.limits.memory becomes --memory; there is no memory-swap key, so swap is limited to the same value by Compose. internal: true networks are the simplest way to keep a database off the host’s default route. pull_policy: always with up replaces pull && up for images tracked by a moving tag.

Running commands in services#

docker compose exec web sh                     # inside the existing container
docker compose run --rm web ./migrate          # new container from the service definition
docker compose run --rm --service-ports web    # same, and publish the service's ports
docker compose cp web:/app/report.csv .
docker compose logs -f --since 10m web db
docker compose top                             # processes per container
docker compose events --json                   # stream container lifecycle events

run skips the service’s ports by default so it does not collide with the running instance. Other containers can still reach it on the project network, but nothing on the host can until --service-ports or -p is added. run also starts dependencies unless --no-deps is given.

Troubleshooting#

SymptomCauseCheck or fix
Change in compose.yaml or .env not appliedrestart was used, which keeps the old container configurationdocker compose up -d; docker compose config --hash '*' shows the hash up compares
New image pushed but old one still runningup does not pull an image that exists locallydocker compose pull web && docker compose up -d web
dependency failed to start: container ... is unhealthyA service_healthy dependency failed its healthcheckdocker inspect -f '{{json .State.Health}}' <container>
App fails on first start, works on restartdepends_on without a condition; dependency not readyAdd a healthcheck and condition: service_healthy
variable is not set. Defaulting to a blank stringInterpolated variable missing from the shell and .envdocker compose config; use ${VAR:?} to fail early
Two stacks interfere, or down removed the wrong containersSame project name (same directory name)Set name: or -p
port is already allocatedAnother project or host process holds the portdocker compose ls, ss -tlnp
Database init script ignoredExisting volume already initialisedRemove the volume only if the data is disposable
Orphan container warningsService removed or renamed in the filedocker compose up -d --remove-orphans
docker: 'compose' is not a docker commandCompose plugin not installedInstall the docker-compose-plugin package for your distribution
service "db" depends on undefined service or no such serviceDependency is behind an inactive profileActivate the profile, or give both services the same profile
Containers left running after downThey belong to a profile that was not active for downdocker compose --profile '*' down
services.web.extends: service "app-base" ... not foundWrong file: path, or the base service is behind a profilePaths in extends.file are relative to the current Compose file
include fails with services.X conflicts with imported resourceSame service name in two included filesRename, or switch to -f stacking where later files override
Variable from env_file not interpolated in compose.yamlenv_file is for the container, not for interpolationPut it in .env or pass --env-file
Container gets an empty value for a variable that is set in .envenvironment: - VAR reads the shell, not .env, unless .env is also an interpolation sourceUse VAR: ${VAR}, or list .env under env_file
up hangs on Waiting for a one-shot dependencyDependency has restart: always, so it never “completes”restart: "no" on migrations and seeders
read_only: true breaks the applicationWrites to a path with no volume or tmpfsdocker compose run --rm web sh then docker diff; add tmpfs entries
pull_policy: always ignored by upCompose older than 2.10Upgrade, or docker compose pull && docker compose up -d
docker compose ps --format json is not a JSON arrayCompose 2.21+ prints JSON linesjq -s . to make an array, or jq -r per line

Oneliners#

# Every unhealthy container in the project
docker compose ps --format json | jq -r 'select(.Health=="unhealthy") | .Name'

# Image each container is actually running
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)

# Pull and recreate one service without touching its dependencies
docker compose pull web && docker compose up -d --no-deps web

# Volumes belonging to this project
docker compose volumes

# Wait for one service without --wait
until [ "$(docker inspect -f '{{.State.Health.Status}}' "$(docker compose ps -q db)")" = healthy ]; do sleep 1; done

# Services and the profiles that enable them
docker compose --profile '*' config --format json | jq -r '.services | to_entries[] | "\(.key)\t\(.value.profiles // ["-"] | join(","))"'

# Services that would be created for the current profiles
docker compose config --services

# Validate the file without starting anything, including interpolation failures
docker compose config -q && echo valid

# Show a service's resolved environment as it will reach the container
docker compose config --format json | jq '.services.web.environment'

# Images the file references, for scanning or mirroring
docker compose config --images

# Pin every image to its current digest in a copy of the config
docker compose config --resolve-image-digests > compose.pinned.yaml

# Which host ports the project publishes
docker compose ps --format json | jq -r '.Name as $n | .Publishers[]? | select(.PublishedPort > 0) | "\($n) \(.URL):\(.PublishedPort)->\(.TargetPort)"' | sort -u

# Exit code of a one-shot service (tests, migrations) after up
docker compose up --exit-code-from tests --abort-on-container-exit tests; echo "rc=$?"

# Run migrations then start the stack in one line
docker compose run --rm migrate && docker compose up -d --wait

# Recreate only containers whose configuration changed, pulling newer images first
docker compose up -d --pull always --remove-orphans

# Restart the process of every service without recreating (no config changes applied)
docker compose restart

# Tail logs for the whole project since the last deploy time
docker compose logs --since "$(docker inspect -f '{{.Created}}' "$(docker compose ps -q web)")"

# Show the hash Compose uses to decide recreation, per service
docker compose config --hash '*'

# Disk used by this project's volumes
docker system df -v | awk -v p="$(docker compose config --format json | jq -r .name)_" '/^VOLUME NAME/ {f = 1; next} f && index($1, p) == 1 {print $1, $3}'

# Copy the project's .env variables into the current shell (for scripts that call docker directly)
set -a; . ./.env; set +a

# Stop everything gracefully with a longer drain window than the file allows
docker compose stop -t 60

# Second isolated copy of the stack on different ports via an override
docker compose -p review-42 -f compose.yaml -f compose.review.yaml up -d

# Attach the project network to a debugging container
docker run --rm -it --network "$(docker compose config --format json | jq -r .name)_default" nicolaka/netshoot

# Delete the project completely: containers, networks, named volumes and locally built images (data loss)
docker compose down -v --rmi local --remove-orphans

Scripts#

Deploy a new image tag with a health gate and automatic rollback: pull, recreate the service, wait for healthy, and on failure recreate with the previous tag. Uses IMAGE_TAG interpolation in the Compose file.

#!/usr/bin/env bash
# usage: deploy.sh SERVICE NEW_TAG    (compose.yaml uses image: registry.example.com/web:${IMAGE_TAG})
set -euo pipefail

svc=${1:?service} new=${2:?new tag}
cid=$(docker compose ps -q "$svc" || true)
old=$(if [[ -n $cid ]]; then docker inspect -f '{{.Config.Image}}' "$cid" | sed 's/.*://'; fi)
export IMAGE_TAG=$new

docker compose pull -q "$svc"
if docker compose up -d --no-deps --wait --wait-timeout 120 "$svc"; then
  printf 'deployed %s:%s\n' "$svc" "$new"
  exit 0
fi

printf 'health gate failed for %s:%s\n' "$svc" "$new" >&2
docker compose logs --tail 50 "$svc" >&2 || true
if [[ -n $old ]]; then
  printf 'rolling back to %s\n' "$old" >&2
  IMAGE_TAG=$old docker compose up -d --no-deps --wait --wait-timeout 120 "$svc"
fi
exit 1

Report every project on the daemon with container states, health, restart counts and image age, to spot stacks that are quietly crash-looping. Read-only.

#!/usr/bin/env bash
# usage: compose-report.sh
set -euo pipefail

now=$(date +%s)
printf '%-20s %-28s %-10s %-10s %8s %6s\n' PROJECT SERVICE STATE HEALTH RESTARTS IMGAGE
docker ps -a --filter label=com.docker.compose.project -q | while read -r id; do
  docker inspect "$id" | jq -r --argjson now "$now" '.[0] |
    [ .Config.Labels["com.docker.compose.project"],
      .Config.Labels["com.docker.compose.service"],
      .State.Status,
      (.State.Health.Status // "-"),
      (.RestartCount | tostring),
      ((($now - (.Created | sub("\\.[0-9]+"; "") | fromdate)) / 86400 | floor | tostring) + "d") ] | @tsv'
done | sort | awk -F'\t' '{printf "%-20s %-28s %-10s %-10s %8s %6s\n", $1, $2, $3, $4, $5, $6}'

Back up a Compose project’s named volumes with the stack stopped, then bring it back up, so the copy is consistent. Stops the project for the duration of the copy.

#!/usr/bin/env bash
# usage: compose-backup.sh DEST_DIR   (run from the project directory)
set -euo pipefail

dest=${1:?destination}; mkdir -p "$dest"
project=$(docker compose config --format json | jq -r .name)
stamp=$(date +%Y%m%dT%H%M%S)
mapfile -t vols < <(docker volume ls -q --filter "label=com.docker.compose.project=$project")
(( ${#vols[@]} )) || { echo "no named volumes for project $project" >&2; exit 0; }

docker compose stop
trap 'docker compose start >/dev/null' EXIT          # restart even if a copy fails
for v in "${vols[@]}"; do
  out=$dest/$v-$stamp.tar.zst
  docker run --rm -v "$v:/data:ro" -v "$dest:/backup" alpine:3.24 \
    sh -c 'apk add --no-cache zstd >/dev/null && tar -C /data -cf - . | zstd -T0 -q > "/backup/$0"' "$(basename "$out")"
  printf '%s %s\n' "$(du -h "$out" | cut -f1)" "$out"
done

Further reading#