Running one container is a docker run command. Running eight, wired together,
is unmanageable that way. Docker Compose
describes a set of containers in a single docker-compose.yaml file and
manages them as one unit: one command brings them all up, one command tears
them all down.
This article covers the parts of the file format. The next article uses them to build a real stack.
The shape of a Compose file#
services: clickhouse: image: clickhouse/clickhouse-server:latest ports: - "8123:8123" environment: - CLICKHOUSE_USER=clickhouse volumes: - clickhouse_data:/var/lib/clickhouse
volumes: clickhouse_data:Two top-level keys matter most:
services— one entry per container. The key (clickhouse) is the service name.volumes— named storage that services can mount and that survivesdocker compose down.
Compose manages everything under these as a project, named after the directory by default.
image vs. build#
A service gets its image one of two ways:
services: postgres: image: postgres:13 # pull a published image
airflow-scheduler: build: . # build from the Dockerfile in this directoryUse image for stock services. Use build when you need a custom image (see
Writing a Dockerfile). A service can have both:
build to build it and image to name the result.
ports — reaching a container from your machine#
ports: - "8123:8123" # host 8123 -> container 8123 - "8085:8080" # host 8085 -> container 8080The format is "HOST:CONTAINER". The left number is the port on your machine;
the right is the port the process listens on inside the container. They do not
have to match — the Kafka UI listens on 8080 inside its container but is
published on 8085 so it does not clash with Airflow.
A service only needs published ports if something outside the Compose network
talks to it. Services talk to each other without any ports entry at all
(next section).
The Compose network#
Compose puts every service on one private network and registers each service
name as a hostname on it. Inside that network, clickhouse resolves to the
ClickHouse container, kafka to the Kafka container, and so on.
your machine | reaches published ports at localhost:<host-port> vcompose network "project_default" postgres other services reach it as postgres:5432 clickhouse other services reach it as clickhouse:8123 kafka other services reach it as kafka:29092This is the single most important idea for wiring a stack:
- From your machine: use
localhost:<published-port>. - From another container: use
<service-name>:<container-port>.
Airflow connects to Postgres with the host postgres, not localhost. Kafka UI
connects to Kafka with kafka:29092. Using localhost from inside a container
points at that container itself and fails.
environment — configuration per environment#
The environment key
accepts either form shown below:
environment: - AIRFLOW__CORE__EXECUTOR=LocalExecutor - AIRFLOW__CORE__LOAD_EXAMPLES=Falseor the map form:
environment: KAFKA_NODE_ID: 1 KAFKA_PROCESS_ROLES: 'broker,controller'Environment variables are how a stock image is configured without rebuilding it.
Both forms are equivalent; pick one per file for consistency. Values that are
secret in a real deployment (POSTGRES_PASSWORD) are fine as plain text in a
local development file, but should come from a secret store in production.
volumes — storage that survives#
A container's own file system is deleted when the container is removed. Anything that must persist goes in a volume.
Named volume — Docker manages the storage; good for a database's data directory:
services: postgres: volumes: - postgres_data:/var/lib/postgresql/data
volumes: postgres_data: # declared here, at the top levelBind mount — a path on your machine is mounted into the container; good for source code and config you edit:
services: airflow-scheduler: volumes: - ./dags:/opt/airflow/dags - ./logs:/opt/airflow/logsThe format is SOURCE:TARGET. A named volume's source is a name declared under
the top-level volumes:; a bind mount's source is a path starting with ./ or
/.
docker compose down keeps named volumes. docker compose down -v deletes
them — that is how you reset a database to empty.
depends_on — start order#
depends_on: postgres: condition: service_healthyPlain depends_on: [postgres] only waits for the Postgres container to
start, not for Postgres itself to be ready to accept connections. The
condition: service_healthy form waits for the dependency's healthcheck to pass
first, which is what you usually want.
healthcheck — "is it actually ready?"#
The full set of healthcheck options
covers more than the four used here:
healthcheck: test: ["CMD-SHELL", "pg_isready -U airflow"] interval: 5s timeout: 5s retries: 5 start_period: 30sCompose runs test on a schedule and marks the service healthy or
unhealthy. interval is how often, retries how many failures before
unhealthy, timeout how long one check may take, and start_period a grace
window at startup during which failures do not count (useful for a service like
Kafka that takes time to initialise its storage).
Healthchecks pair with depends_on: condition: service_healthy to sequence a
stack correctly.
restart — what happens when a container exits#
The restart policy
options:
restart: always # restart whenever it stops, including on rebootrestart: unless-stopped # same, but not if you stopped it manuallyrestart: "no" # default: do not restartLong-running services use always or unless-stopped. A one-shot init
container that is meant to run once and exit uses the default.
Running a Compose project#
docker compose up -d # start everything in the backgrounddocker compose ps # status of each servicedocker compose logs -f kafka # follow one service's logsdocker compose exec kafka bash # a shell inside a running servicedocker compose down # stop and remove containers (keep volumes)docker compose down -v # also delete named volumesdocker compose up -d --build # rebuild images first, then startup creates the network, pulls or builds images, and starts services in
dependency order. -d (detached) runs them in the background.
Common mistakes#
Using localhost between containers#
Inside a container, localhost is that container. Use the other service's name:
postgres, clickhouse, kafka.
Publishing a port just to let services talk#
Services communicate over the Compose network without any ports entry. Publish
only what you open in a browser or connect to from your host.
depends_on without a health condition#
Plain depends_on waits for the container, not the service. Add a healthcheck to
the dependency and condition: service_healthy.
Expecting data after down -v#
-v deletes named volumes. Run docker compose down without -v to keep a
database's data.
Editing a running container instead of the file#
Changes made with exec are lost on recreate. Change the Compose file or the
Dockerfile and re-run up.
Quick reference#
| Key | Purpose |
|---|---|
services | One entry per container |
image / build | Pull a published image / build from a Dockerfile |
ports: ["H:C"] | Publish container port C on host port H |
environment | Configure the image without rebuilding |
volumes (service) | named:/path to persist, ./path:/path to mount local files |
volumes (top level) | Declare named volumes |
depends_on + condition: service_healthy | Wait for a dependency to be ready |
healthcheck | Command + schedule that decides healthy / unhealthy |
restart | always, unless-stopped, or "no" |
Further reading#
- Compose file reference — the complete key-by-key specification.
- Docker Compose overview — what Compose is and how the CLI fits with the file format.
environment,restart, andhealthcheck— the specific service keys covered above.docker compose— the CLI reference for every command in the last section.