Compose for real projects

Docker · Advanced · 12 min

Most teams' local environment and integration tests run on Compose; these features keep it maintainable.

Once a project grows, a single flat compose.yaml starts to strain. Compose has tools for that.

Override files. docker compose up automatically merges compose.override.yaml on top of compose.yaml. Put production-like settings in the base and dev conveniences (bind mounts, debug ports) in the override. For other environments, pass files explicitly: docker compose -f compose.yaml -f compose.ci.yaml up. Later files override or extend earlier ones.

Profiles. Mark optional services with profiles: [tools]; they start only with --profile tools. Good for admin UIs, seeders and load generators.

Startup ordering. depends_on conditions: service_started, service_healthy (requires a healthcheck) and service_completed_successfully, which is ideal for one-shot migration jobs that must finish before the app starts.

Interpolation. ${VAR}, ${VAR:-default} and ${VAR:?error message} (fail if unset) come from the shell or the project's .env. Check the final, merged result with docker compose config; it's the best debugging tool for Compose files.

Watch mode. A develop.watch section plus docker compose watch syncs changed source files into running containers (action: sync), restarts on config changes (sync+restart) or rebuilds when dependencies change (rebuild). It replaces fragile bind mounts on Docker Desktop.

Reuse. include: pulls in other Compose files, such as a shared infrastructure stack, and YAML anchors or extends remove repetition.

CI. docker compose up -d --wait blocks until services are healthy, and docker compose run --rm tests runs one-off containers on the project network.

Example

services:
  migrate:
    build: .
    command: ["alembic", "upgrade", "head"]
    depends_on: { db: { condition: service_healthy } }
  api:
    build: .
    ports: ["127.0.0.1:${API_PORT:-8000}:8000"]
    environment:
      SECRET_KEY: ${SECRET_KEY:?set SECRET_KEY in .env}
    depends_on:
      migrate: { condition: service_completed_successfully }
    develop:
      watch:
        - { action: sync, path: ./src, target: /app/src }
        - { action: rebuild, path: requirements.txt }
  db:
    image: postgres:17
    environment: { POSTGRES_PASSWORD: dev }
    healthcheck: { test: ["CMD-SHELL", "pg_isready -U postgres"], interval: 5s }
  adminer:
    image: adminer
    profiles: [tools]
# docker compose config ; docker compose watch ; docker compose --profile tools up -d

Remember

  • compose.override.yaml is merged automatically; -f stacks files explicitly
  • profiles make services optional; service_completed_successfully gates on jobs
  • docker compose config shows the fully merged, interpolated file
  • compose watch syncs or rebuilds on file changes

Watch out for

  • Debugging a confusing setup without running docker compose config to see what Compose actually resolved.

Check yourself

  1. Which depends_on condition fits a database migration job that must finish before the API starts?
  2. You run docker compose up in a folder with compose.yaml and compose.override.yaml. What is used?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.docker.com/reference/compose-file/services/

Builds on

Unlocks

Nothing builds on this yet.