<img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id=1063935717132479&amp;ev=PageView&amp;noscript=1 https://www.facebook.com/tr?id=1063935717132479&amp;ev=PageView&amp;noscript=1 "> Compose for Multi-Container Apps

Compose for Multi-Container Apps

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.

Compose Network api build: . (Dockerfile) ports: 3000:3000 depends_on: db db image: postgres:16 volume: pgdata :5432 db:5432 ✓ not localhost:5432
Compose puts all services on a shared network. The 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 Dockerfile in this folder.
  • Publish the app on port 3000.
  • Point DATABASE_URL at the database by its service name (db:5432), not localhost.
  • Read the password from ${POSTGRES_PASSWORD} instead of hardcoding it.
  • Make the api wait until the database is healthy before it starts (depends_on with condition: service_healthy).

Check your understanding

Question 1 of 2

Can't reach the database at localhost

Your api can't reach the database at localhost:5432, even though both containers are up. Why?