Docker Compose from scratch: a multi-service stack with health checks and its own network
A runnable minimal Compose example with Postgres and Adminer – including a health check, depends_on on service_healthy, a custom network and common pitfalls.
As soon as an application consists of more than one container – an app plus a database, perhaps a cache on top – juggling individual docker run commands becomes tedious. Docker Compose describes the entire stack in a single YAML file: which services run, in which network they find each other, when a service counts as "healthy", and in which order things start. This article builds a minimal but realistic example – Postgres plus Adminer – and shows the three building blocks every multi-service stack needs.
What Compose does – and when it fits
Compose is the tool for multiple containers on one host. It is excellent for local development and for modest single-host deployments (one VPS, one self-hosting server). For orchestration across multiple nodes you reach for Kubernetes or Docker Swarm – but for "app + database on one machine", Compose is the right, lean choice.
Three things you almost always want: the services should reach each other (a custom network), a dependent service should only start once its database is truly ready (health check + depends_on), and crashed containers should restart automatically (a restart policy). That is exactly what the following example covers.
The minimal stack
A single file, compose.yaml, is enough:
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: <YOUR_PASSWORD>
POSTGRES_DB: appdb
volumes:
- db-data:/var/lib/postgresql/data
networks:
- appnet
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
adminer:
image: adminer:5
environment:
ADMINER_DEFAULT_SERVER: db
ports:
- "8080:8080"
networks:
- appnet
restart: unless-stopped
depends_on:
db:
condition: service_healthy
networks:
appnet:
volumes:
db-data:
Start it:
docker compose up -d
docker compose ps
After a few seconds, open http://localhost:8080, log in with app / <YOUR_PASSWORD> to the database appdb – and you see the running Postgres instance. Adminer already fills in db as the server because we set ADMINER_DEFAULT_SERVER.
The three building blocks
Custom network. The networks: appnet: block creates a user-defined bridge network; both services join it. On a shared Compose network, containers reach each other by service name as the hostname. That is why Adminer connects not to localhost but to db – the DNS name of the database container. Inside the Adminer container, localhost would point at Adminer itself, not at Postgres.
Health check. The healthcheck block tells Docker how to determine whether Postgres is ready. pg_isready is built exactly for this and ships in the Postgres image; it returns exit code 0 once the server accepts connections. interval is the spacing between checks, retries the number of failures before "unhealthy", and start_period a grace window at startup during which failures do not yet count. You see the state in the STATUS column of docker compose ps (healthy / unhealthy).
Startup order. depends_on with condition: service_healthy makes Adminer start only once the health check of db is green – not merely once the container has started. That is the decisive difference (see pitfall 1).
On top of that comes the restart policy. restart: unless-stopped restarts a container automatically after a crash or a restart of the Docker daemon – but only as long as you have not stopped it yourself. For long-running services this is the usual choice; always would bring a deliberately stopped container back up after a daemon restart, which is rarely what you want.
The flow at a glance:
docker compose up -d
|
v
+-------------+ start_period +----------------+
| db | ---------------> | healthcheck: |
| postgres:17 | pg_isready ... | retries 5 ... |
+-------------+ +-------+--------+
| healthy
v
depends_on: db: condition: service_healthy
|
v
+----------------+
| adminer | :8080
| server = "db" |
+----------------+
Three common pitfalls
1. depends_on alone only waits for start, not for readiness. Without condition: service_healthy, Docker starts the dependent service as soon as the other container is running – but the database is often still initialising at that point. Only the combination of a health check on the db service and condition: service_healthy on the dependent service guarantees genuine readiness.
2. The $$ in the health check. Compose interprets $VARIABLE itself and would substitute ${POSTGRES_USER} already when reading the file (to an empty value). With $$ you write a literal $ into the container instruction, so the variable is resolved inside the container at runtime. Hence pg_isready -U $${POSTGRES_USER}.
3. The obsolete version:. Older guides begin with version: "3.8". In Compose v2 this key is superfluous and only produces a warning – leave it out. Compose validates against the current specification anyway.
Where to go from here
You now have a clean pattern that transfers to every further service: own network, health check, depends_on. Obvious next steps are adding your own app to the stack with a multi-stage Dockerfile, moving credentials into a .env file, and a look at the full Compose file reference as well as the section on startup order.