More Power in Your Docker Compose
DEV Community

More Power in Your Docker Compose

Healthcheck

The Compose Specification lets you define a healthcheck to verify that a service is not just running, but actually operational. This matters when you want a service to start only after another one is fully up and able to do its job.

services:
  db:
    image: postgres:18-alpine
    environment:
      POSTGRES_PASSWORD: postgres
      POSTGRES_USER: postgres
      POSTGRES_DB: postgres
    volumes:
      - db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

volumes:
  db_data:

A few things worth pointing out about this setup:

  • Pin your image tags. postgres:alpine floats and can change under you. Prefer an explicit major version like postgres:18-alpine so builds stay reproducible.
  • start_period is your friend. During this grace period, failing checks do not count against the retry limit. A successful check ends the grace period early. Databases can take a while to initialize, so give them room (30s or more).
  • start_interval (Docker Engine 25.0+) lets you probe more frequently during the start period without hammering the container for its whole life. It is a nice touch for services with long, variable startup times.
  • The double $$ escapes the variable so it is expanded inside the container, by the shell, and not by Compose at parse time.

depends_on with conditions

Defining the healthcheck is only half the story. The other half is telling dependent services to wait for it. This is where depends_on with a condition comes in. Compose supports three conditions:

  • service_started (the default): waits only until the container is running.
  • service_healthy: waits until the dependency's healthcheck passes.
  • service_completed_successfully: waits until the dependency runs to completion and exits with code 0.
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8000:8000"
    command: "gunicorn -b 0.0.0.0:8000 -k gevent sample.main:create_app() --access-logfile=- --preload -w 4"
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ["CMD-SHELL", "python -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/healthcheck').status==200 else 1)\""]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 20s

  db:
    image: postgres:18-alpine
    environment:
      POSTGRES_PASSWORD: postgres
      POSTGRES_USER: postgres
      POSTGRES_DB: postgres
    volumes:
      - db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

volumes:
  db_data:

Two pitfalls that are very easy to hit here:

  • The condition is service_healthy, not service_health. That single missing letter is a classic silent failure.
  • A common mistake is using curl for the app healthcheck. Many slim base images do not ship curl, so the check fails not because the app is down but because the binary is missing. Above I use a small Python one-liner instead, which is always available in a Python image. If your image does have curl, ["CMD", "curl", "-f", "http://localhost:8000/healthcheck"] is fine too. The point is: use a tool that actually exists in the container.

The migration pattern

service_completed_successfully is the piece that lets you retire most of those startup shell scripts. A common need is "run the database migration, and only then start the app". You can model that directly:

services:
  migrate:
    build:
      context: .
      dockerfile: Dockerfile
    command: "alembic upgrade head"
    # or: python manage.py migrate
    depends_on:
      db:
        condition: service_healthy

  app:
    build:
      context: .
      dockerfile: Dockerfile
    command: "gunicorn -b 0.0.0.0:8000 sample.main:create_app() -w 4"
    depends_on:
      migrate:
        condition: service_completed_successfully
      db:
        condition: service_healthy

The migrate service waits for the database to be healthy, runs once, and exits. The app service waits for that exit to be successful before it even starts. No bespoke wait-for-it.sh needed.

Profiles

Sometimes you open a compose.yaml with a dozen service definitions, but the feature you are working on only needs a handful of them. That is what profiles are for.

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8000:8000"
    command: "gunicorn -b 0.0.0.0:8000 sample.main:create_app() -w 4"
    profiles:
      - web
      - full
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/healthcheck')\""]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 20s

  db:
    image: postgres:18-alpine
    environment:
      POSTGRES_PASSWORD: postgres
      POSTGRES_USER: postgres
      POSTGRES_DB: postgres
    profiles:
      - dependencies
      - web
      - full
    volumes:
      - db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  db_data:

Note the indentation: profiles is a top-level key of the service, at the same level as depends_on and healthcheck. A frequent mistake is nesting it inside depends_on, which is invalid.

To start only the database, you can either use the flag:

docker compose --profile dependencies up

or the environment variable, which is handy in scripts:

COMPOSE_PROFILES=dependencies docker compose up

You can pass multiple profiles too: docker compose --profile web --profile full up.

Reusing definitions

The same codebase is often run in different shapes: a web app, an async worker, a one-off command. It is convenient to define a common base and specialize it per use case. Compose gives you two ways to do this.

YAML anchors

The YAML spec supports anchors and aliases for reusing definitions. Combined with Compose's convention that any top-level key starting with x- is treated as an extension field (and ignored by the parser), you can build a shared base and merge it into each service:

x-app: &app
  build:
    context: .
    dockerfile: Dockerfile
  depends_on:
    db:
      condition: service_healthy
    queue:
      condition: service_healthy
    cache:
      condition: service_healthy

