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:alpinefloats and can change under you. Prefer an explicit major version likepostgres:18-alpineso builds stay reproducible. start_periodis 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, notservice_health. That single missing letter is a classic silent failure. - A common mistake is using
curlfor the app healthcheck. Many slim base images do not shipcurl, 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 havecurl,["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.