Compose merges multiple files in order. Each subsequent file deep-merges onto the previous, with maps merged key-by-key and sequences replaced outright unless you use the !reset or !override YAML tags.
# merge three files in order docker compose -f docker-compose.yml -f docker-compose.override.yml -f docker-compose.prod.yml up
Without -f, Compose automatically loads docker-compose.yml then docker-compose.override.yml if it exists. Commit the base file; gitignore the override for local dev customisation.
| YAML type | Merge behavior | Example key |
|---|---|---|
| Map | Deep merge — child keys in overlay win, others kept | environment, labels |
| Sequence | Replaced entirely by overlay value | command, ports |
| Scalar | Overlay wins unconditionally | image, restart |
ports and volumes are replaced, not appended. If your base file defines three port mappings and the override defines one, the result has one. Use extends or merge carefully.
extends lets a service inherit from another file's service definition without merging entire files:
# docker-compose.yml services: web: extends: file: common-services.yml service: app-base ports: - "8080:80" environment: NODE_ENV: production
extends does not support circular references. depends_on is not inherited; you must redeclare it in the child service.
Profiles allow you to group services that are only started on demand. Services with no profile assigned are always started. Services with a profile are started only when that profile is active.
services: app: image: myapp:latest db: image: postgres:16 debug-tools: image: nicolaka/netshoot profiles: [debug] mailpit: image: axllent/mailpit profiles: [dev] prometheus: image: prom/prometheus profiles: [monitoring, dev]
# activate single profile docker compose --profile dev up # activate multiple profiles docker compose --profile dev --profile monitoring up # via environment variable COMPOSE_PROFILES=dev,monitoring docker compose up
debug-tools depends on app, and app has no profile, that's fine. If debug-tools depends on another profiled service, that profile must also be active.
depends_on in v2 accepts a condition alongside the service name, controlling when a dependent service is considered ready. Without conditions, Compose just waits for the container to start — not for the process inside to be ready.
services: app: image: myapp:latest depends_on: db: condition: service_healthy restart: true migrations: condition: service_completed_successfully cache: condition: service_started required: false
| Condition | Meaning |
|---|---|
service_started | Container has started (default, same as old-style depends_on) |
service_healthy | Container healthcheck is passing; requires a healthcheck block on the dependency |
service_completed_successfully | Container exited with code 0; useful for one-shot init containers |
restart: true causes the dependent service to restart automatically if its dependency restarts. required: false makes the dependency optional — Compose won't fail if the dependency service is not present (e.g., excluded by profile).
services: migrations: image: myapp:latest command: ["python", "manage.py", "migrate"] depends_on: db: condition: service_healthy restart: on-failure app: image: myapp:latest depends_on: migrations: condition: service_completed_successfully
include imports another Compose file as a self-contained unit. Unlike file merging with -f, each included file is processed in its own project namespace and its services do not collide with the parent's services of the same name.
# docker-compose.yml include: - ./infra/observability.yml - path: ./infra/database.yml project_directory: ./infra env_file: .env.db services: app: image: myapp:latest networks: - default - infra_db_net # network exported by database.yml
.env file referenced explicitly in both.
This is the right tool for monorepos with independent service clusters: include the auth stack, include the metrics stack, compose them at the top level.
Both secrets and configs mount data into containers at runtime without baking it into images or environment variables. The difference: secrets are mounted to an in-memory tmpfs under /run/secrets/; configs are standard files (not guaranteed in-memory) and commonly used for non-sensitive config files.
secrets: db_password: file: ./secrets/db_password.txt api_key: environment: MY_API_KEY # read from host env var services: app: image: myapp:latest secrets: - db_password - source: api_key target: /run/secrets/api_key uid: "1000" gid: "1000" mode: 0400
configs: nginx_conf: file: ./nginx/nginx.conf feature_flags: content: | FEATURE_X=true FEATURE_Y=false services: proxy: image: nginx:alpine configs: - source: nginx_conf target: /etc/nginx/nginx.conf
| Feature | secrets | configs |
|---|---|---|
| Default mount path | /run/secrets/<name> | /<name> |
| In-memory (tmpfs) | Yes | No |
| Inline content | No | Yes (content:) |
| From env var | Yes (environment:) | No |
| Custom permissions | uid, gid, mode | uid, gid, mode |
Any top-level key prefixed with x- is ignored by Compose's schema validation. Combined with YAML anchors and aliases, this is the most effective way to deduplicate configuration.
# Define reusable blocks at top level x-logging: &logging driver: json-file options: max-size: "10m" max-file: "3" x-common-env: &common-env TZ: UTC LOG_LEVEL: info x-healthcheck: &healthcheck test: ["CMD", "curl", "-f", "http://localhost/health"] interval: 30s timeout: 5s retries: 3 start_period: 10s services: api: image: myapp/api:latest logging: *logging healthcheck: *healthcheck environment: <<: *common-env PORT: "8080" worker: image: myapp/worker:latest logging: *logging environment: <<: *common-env WORKER_CONCURRENCY: "4"
The <<: syntax is a YAML merge key — it inlines the referenced map's keys into the current map. Keys defined after <<: override any conflicting keys from the anchor.
services: app: build: context: . dockerfile: Dockerfile target: production args: NODE_VERSION: "20" BUILD_DATE: ${BUILD_DATE} secrets: - npm_token # mounts as /run/secrets/npm_token at build time ssh: - default # forward host SSH agent cache_from: - type=registry,ref=myregistry/myapp:buildcache cache_to: - type=registry,ref=myregistry/myapp:buildcache,mode=max platforms: - linux/amd64 - linux/arm64 labels: org.opencontainers.image.revision: ${GIT_SHA} secrets: npm_token: environment: NPM_TOKEN
The target field selects a named stage from a multi-stage Dockerfile. This lets you run a development stage locally (with dev tools) and a production stage in CI, sharing cache layers between them.
build: context: . additional_contexts: shared-lib: ../shared base-image: docker-image://myregistry/base:latest
In your Dockerfile, reference these as FROM shared-lib AS lib or COPY --from=shared-lib . /shared.
networks: frontend: driver: bridge driver_opts: com.docker.network.bridge.name: br-frontend com.docker.network.driver.mtu: "1450" ipam: driver: default config: - subnet: 172.28.0.0/16 ip_range: 172.28.5.0/24 gateway: 172.28.5.254 backend: internal: true # no external connectivity attachable: true # standalone containers can join
services: app: networks: frontend: ipv4_address: 172.28.5.10 aliases: - app - myapp backend: priority: 100 # higher = preferred interface
networks: shared: external: true name: myproject_shared # exact Docker network name
external: true to share a network between two independent Compose projects. Services in both projects discover each other by service name if they use the same network and alias.
Compose supports extended shell-style variable syntax beyond simple ${VAR} substitution.
| Syntax | Behavior |
|---|---|
${VAR} | Substitute value; empty string if unset |
${VAR:-default} | Use default if VAR is unset or empty |
${VAR-default} | Use default only if VAR is unset (not if empty) |
${VAR:?error} | Error with message if VAR is unset or empty |
${VAR?error} | Error with message only if VAR is unset |
${VAR:+value} | Use value if VAR is set and non-empty; else empty |
$$VAR | Literal $VAR — escape for passing to containers |
services: app: image: myapp:${TAG:-latest} environment: DB_HOST: ${DB_HOST:?DB_HOST must be set} DB_PORT: ${DB_PORT:-5432} DOLLAR_SIGN_EXAMPLE: $$HOME # passes literal $HOME to container
The env_file key accepts a list and supports per-file settings:
services: app: env_file: - path: .env required: true - path: .env.local required: false # silently skipped if absent
Later files in the list override earlier ones. environment: keys in the service definition override everything from env_file.
develop.watch replaces bind mounts for live development. It syncs specific paths into running containers and can trigger rebuilds on certain changes — without mounting the entire project directory.
services: app: build: . develop: watch: - action: sync path: ./src target: /app/src ignore: - node_modules - action: sync+restart path: ./config target: /app/config - action: rebuild path: package.json
| Action | Behavior |
|---|---|
sync | Copy changed files into the running container; no restart |
sync+restart | Sync files, then restart the container's entrypoint |
rebuild | Run docker compose build and recreate the container |
Activate with:
docker compose up --watch # or in background docker compose watch
sync uses tar-based file transfer, not bind mounts — it works correctly on Docker Desktop (macOS/Windows) where bind mount performance is poor, and avoids cross-OS file permission issues.
Healthchecks defined in Compose override any HEALTHCHECK instruction in the Dockerfile.
services: db: image: postgres:16 healthcheck: test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] interval: 10s timeout: 5s retries: 5 start_period: 30s start_interval: 2s # poll faster during start_period redis: image: redis:7 healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 3s retries: 3 inherited: image: some-image-with-healthcheck healthcheck: disable: true # suppress Dockerfile HEALTHCHECK
start_interval (added in Docker Engine 25) lets you poll more aggressively during the initial start_period without affecting the steady-state interval.
| Test format | Behavior |
|---|---|
["CMD", "cmd", "arg"] | Exec directly; no shell — faster, no shell injection risk |
["CMD-SHELL", "shell command"] | Runs /bin/sh -c "shell command"; supports pipes, &&, env expansion |
"shell command" | String shorthand; equivalent to CMD-SHELL |
# restart only one service without recreating dependants docker compose restart worker # bring up a single service and its dependencies only docker compose up --no-deps api # build without using cache docker compose build --no-cache app # pull images and rebuild before starting docker compose up --pull always --build
# run multiple instances of a service docker compose up --scale worker=4 # equivalent in compose file (preferred) services: worker: deploy: replicas: 4 resources: limits: cpus: "0.5" memory: 256M reservations: cpus: "0.25" memory: 128M
# show the merged config that would be applied docker compose config # show only service names docker compose config --services # validate without starting docker compose config --quiet # dry-run any command (shows what would happen) docker compose --dry-run up --build
# run a command in a new container (not the running one) docker compose run --rm app python manage.py shell # exec into running container docker compose exec -it app bash # run with specific env overrides docker compose run --rm -e DEBUG=1 app pytest tests/
# run multiple independent instances of the same compose file docker compose -p project-a up -d docker compose -p project-b up -d # or via env variable COMPOSE_PROJECT_NAME=staging docker compose up -d
# block until all services with healthchecks pass docker compose up -d --wait # with a timeout docker compose up -d --wait --wait-timeout 60
This is the correct replacement for external wait-for-it.sh scripts in CI pipelines when your services define healthchecks.