services:
  app:
    <<: *app
    ports:
      - "8000:8000"
    command: "gunicorn -b 0.0.0.0:8000 sample.main:create_app() -w 4"
    profiles:
      - web
      - full
    healthcheck:
      test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/healthcheck')\""]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 20s

  worker:
    <<: *app
    command: "celery -A sample.main:celery_app worker"
    environment:
      CELERY_RESULT_BACKEND: redis://cache:6379/0
      CELERY_BROKER_URL: amqp://guest:guest@queue:5672//
    profiles:
      - background
      - full

  db:
    image: postgres:18-alpine
    environment:
      POSTGRES_PASSWORD: postgres
      POSTGRES_USER: postgres
      POSTGRES_DB: postgres
    profiles:
      - dependencies
      - full
    volumes:
      - db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5

  queue:
    image: rabbitmq:4-management-alpine
    profiles:
      - dependencies
      - full
    healthcheck:
      test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
      interval: 30s
      timeout: 30s
      retries: 3

  cache:
    image: redis:8-alpine
    profiles:
      - dependencies
      - full
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 30s
      timeout: 30s
      retries: 3

volumes:
  db_data:

The extends keyword

Anchors are pure YAML and only work within a single file. If you want to share definitions across files, Compose has its own extends keyword:

# common.yaml
services:
  base:
    build:
      context: .
    env_file:
      - .env
# compose.yaml
services:
  app:
    extends:
      file: common.yaml
      service: base
    command: "gunicorn -b 0.0.0.0:8000 sample.main:create_app() -w 4"

Use anchors for local reuse, extends for reuse across files.

What else you can add

The features so far cover startup order, readiness, and reuse. Here is what I would add on top to make a Compose setup genuinely production-minded.

Compose Watch: hot reload

The develop.watch block keeps your running containers in sync with local changes, syncing files or rebuilding the image automatically as you edit.

services:
  app:
    build:
      context: .
    command: "uvicorn sample.main:app --host 0.0.0.0 --port 8000 --reload"
    ports:
      - "8000:8000"
    develop:
      watch:
        # Sync source changes straight into the running container
        - action: sync
          path: ./sample
          target: /app/sample
          initial_sync: true
          ignore:
            - __pycache__/
        # Rebuild the image when dependencies change
        - action: rebuild
          path: ./pyproject.toml

Start it with docker compose up --watch (or docker compose watch to keep sync events out of your app logs). The sync action copies changed files into the container without a rebuild, rebuild recreates the image, and sync+restart syncs then restarts the process. The initial_sync: true flag copies everything on startup so the container never runs stale code, and there is really no reason not to use it.

A couple of requirements: the watched container's user must be able to write to the target path, and sync needs common executables present in the image.

Restart policies

Tell Compose what to do when a container exits. For long-running services, unless-stopped is a sensible default: it restarts on failure and on daemon restart, but respects a manual stop.

services:
  app:
    restart: unless-stopped

Options are no (default), always, on-failure, and unless-stopped. Do not put this on your one-off migrate service, or it will loop.

Resource limits

Without limits, a single misbehaving container can starve everything else on the host. Compose honors deploy.resources for docker compose up, no Swarm required:

services:
  app:
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
        reservations:
          cpus: "0.25"
          memory: 256M

limits is the ceiling, reservations is the guaranteed minimum.

Security hardening

Containers run as root by default, which is more privilege than most apps need. A hardened service looks like this:

services:
  app:
    image: myapp:1.4.2  # pinned, never :latest in production
    user: "1000:1000"   # non-root
    read_only: true     # immutable root filesystem
    tmpfs:
      - /tmp            # writable scratch space that is not persisted
    cap_drop:
      - ALL             # drop every Linux capability
    cap_add:
      - NET_BIND_SERVICE # add back only what you actually need
    security_opt:
      - no-new-privileges:true  # block privilege escalation via setuid
    ports:
      - "127.0.0.1:8000:8000"   # bind to localhost, not 0.0.0.0

The principle is least privilege: drop all capabilities and add back only the ones your app needs, keep the filesystem read-only with a small tmpfs for scratch files, forbid privilege escalation, and bind ports to 127.0.0.1 when the port does not need to be exposed to the world. And never use privileged: true as a shortcut. It disables essentially every one of these protections at once.

Secrets instead of plaintext env vars

Passing a database password through environment leaves it visible in docker inspect, in your shell history, and often committed to git. Compose secrets mount the value as a file instead:

services:
  db:
    image: postgres:18-alpine
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt

Postgres (and many other official images) support the _FILE convention specifically for this. Add secrets/ to your .gitignore.

Env files and interpolation with defaults

Keep configuration out of the file and provide sane fallbacks with the ${VAR:-default} syntax:

services:
  app:
    image: myapp:${APP_TAG:-latest}
    env_file:
      - .env
    environment:
      LOG_LEVEL: ${LOG_LEVEL:-info}

Compose reads a .env file next to compose.yaml automatically for interpolation. Do not commit it.

Network isolation

By default all services share one network and can talk to each other freely. If your database never needs to be reached from outside, put it on an internal network so it has no route to the host or the internet:

services:
  app:
    networks:
      - frontend
      - backend

  db:
    networks:
      - backend  # only reachable by services on backend

networks:
  frontend:
  backend:
    internal: true  # no external connectivity

This limits lateral movement if one container is ever compromised.

The include directive (with a security note)

Large setups can be split into fragments and pulled together with include:

include:
  - path: ./services/database.yaml
  - path: ./services/observability.yaml

It can even pull fragments from Git or OCI registries. One caveat worth knowing: CVE-2025-62725 was a path traversal vulnerability in include when using OCI artifacts. If you use that feature, make sure you are on Compose v2.40.2 or newer.

pull_policy

Control when Compose pulls an

Comments

No comments yet. Start the discussion.