# 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.

Canonical: https://www.wiki.jodisand.me/docker-compose/
Reviewed: 2026-09-24
Related: [Docker](https://www.wiki.jodisand.me/docker/index.md), [Kubernetes](https://www.wiki.jodisand.me/kubernetes/index.md), [Traefik](https://www.wiki.jodisand.me/traefik/index.md), [PostgreSQL](https://www.wiki.jodisand.me/postgresql/index.md)


## 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` |

> [!NOTE] `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](https://docs.docker.com/reference/compose-file/).

For the underlying container, image, volume and network behaviour, see [Docker](https://www.wiki.jodisand.me/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.

```sh
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

```sh
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
```

> [!WARNING] `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:

```sh
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

```yaml
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 `$`.

```sh
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](https://docs.docker.com/reference/compose-file/merge/).

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

```sh
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
```

```yaml
# 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.

```yaml
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]
```

```sh
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.

```yaml
# 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"]
```

```yaml
# 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.

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

```yaml
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`.

```sh
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](https://www.wiki.jodisand.me/docker/#security-options):

```yaml
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

```sh
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

| 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

```sh
# 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.

```sh
#!/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.

```sh
#!/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.

```sh
#!/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

- [Compose Specification](https://docs.docker.com/reference/compose-file/): every top-level and service key, with the `services`, `networks`, `volumes`, `configs` and `secrets` sections.
- [Merge and override rules](https://docs.docker.com/reference/compose-file/merge/) and [include](https://docs.docker.com/reference/compose-file/include/) and [extends](https://docs.docker.com/reference/compose-file/extension/): how multiple files combine.
- [Profiles](https://docs.docker.com/compose/how-tos/profiles/): activation rules and the implicit activation by naming a service.
- [Environment variables in Compose](https://docs.docker.com/compose/how-tos/environment-variables/): precedence table, interpolation and `.env` file syntax.
- [docker compose CLI reference](https://docs.docker.com/reference/cli/docker/compose/): every subcommand and flag, including `up --wait`, `config --hash` and `watch`.


