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.
On this page
Cheatsheet#
| Task | Command |
|---|---|
| Start detached, wait until running or healthy | docker compose up -d --wait |
| Rebuild images and start | docker compose up -d --build |
| Pull newer images and recreate what changed | docker compose pull && docker compose up -d |
| Stop and remove containers and networks | docker compose down |
| Also delete the project’s volumes (data loss) | docker compose down -v |
| Status and health, including stopped | 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 |
| Recreate even if nothing changed | docker compose up -d --force-recreate |
| Second isolated copy of the stack | docker compose -p feature-x up -d |
| Sync or rebuild on file change | docker 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 daemonRecreating 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 filedown -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 -vrestart 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:| Key | Behaviour |
|---|---|
depends_on (list form) | Start and stop order only; says nothing about readiness |
depends_on.<svc>.condition | service_started, service_healthy or service_completed_successfully |
depends_on.<svc>.restart | true restarts this service after Compose updates (recreates or restarts) the dependency (2.17+) |
depends_on.<svc>.required | false downgrades a missing dependency to a warning (2.20+) |
healthcheck.start_period | Failures in this window do not count towards retries |
healthcheck.start_interval | Check interval during start_period (Compose 2.20.2+) |
restart | no, always, on-failure[:max], unless-stopped; enforced by the Docker daemon |
deploy.resources.limits | Applied by docker compose up on a single host despite the Swarm-era name |
ports vs expose | ports publishes on the host; expose is metadata only |
profiles | The service starts only when one of its profiles is active |
develop.watch | Sync, rebuild or restart rules for docker compose watch |
extra_hosts | Adds /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”:
| Mechanism | Used for | Source |
|---|---|---|
Interpolation ${VAR} | Substituting values into the Compose file itself | Shell 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 container | The 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 interpolationOverrides, 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 value | Merge 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.test | Replaced |
ports | Merged by {ip, target, published, protocol}; new entries appended |
volumes, secrets, configs | Merged 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: warninclude: 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 orphansA 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: falseIncluded 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.
| Condition | up proceeds when | Use for |
|---|---|---|
service_started (default) | The dependency’s container is running | Order only; the application retries connections itself |
service_healthy | The dependency reports healthy | Databases, brokers, anything with a real readiness probe |
service_completed_successfully | The dependency exited with code 0 | Migrations, 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:
docker compose run -e VAR=value(or-e VARto pass the shell’s value).environment:in the Compose file, after interpolation. A bare- VARentry passes through the shell’s value.env_file:files, in the order listed; later files win over earlier ones.ENVinstructions 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 talkdeploy.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 eventsrun 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#
| Symptom | Cause | Check or fix |
|---|---|---|
Change in compose.yaml or .env not applied | restart was used, which keeps the old container configuration | docker compose up -d; docker compose config --hash '*' shows the hash up compares |
| New image pushed but old one still running | up does not pull an image that exists locally | docker compose pull web && docker compose up -d web |
dependency failed to start: container ... is unhealthy | A service_healthy dependency failed its healthcheck | docker inspect -f '{{json .State.Health}}' <container> |
| App fails on first start, works on restart | depends_on without a condition; dependency not ready | Add a healthcheck and condition: service_healthy |
variable is not set. Defaulting to a blank string | Interpolated variable missing from the shell and .env | docker compose config; use ${VAR:?} to fail early |
Two stacks interfere, or down removed the wrong containers | Same project name (same directory name) | Set name: or -p |
port is already allocated | Another project or host process holds the port | docker compose ls, ss -tlnp |
| Database init script ignored | Existing volume already initialised | Remove the volume only if the data is disposable |
| Orphan container warnings | Service removed or renamed in the file | docker compose up -d --remove-orphans |
docker: 'compose' is not a docker command | Compose plugin not installed | Install the docker-compose-plugin package for your distribution |
service "db" depends on undefined service or no such service | Dependency is behind an inactive profile | Activate the profile, or give both services the same profile |
Containers left running after down | They belong to a profile that was not active for down | docker compose --profile '*' down |
services.web.extends: service "app-base" ... not found | Wrong file: path, or the base service is behind a profile | Paths in extends.file are relative to the current Compose file |
include fails with services.X conflicts with imported resource | Same service name in two included files | Rename, or switch to -f stacking where later files override |
Variable from env_file not interpolated in compose.yaml | env_file is for the container, not for interpolation | Put it in .env or pass --env-file |
Container gets an empty value for a variable that is set in .env | environment: - VAR reads the shell, not .env, unless .env is also an interpolation source | Use VAR: ${VAR}, or list .env under env_file |
up hangs on Waiting for a one-shot dependency | Dependency has restart: always, so it never “completes” | restart: "no" on migrations and seeders |
read_only: true breaks the application | Writes to a path with no volume or tmpfs | docker compose run --rm web sh then docker diff; add tmpfs entries |
pull_policy: always ignored by up | Compose older than 2.10 | Upgrade, or docker compose pull && docker compose up -d |
docker compose ps --format json is not a JSON array | Compose 2.21+ prints JSON lines | jq -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-orphansScripts#
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 1Report 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"
doneFurther reading#
- Compose Specification: every top-level and service key, with the
services,networks,volumes,configsandsecretssections. - Merge and override rules and include and extends: how multiple files combine.
- Profiles: activation rules and the implicit activation by naming a service.
- Environment variables in Compose: precedence table, interpolation and
.envfile syntax. - docker compose CLI reference: every subcommand and flag, including
up --wait,config --hashandwatch.