A real app is rarely one container. It's an API, a database, maybe a cache.
Docker Compose lets you describe all of them in a single compose.yaml
(the older name docker-compose.yaml also works) and start the whole set with one
command. Compose reads that file, builds or pulls each image, and starts the containers,
respecting any depends_on ordering you declare.
services:
api:
build: . # build from the Dockerfile in this folder
ports:
- "3000:3000"
volumes:
- .:/app # bind mount for live-reload
environment:
DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD}@db:5432/app
depends_on:
- db
db:
image: postgres:16 # pulled prebuilt from a registry, no Dockerfile needed
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data # named volume keeps data across restarts
volumes:
pgdata:
Not every service needs a Dockerfile. Below, the db service has none: it just names a
prebuilt image (postgres:16), and Compose pulls it from a registry, the same place
your FROM node:20-alpine base image came from. Here the pulled image is the
whole service, with no build step on top.
${POSTGRES_PASSWORD} tells Compose to read the value from your shell or a
.env file in the same folder, instead of writing the password into a file you commit.
Keep .env out of source control. The Secrets lesson covers why this matters.
How to use it
# Build the images for services that have a `build:` (the api here).
docker compose build
# Start everything; pass --build to rebuild images first if your code changed
docker compose up
docker compose up --build
# Stop and remove the containers and the network.
# Named volumes survive; add -v to remove those too.
docker compose down docker compose up only builds an image the first time. After you change your code or
Dockerfile, run docker compose build first, or docker compose up --build,
so the running container uses the new image rather than a stale one.
Containers talk by service name
Compose puts every service on a shared network and uses the service name as a
hostname. That's why the api reaches the database at db:5432, not
localhost . Inside the network, db resolves to the database container.
The catch is that localhost means "this same container," not "this whole app." If the
api tried localhost:5432, it would look for a database inside its own
container and find nothing. Using the service name db is how one container reaches
another.
api reaches db by service name — db:5432 — not localhost. depends_on orders startup, not readiness
depends_on tells Compose to start the db container before the
api container. But "started" only means the container process has launched. It says
nothing about whether the software inside is actually ready. Postgres spends a few seconds
initializing; if the api boots faster and connects immediately, it errors with "connection
refused." Both containers are running; the timing is just off.
The fix is a healthcheck , a command Compose runs repeatedly inside the container to
test if the service is working, combined with the long form of depends_on:
db:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 2s
timeout: 2s
retries: 10
api:
build: .
depends_on:
db:
condition: service_healthy # wait for the healthcheck, not just the start
Now Compose holds the api back until Postgres reports it's accepting connections. The
alternative is to make the app resilient: have the api retry its database connection a few times
on startup. That's good practice anyway, since a database can briefly drop out in production too.
Exercise
Complete the api service in a real compose.yaml. The db
service is already wired up with a healthcheck and a named volume. Fill in the build, port
mapping, the DATABASE_URL that reaches the database by its service name, the
$${POSTGRES_PASSWORD} interpolation, and the depends_on condition
that holds the api until the database is healthy. Press Validate to parse your
YAML and check it.
Compose challenge
Wire up the api service
The db service is already configured: Postgres with a healthcheck and a named
volume. Finish the api service so the stack comes up and can talk to the
database.
- Build the api image from the
Dockerfilein this folder. - Publish the app on port
3000. - Point
DATABASE_URLat the database by its service name (db:5432), notlocalhost. - Read the password from
${POSTGRES_PASSWORD}instead of hardcoding it. - Make the api wait until the database is healthy before it starts (
depends_onwithcondition: service_healthy).
Check your understanding
Can't reach the database at localhost
Your api can't reach the database at localhost:5432, even though both containers are up. Why?