08 — Compose: a stack instead of a container
Read this first: this lesson turns one container into a stack that starts in the right order and knows when it is actually ready. It covers services, container-name DNS, volumes, and the difference between "started" and "healthy" — which is the difference between a deploy that works and one that works most of the time.
Time: about 50 minutes. Assumes lesson 06.
Why compose exists
By lesson 05 you can run a container. A real application is several: an app, a database, maybe a proxy. Running them by hand means remembering every flag, in order, every time.
A docker-compose.yml file is that command line, written down.
Your first stack
services:
greeter-web:
build: ./greeter-web
ports:
- '8090:80'
depends_on:
- greeter-api
greeter-api:
build: ./greeter-api
environment:
APP_SECRET: 'your-local-dev-secret-value'
DATABASE_URL: jdbc:postgresql://db:5432/greeter
DATABASE_USER: greeter
DATABASE_PASSWORD: changeme-local-only
ports:
- '8390:8080'
depends_on:
- db
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: greeter
POSTGRES_PASSWORD: changeme-local-only
POSTGRES_DB: greeter
volumes:
- greeter_db:/var/lib/postgresql/data
volumes:
greeter_db:
Three tiers, the same shape as the real system: a React frontend served by nginx, a Spring Boot API, and Postgres.
docker compose up -d
Two ideas are already doing a lot of work here.
build: versus image:. The two application services are built from source; the database is
pulled ready-made.
Later you will meet the production version of this same file, where the app also uses image: —
because production pulls a tested image rather than building on the server. Same app, different
file. That is the whole idea behind "build once, deploy the same artifact".
Service names are hostnames. Look at the database URL: //db:5432. There is no IP address
anywhere. Compose puts every service on a shared network and registers each one under its service
name, so db resolves from inside greeter-api. The same mechanism is how the frontend's nginx
reaches the API — its config says proxy_pass http://greeter-api:8080/api/, by name.
Break it on purpose: localhost is not what you think
Predict: change the database URL from db:5432 to localhost:5432 and restart the app. Postgres
is definitely running. Does it connect?
Caused by: org.postgresql.util.PSQLException: Connection to localhost:5432 refused. Check that the
hostname and port are correct and that the postmaster is accepting TCP/IP connections.
It does not. Inside a container, localhost means that container — its own isolated network
namespace, from lesson 05. The app looked for a database inside itself and
correctly found nothing.
Notice how unhelpful the message is if your model is wrong: it tells you to check the hostname and port, and both look right. The hostname is right for your laptop and wrong for the container.
This is one of the most common beginner failures and one of the easiest to fix once the model is right: containers reach each other by service name, never by localhost.
Break it on purpose: "started" is not "ready"
This is the important one.
depends_on: [db] looks like it says "wait for the database". It does not. It says wait for the
database container to start — which happens in milliseconds, long before Postgres is accepting
connections.
Predict: with plain depends_on: [db], run docker compose down -v and then up. Does the
app's first query succeed on a genuinely cold start?
Usually not. Postgres on a fresh volume initialises the database cluster first, which takes seconds. The app starts, queries immediately, and fails.
The fix has two halves, and you need both.
Half one — the database declares how to tell if it is ready:
db:
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U greeter']
interval: 5s
timeout: 3s
retries: 10
Half two — the app waits for that, not merely for startup:
greeter-api:
depends_on:
db:
condition: service_healthy
Now up blocks until the healthcheck passes:
Container greeterlab-db-1 Waiting
Container greeterlab-db-1 Healthy
Container greeterlab-greeter-api-1 Starting
Container greeterlab-greeter-api-1 Started
And docker compose ps reports it:
NAME STATUS PORTS
greeterlab-db-1 Up 2 minutes (healthy) 5432/tcp
greeterlab-greeter-api-1 Up 56 seconds (healthy) 0.0.0.0:8390->8080/tcp
greeterlab-greeter-web-1 Up 50 seconds 0.0.0.0:8090->80/tcp
Read the PORTS column carefully, because it is teaching you something. The API shows
0.0.0.0:8390->8080/tcp — published to the host. The database shows only 5432/tcp — the port is
open inside the network, and not reachable from your machine at all. A service with no ports:
is not exposed. That is exactly how the MotorPH production stack is arranged: only the edge proxy
publishes anything; the app and the database publish nothing.
Health checks are a contract
Your app's own healthcheck should answer "can I actually serve traffic", not "is the process alive":
healthcheck:
test: ['CMD-SHELL', 'wget -qO- http://127.0.0.1:8080/actuator/health | grep -q UP || exit 1']
interval: 10s
timeout: 5s
retries: 10
start_period: 60s
Spring Boot's actuator gives you /actuator/health for free, and it reports UP only when the
application context started and its dependency checks pass — including the database. That is a far
better answer to "are you ready" than "is the process alive".
start_period is the grace window: failures during it do not count against retries. A JVM plus a
Spring context needs one — 60 seconds here. The MotorPH backend sets start_period: 180s in
production,
because a first boot runs every Flyway migration against a VPS disk, and the 90 seconds that is
plenty on a laptop is not enough there.
Note the healthcheck probes 127.0.0.1, not localhost. That is not fussiness: the docs container
in this repo carries a comment explaining that Alpine resolves localhost to the IPv6 ::1 first,
its nginx listens on IPv4 only, and the probe therefore fails forever while the site serves fine.
The health gate is the whole basis of safe deployment. A deploy script starts the new container
and waits for healthy. If it never arrives, the deploy failed and can be rolled back — before
anyone notices. You will build exactly that in the capstone.
The commands
docker compose up -d # start everything in the background
docker compose up -d --build # rebuild images first
docker compose ps # what is running, and is it healthy
docker compose logs -f greeter-api # follow one service's logs
docker compose exec greeter-api sh # shell inside a running service
docker compose config # print the fully-resolved file — see lesson 09
docker compose down # stop and remove containers
docker compose down -v # ...and DELETE THE VOLUMES
down and down -v are different orders of danger. down stops the stack; down -v destroys
your data. Prove it to yourself while it is free:
curl localhost:8090/api/count # {"count":1,...}
curl localhost:8090/api/count # {"count":2,...}
docker compose down
docker compose up -d
curl localhost:8090/api/count # {"count":3,...} <- survived
docker compose down -v
docker compose up -d
curl localhost:8090/api/count # {"count":1,...} <- gone
Note that those requests go through the frontend, on port 8090, not to the API directly. nginx
forwards /api/ to greeter-api:8080, so the browser only ever talks to one origin — which is why
there is no CORS configuration anywhere and no API URL baked into the React bundle. The real
frontend works the same way, and its Dockerfile sets an empty API base URL on purpose.
That named volume is the reason a rollback swaps code and leaves data alone.
Where this shows up in MotorPH
- docker-compose.yml is the local shape:
build:, published ports, pgAdmin and Mailpit for convenience. - deploy/docker-compose.prod.yml is the same services with
image: ghcr.io/...:${IMAGE_TAG:?}, no published ports at all, andstart_period: 180s. - Four separate compose projects run on the one production host — edge, prod, stage and docs — sharing a single external network. That is lesson 20.
Recap
- Service names are hostnames;
localhostinside a container means that container. depends_onalone waits for start, not readiness. Use ahealthcheckpluscondition: service_healthy.- A service with no
ports:is unreachable from your machine — which is the correct default for everything except the proxy. down -vdeletes volumes.downdoes not.
Next: 09 — Environment variables and secrets, which contains the single nastiest trap in this entire course.