Docker Compose
Describing a multi-container application in a compose.yaml file: services, networks, volumes, environment and secrets, dependencies and health checks, and the docker compose commands.
What Compose is
Running Kestrel Energy's portal by hand takes several commands: a network, a volume, a database container, the meter-api, the portal, each with the right options in the right order. Typed by hand, that is slow and nobody does it the same way twice. Docker Compose is the Docker tool that reads a description of a multi-container application from a YAML file and creates, starts, stops and removes all of it with one command. The file is declarative: it states what should exist, and Compose works out what to create or change.
Compose runs on one host, talking to that host's Docker Engine. It is the standard tool for development set-ups, test environments in CI pipelines, and small production applications on a single server.
Which Compose?
The name covers three generations, and old material mixes them up:
-
Compose v1 (2014) was a separate Python program,
docker-composewith a hyphen. It stopped receiving updates in 2023. -
Compose v2 (announced 2020) is a rewrite in Go
that plugs into the Docker command line:
docker composewith a space. - Compose v5 (2025) is, in Docker's words, functionally identical to v2; the major version jumped to 5 to end the confusion with the old file-format versions 2 and 3, and it adds an official Go library. As of October 2026 the current release is v5.6.
The recorded sessions on this page come from a test machine with Docker Engine 28.2 and Compose v2.36.2; the commands and the messages are the same in Compose v5.
The compose file
A compose file is the
YAML file that
describes an application for Compose: its services (containers),
networks, volumes, secrets and configuration. The preferred name is
compose.yaml; Compose also reads
compose.yml and, for backward compatibility,
docker-compose.yaml and
docker-compose.yml. It looks for the file in the
current folder, so you run the commands from the folder that holds
it.
Here is the whole Kestrel application: the portal, the meter-api, PostgreSQL and the nightly billing export.
name: kestrel
services:
portal:
image: registry.example.com/kestrel/portal:1.4.2
ports:
- "127.0.0.1:8080:8080"
environment:
METER_API_URL: http://meter-api:8000
depends_on:
meter-api:
condition: service_healthy
restart: unless-stopped
meter-api:
image: registry.example.com/kestrel/meter-api:2.3.0
environment:
DATABASE_HOST: db
DATABASE_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz')"]
interval: 10s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:17
environment:
POSTGRES_DB: kestrel
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d kestrel"]
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
billing-export:
image: registry.example.com/kestrel/billing-export:1.0.7
profiles: ["jobs"]
depends_on:
db:
condition: service_healthy
volumes:
pgdata:
secrets:
db_password:
file: ./secrets/db_password.txt
Reading it from top to bottom:
-
namesets the project name (see below). There is noversion:line: that element is obsolete, and Compose only prints a warning when it finds one. -
Under
services, each key is one service with its image and the same settings you would otherwise pass todocker run:ports,environment,volumes,restart. -
Only the portal publishes a port, and only on
127.0.0.1. The portal reaches the meter-api athttp://meter-api:8000: service name and container port. -
volumes: pgdata:at the bottom declares a named volume (see Volumes and Bind Mounts);secretsdeclares the password file. -
The
healthcheckblocks and thedepends_onconditions control the start order, explained further down.
Projects and services
A Compose service is one
entry under services: a description of a container
(image, settings, mounts) that Compose runs, normally as one
container and optionally as several identical replicas. A
Compose project is everything
Compose creates from one compose file, grouped under one project
name. The name comes from name: in the file, from
-p on the command line, or else from the folder name.
The project name is the prefix of everything Compose creates, which
is how two copies of the same application (say
kestrel-test and kestrel-acc) can run on
one host without colliding:
| Object | Name pattern | Example (project kestrel) |
|---|---|---|
| Default network | <project>_default |
kestrel_default |
| Named volume | <project>_<volume> |
kestrel_pgdata |
| Container |
<project>-<service>-<number>
|
kestrel-meter-api-1 |
Every project gets its own
user-defined network, and every service is reachable on it by its service name. That is
why the compose file needs no networking settings at all for the
three services to find each other. A service such as the database
that has no ports entry is reachable only from the
other containers in the project.
Start order and health checks
depends_on is the service setting that lists which other services must be started first, and (in its long form) what "ready" means for each of them. Compose starts services in dependency order and stops them in reverse order. The conditions are:
| Condition | Compose waits until the dependency … |
|---|---|
service_started |
has been started (the default, and what the short list
syntax depends_on: [db] means)
|
service_healthy |
reports "healthy" from its healthcheck |
service_completed_successfully |
has run to completion with exit code 0 (for set-up jobs) |
"Started" is not "ready". PostgreSQL needs a few seconds after its
container starts before it accepts connections, and a meter-api that
connects in that window crashes or logs errors. A
healthcheck (the Compose equivalent of the
HEALTHCHECK instruction) runs a test command inside the container at an interval; with
condition: service_healthy Compose holds the dependent
service back until that test passes. Two more options belong to the
long form: restart: true restarts the dependent service
after Compose updates the dependency, and
required: false only warns when the dependency is
missing.
The test run used a simplified version of the application: the meter-api with a health check, and the portal waiting for it to be healthy. The file puts a password directly in the environment to keep it short; do not copy that part (see configuration and secrets).
name: kestrel
services:
meter-api:
image: kestrel-meter-api:1.1
environment:
PORT: "8000"
DB_PASSWORD: example-only
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 5s
timeout: 3s
retries: 3
start_period: 5s
volumes:
- meter-data:/data
portal:
image: kestrel-portal:multi
ports:
- "127.0.0.1:18082:80"
depends_on:
meter-api:
condition: service_healthy
volumes:
meter-data:
$ docker compose -p kestrel up -d
Network kestrel_default Creating Network kestrel_default Created Volume "kestrel_meter-data" Creating Volume "kestrel_meter-data" Created Container kestrel-meter-api-1 Creating Container kestrel-meter-api-1 Created Container kestrel-portal-1 Creating Container kestrel-portal-1 Created Container kestrel-meter-api-1 Starting Container kestrel-meter-api-1 Started Container kestrel-meter-api-1 Waiting Container kestrel-meter-api-1 Healthy Container kestrel-portal-1 Starting Container kestrel-portal-1 Started
Compose creates the network and the volume, creates both containers, starts the meter-api, and then waits until its health check reports healthy before it starts the portal.
When the dependency never gets healthy
In a second run, the health check tested the wrong port (9999
instead of 8000), so it could never pass:
$ docker compose -p kestrel-unhealthy up -d
Network kestrel-unhealthy_default Creating Network kestrel-unhealthy_default Created Volume "kestrel-unhealthy_meter-data" Creating Volume "kestrel-unhealthy_meter-data" Created Container kestrel-unhealthy-meter-api-1 Creating Container kestrel-unhealthy-meter-api-1 Created Container kestrel-unhealthy-portal-1 Creating Container kestrel-unhealthy-portal-1 Created Container kestrel-unhealthy-meter-api-1 Starting Container kestrel-unhealthy-meter-api-1 Started Container kestrel-unhealthy-meter-api-1 Waiting Container kestrel-unhealthy-meter-api-1 Error dependency failed to start: container kestrel-unhealthy-meter-api-1 is unhealthy
$ docker compose -p kestrel-unhealthy ps -a
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS kestrel-unhealthy-meter-api-1 kestrel-meter-api:1.1 "python app.py" meter-api 16 seconds ago Up 16 seconds (unhealthy) 8000/tcp kestrel-unhealthy-portal-1 kestrel-portal:multi "/docker-entrypoint.…" portal 16 seconds ago Created
The meter-api itself runs fine; only its check fails, three times in
a row (retries: 3), which marks it unhealthy. The
portal stays Created and never starts, and
up exits with 1. The health check's own output, in
docker inspect, shows why (see
Debugging Containers). A wrong health check fails a deployment just as surely as a
broken service.
Working out the start order
Compose sorts services so that each one starts after everything it depends on: a topological sort. Python's standard library has one, which also shows what happens with a circular dependency (Compose refuses such a file):
from graphlib import CycleError, TopologicalSorter
depends_on = {
"portal": ["meter-api"],
"meter-api": ["db"],
"billing-export": ["db"], # only with --profile jobs
"db": [],
}
print("start order:", " -> ".join(TopologicalSorter(depends_on).static_order()))
sorter = TopologicalSorter(depends_on)
sorter.prepare()
step = 1
while sorter.is_active():
ready = sorted(sorter.get_ready())
print(f"step {step}: start {', '.join(ready)}")
sorter.done(*ready)
step += 1
depends_on["db"] = ["portal"] # a mistake: db now waits for portal
try:
list(TopologicalSorter(depends_on).static_order())
except CycleError as error:
print("cycle:", " -> ".join(error.args[1]))
start order: db -> meter-api -> billing-export -> portal step 1: start db step 2: start billing-export, meter-api step 3: start portal cycle: portal -> db -> meter-api -> portal
Services in the same step have no dependency on each other, so Compose may start them in parallel. In the cycle, each service is needed by the next one, so none of them can ever go first.
Configuration and secrets
Settings reach a container in three ways, and they are easy to confuse:
-
environment:sets environment variables in the container, directly in the compose file. -
env_file:reads environment variables for the container from a file. When both set the same variable,environment:wins. -
A file named
.envnext tocompose.yamlis read by Compose itself, to fill in${VARIABLE}placeholders in the compose file, such asimage: registry.example.com/kestrel/portal:${PORTAL_TAG}. Its values do not reach the containers unless the file passes them on.
None of these is a good place for a password. Environment variables
show up in docker inspect, in the output of
docker compose config, in crash reports and in the
process list, and a compose file with a real password ends up in Git
sooner or later (see
what never belongs in Git). Compose secrets mount a file instead: the
db_password secret above appears inside the meter-api
and db containers as the file /run/secrets/db_password.
Applications read the password from that file; the official
postgres image supports this through
POSTGRES_PASSWORD_FILE. The secret file itself stays
out of Git, in .gitignore, just like .env.
A small review script catches the most common problems before a compose file is merged. Python's standard library cannot read YAML, so this one works on the file already loaded as a dictionary:
compose = {
"version": "3.8",
"services": {
"portal": {"image": "registry.example.com/kestrel/portal:latest",
"ports": ["8080:8080"]},
"meter-api": {"image": "registry.example.com/kestrel/meter-api:2.3.0",
"environment": {"DB_PASSWORD": "example-only"},
"depends_on": ["db"]},
"db": {"image": "postgres:17", "ports": ["5432:5432"]},
},
}
if "version" in compose:
print("top level: 'version' is obsolete, remove it")
for name, svc in compose["services"].items():
image = svc.get("image", "")
if image.endswith(":latest") or ":" not in image.split("/")[-1]:
print(f"{name}: pin a version instead of {image}")
for port in svc.get("ports", []):
if port.count(":") == 1:
print(f"{name}: port {port} is published on all interfaces")
for key in svc.get("environment", {}):
if "PASSWORD" in key or "SECRET" in key:
print(f"{name}: {key} in plain text, use a secret file")
if isinstance(svc.get("depends_on"), list):
print(f"{name}: depends_on waits only for 'started', not 'healthy'")
top level: 'version' is obsolete, remove it portal: pin a version instead of registry.example.com/kestrel/portal:latest portal: port 8080:8080 is published on all interfaces meter-api: DB_PASSWORD in plain text, use a secret file meter-api: depends_on waits only for 'started', not 'healthy' db: port 5432:5432 is published on all interfaces
The published database port deserves the most attention: on a
server, "5432:5432" offers PostgreSQL to the whole
network, and a host firewall does not stop it (see
Container Networking).
Everyday commands
Every command works on the project in the current folder (or the one
named with -p):
| Command | What it does |
|---|---|
docker compose config |
Validates the file and prints the full, normalized model with all defaults and variables filled in |
docker compose pull |
Downloads the images of all services |
docker compose up -d |
Creates and starts everything in the background; run again after a change, it recreates only the services whose configuration or image changed |
docker compose ps |
Lists the project's containers with status and ports |
docker compose logs -f meter-api |
Follows the logs of one service (or all, without a name) |
docker compose exec db psql -U postgres |
Runs a command in a running service container |
docker compose stop / start |
Stops or starts the containers, keeping them |
docker compose down |
Stops and removes the containers and the network; named volumes stay |
docker compose down -v |
The same, and also removes the volumes: the data is gone |
docker compose config is the first command to run when
something behaves unexpectedly: it shows what Compose really
understood. For this file it expands the short port syntax and fills
in the generated names:
$ docker compose -p kestrel config
name: kestrel
services:
meter-api:
…
portal:
depends_on:
meter-api:
condition: service_healthy
required: true
image: kestrel-portal:multi
networks:
default: null
ports:
- mode: ingress
host_ip: 127.0.0.1
target: 80
published: "18082"
protocol: tcp
networks:
default:
name: kestrel_default
volumes:
meter-data:
name: kestrel_meter-data
The running project, a request to the portal, and the logs:
$ docker compose -p kestrel ps
$ curl -s http://127.0.0.1:18082/
$ docker compose -p kestrel logs --tail 1 meter-api
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS kestrel-meter-api-1 kestrel-meter-api:1.1 "python app.py" meter-api 6 seconds ago Up 5 seconds (healthy) 8000/tcp kestrel-portal-1 kestrel-portal:multi "/docker-entrypoint.…" portal 6 seconds ago Up Less than a second 127.0.0.1:18082->80/tcp <!doctype html><title>Kestrel Energy portal</title><h1>Kestrel Energy</h1><p>Your meter readings, without the paperwork.</p> meter-api-1 | 127.0.0.1 - - [06/Oct/2026 12:22:58] "GET /health HTTP/1.1" 200 -
The meter-api shows 8000/tcp without an arrow: the port
exists, but only inside the project network. The log line is the
health check calling the service from inside its own container. From
the portal container, the meter-api is reachable by service name:
$ docker compose -p kestrel exec portal wget -qO- http://meter-api:8000/readings
[{"meter": "KE-0001", "kwh": 4123.7}, {"meter": "KE-0002", "kwh": 977.1}, {"meter": "KE-0003", "kwh": 15.0}]
Stopping one service, and then removing the whole project:
$ docker compose -p kestrel stop meter-api
$ docker compose -p kestrel ps -a
Container kestrel-meter-api-1 Stopping Container kestrel-meter-api-1 Stopped NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS kestrel-meter-api-1 kestrel-meter-api:1.1 "python app.py" meter-api 26 seconds ago Exited (137) Less than a second ago kestrel-portal-1 kestrel-portal:multi "/docker-entrypoint.…" portal 26 seconds ago Up 19 seconds 127.0.0.1:18082->80/tcp
Exit code 137 after a plain stop is worth a second
look: this simplified meter-api runs as process 1 in its container
and ignores the polite stop signal (SIGTERM), so Docker killed it
after the 10-second grace period. A well-behaved service exits with
0 or 143; see
Running Containers.
$ docker compose -p kestrel down -v
Container kestrel-portal-1 Stopping Container kestrel-portal-1 Stopped Container kestrel-portal-1 Removing Container kestrel-portal-1 Removed Container kestrel-meter-api-1 Stopping Container kestrel-meter-api-1 Stopped Container kestrel-meter-api-1 Removing Container kestrel-meter-api-1 Removed Network kestrel_default Removing Volume kestrel_meter-data Removing Volume kestrel_meter-data Removed Network kestrel_default Removed
Compose stops the portal first (it depends on the meter-api), then
the meter-api, and with -v the volume goes as well.
Profiles and one-off jobs
Not every service should start with up. The billing
export runs once a night, so it has profiles: ["jobs"]:
services with a profile start only when that profile is activated.
Joost Bakker runs the export by hand, as a one-off container that is
removed when it ends:
$ docker compose --profile jobs run --rm billing-export --date 2026-10-05
run starts one new container of the service with the
arguments you give (here replacing the image's default command
arguments), and it starts the dependencies first, so the database is
healthy before the export begins. On a single server, a cron job or
systemd timer can run this command every night; in Kubernetes the
same job becomes a CronJob.
Outdated advice you will still find
| Old habit | Current practice (October 2026) |
|---|---|
docker-compose up, installing Compose with pip
|
docker compose up, the plugin that comes with
Docker; v1 is unmaintained
|
version: "3.8" at the top |
Leave it out; Compose always uses the current Compose Specification and only warns about the line |
docker-compose.yml |
compose.yaml (old names still work) |
links: between services |
Not needed: service names resolve on the project network |
depends_on plus a "wait-for-it" script in the
image
|
condition: service_healthy with a
healthcheck
|
Passwords under environment: |
secrets: mounted as files, read through
*_FILE variables
|
When Compose is not enough
Compose manages containers on one host. If that host fails, the application is down until someone starts it elsewhere; Compose does not move containers between machines, balance load over several hosts or roll out a new version without a short interruption. For that you need an orchestrator such as Kubernetes (see why orchestration and How a Kubernetes Cluster Works). Compose remains the quickest way to run the same application on a developer's laptop and in a CI test job, and many small applications run happily on one well-backed-up server with Compose and a restart policy.