Running automation tooling with Docker Compose¶
Automation tooling rarely runs alone. A source-of-truth database wants a cache and a worker beside it; a lab of scripts wants a consistent Python image. This article shows how to read and write a Docker Compose file so that a whole stack — its containers, their networking, and their data — is captured in one version-controlled file and comes up with a single command.
Compose is infrastructure as code for the tooling layer. A single docker run is fine for
one container, but the moment several containers must run together, you want that topology
written down rather than reconstructed by hand each time. docker compose up then
reproduces the same environment on any host that has Docker.
The anatomy of a Compose file¶
A Compose file is YAML with a few top-level keys. Three describe the stack:
services— the containers that make up the application. Each service is one container (or a scalable set of identical ones).networks— the virtual networks that connect services. Containers on the same network reach each other by service name, resolved through Docker's embedded DNS.volumes— named, persistent storage that outlives any single container, plus bind mounts that map a host path into a container.
You will still see links in older files; it is the deprecated predecessor of
user-defined networks. Recognize it, but reach for networks in anything you write today.
Within a service, a handful of keys carry most of the meaning:
| Key | What it does |
|---|---|
image / build |
Run a prebuilt image, or build one locally from a Dockerfile |
ports |
Map host:container ports to expose a service to the host |
volumes |
Attach named volumes or bind mounts |
environment / env_file |
Set environment variables (configuration and secrets) |
depends_on |
Control start order relative to other services |
networks |
Which networks this service joins |
healthcheck |
How Compose decides a service is actually ready |
A worked example¶
The file below defines a small NetBox-style source-of-truth stack: a web application, a Postgres database, and a Redis cache, wired across two networks with a named volume for database persistence.
services:
web:
image: netboxcommunity/netbox:latest
ports:
- "8080:8080" # host 8080 -> container 8080
environment:
DB_HOST: database # reach Postgres by its service name
REDIS_HOST: cache
depends_on:
database:
condition: service_healthy # wait until Postgres is ready, not just started
cache:
condition: service_started
networks:
- frontend
- backend
database:
image: postgres:16
environment:
POSTGRES_DB: netbox
POSTGRES_USER: netbox
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in the environment}
volumes:
- pgdata:/var/lib/postgresql/data # named volume persists the data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U netbox -d netbox"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
- backend
cache:
image: redis:7
networks:
- backend
networks:
frontend:
backend:
volumes:
pgdata:
Read it top-down:
- The web service publishes port 8080 to the host and joins both networks. It reaches
databaseandcacheby name because they share thebackendnetwork. - Only web is on the
frontendnetwork and exposes a port, so the database and cache are not reachable from outside the stack — a sensible isolation pattern that keeps the data tier private. - The named volume
pgdatakeeps the database's files even if the container is recreated. Without it, adocker compose downfollowed byupwould start from an empty database.
Networking: reach services by name, not by IP¶
Because web, database, and cache share a user-defined network, the application connects
to database:5432, never a hard-coded IP address. Docker's embedded DNS resolves the service
name to whatever address the container currently has, so the stack keeps working when
containers are recreated and addresses change. Splitting the stack into frontend and
backend networks then controls who can talk to whom: only services placed on a network
can reach the others on it.
Start order is not readiness¶
This is the pitfall that causes intermittent, hard-to-reproduce failures. Plain depends_on
guarantees only that a dependency's container has started — not that the service inside
it is accepting connections. A Postgres container reports "started" long before the database
has finished initializing, so an app that connects immediately can race it and fail.
There are two robust fixes, and the example above uses both ideas:
- Give the dependency a
healthcheckand wait on it. The long-formdepends_onwithcondition: service_healthymakes Compose holdwebback until Postgres'spg_isreadycheck passes. The valid conditions areservice_started(the default),service_healthy, andservice_completed_successfully. - Make the client retry. Even with a health check, treat a missing dependency as a transient error and reconnect with backoff. Networks and dependencies can also restart after startup, so resilient clients matter beyond the first boot.
A health check is only as good as its test command: it must actually probe readiness (a real query, not just "is the process alive") and it must eventually turn healthy, or the dependent service will wait forever.
Keep secrets out of the file¶
A committed Compose file is visible to everyone with repository access, so the literal
POSTGRES_PASSWORD: changeme you often see in tutorials is a habit worth breaking. Reference
values from the environment instead — ${DB_PASSWORD:?...} above fails fast with a clear
message if the variable is unset — and keep the real values in a local .env file that is
git-ignored, or in a secrets manager. For production, Compose also supports a top-level
secrets mechanism that mounts
sensitive values as files rather than environment variables.
A few modern conventions¶
Compose has changed enough that older examples can mislead:
- Use the
docker compose(v2) subcommand, not the standalonedocker-composebinary. The Python-based v1 tool was removed from Docker's official images and the GitHub Actions runners in 2025. - Drop the top-level
version:key. It has been ignored since Compose v2 and now only produces an "obsolete" warning; Compose always validates against the latest Compose Specification. - Prefer the filename
compose.yaml. It is the canonical name in the specification;docker-compose.ymlstill works for backward compatibility.
The everyday commands are few: docker compose up -d starts the stack in the background,
docker compose ps lists it, docker compose logs -f web follows one service's output, and
docker compose down stops it. Add -v to down only when you want to delete the named
volumes — that is what discards the database.
Key takeaways¶
- Compose captures a multi-container stack — containers, networking, and data — in one
version-controlled YAML file, and
docker compose upreproduces it anywhere. servicesare the containers;networksconnect them (same network → reach each other by service name via DNS);volumespersist data;linksis legacy.portsmaphost:container, and only published ports are reachable from the host — keep data-tier services off the public network.- A named volume survives container recreation; without one, recreating a container loses its data.
depends_oncontrols start order, not readiness. Add ahealthcheckwithcondition: service_healthy, and make clients retry.- Keep secrets in the environment or a secrets manager, not in the committed file; drop the
obsolete
version:key and use thedocker composev2 command.