Skip to main content

🧩 Docker Compose for Multi-Container Applications

Real apps aren't one container — they're a web service, a database, a cache, and maybe a worker, all talking to each other. Docker Compose lets you describe that whole stack in one YAML file and bring it up or down with a single command.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what problem Docker Compose solves and read a compose.yaml file
  • Define services from images or a build context, with ports, env vars, and volumes
  • Use Compose's automatic DNS network so services reach each other by name
  • Persist data with named volumes and order startup with healthcheck-gated depends_on
  • Run the core lifecycle commands: up, down, logs, exec, build

Estimated Time: 35–45 minutes  •  Difficulty: Intermediate

Hands-on: Compose a Node API + PostgreSQL stack that survives a restart.

In This Lesson

Why Docker Compose?

Running one container by hand is easy. Running four is a nightmare of long docker run commands: you have to create a network, start the database with the right env vars and volume, start the cache, then start the app pointing at both — in the right order, every time. Miss a flag and things silently break.

Docker Compose replaces all of that with a single declarative file. You describe the desired end state — which services exist, how they connect, what data persists — and Compose figures out how to make it real.

💡 A useful analogy: If a container is one musician, Compose is the conductor. You don't tell each player when to breathe; you hand them a score, raise the baton, and the whole ensemble starts together and stays in sync.

📖 A note on names

Modern Docker ships Compose as a built-in subcommand: docker compose up (a space, no hyphen). The old standalone docker-compose binary still works but is legacy. The file itself is now conventionally compose.yaml, though docker-compose.yml is still recognized.

Anatomy of a Compose File

A Compose file is YAML with a few top-level keys. Here is a complete, minimal example — a web server and a database — annotated:

services:                 # the containers that make up the app
  web:
    image: nginx:1.27-alpine
    ports:
      - "8080:80"         # HOST:CONTAINER
    volumes:
      - ./site:/usr/share/nginx/html:ro
    depends_on:
      - db

  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp
    volumes:
      - db_data:/var/lib/postgresql/data

volumes:                  # persistent, Docker-managed storage
  db_data:

⚠️ The version: key is obsolete

Old tutorials start with version: '3'. The current Compose Specification ignores that field and will warn you about it. Just start with services: — no version line needed.

One Compose file managing several services A single compose.yaml file fans out to a web service, an API service, a database, and a cache, plus shared networks and volumes. compose.yaml one file web api db (volume) cache shared network DNS by name
Figure 1 — One Compose file declares every service, the network they share, and the volumes that outlive them. docker compose up brings the whole thing to life.

Defining Services

Each entry under services: is a container. A service either pulls a prebuilt image: or builds from a Dockerfile with build:. The most common configuration keys:

services:
  api:
    build:                     # build from a Dockerfile...
      context: ./api           #   ...in this directory
      dockerfile: Dockerfile
    # image: myorg/api:1.0     # ...or pull a prebuilt image instead
    restart: unless-stopped    # restart policy
    ports:
      - "3000:3000"
    environment:
      NODE_ENV: production
      DATABASE_URL: postgres://app:secret@db:5432/app
    env_file:
      - .env                   # load extra vars from a file
    volumes:
      - ./api:/app             # bind mount for live code
    command: ["node", "server.js"]   # override the image's CMD

💡 image vs build

Use image: for off-the-shelf services you don't modify (Postgres, Redis, Nginx). Use build: for your own code so Compose builds it as part of up. You rarely need both on one service.

Networking & Service Discovery

This is the feature that makes Compose feel magical. When you run docker compose up, Compose creates a private network and joins every service to it. Inside that network, each service is reachable by its service name as a hostname.

So the api service connects to Postgres at db:5432 — literally the string db, the service's name — with no IP addresses and no manual network wiring:

services:
  api:
    build: ./api
    environment:
      DATABASE_URL: postgres://app:secret@db:5432/app   # "db" resolves via DNS
    depends_on:
      - db

  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: app

⚠️ ports is not how services talk to each other

ports: only publishes a port to your host so you can reach it from a browser. Service-to-service traffic on the internal network can use any port whether or not it's published — the db service does not need a ports entry for api to reach it.

Volumes & Environment

Containers are ephemeral — delete one and its filesystem is gone. To keep a database's data, you attach a volume. Compose supports two kinds you'll use constantly:

TypeSyntaxUse for
Named volumedb_data:/var/lib/...Persistent data Docker manages (databases)
Bind mount./src:/app/srcLive-editing your source during development

Named volumes must also be declared in the top-level volumes: block so Docker creates and tracks them:

services:
  db:
    image: postgres:16
    volumes:
      - db_data:/var/lib/postgresql/data   # named volume: survives `down`

  web:
    build: ./web
    volumes:
      - ./web/src:/app/src                 # bind mount: host edits show instantly

volumes:
  db_data:

Keeping secrets out of the file with variable substitution

Compose reads a .env file sitting next to the Compose file and substitutes ${VAR} references. This lets you commit the Compose file to Git while keeping passwords out of it:

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}          # pulled from .env
      POSTGRES_DB: ${DB_NAME:-app}               # default if unset
# .env  (git-ignored)
DB_PASSWORD=super-secret
DB_NAME=myapp

Startup Order & Healthchecks

depends_on controls the order services start — but by default it only waits for the dependency's container to start, not for the service inside it to be ready. Postgres takes a couple of seconds to accept connections, so a plain depends_on: [db] can still let your API start too early and crash.

The fix is a healthcheck on the dependency plus a condition: service_healthy on the dependant:

services:
  api:
    build: ./api
    depends_on:
      db:
        condition: service_healthy   # wait until db is actually ready

  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: secret
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 5

✅ Ready, not just started

With a healthcheck gate, Compose holds the api container until pg_isready succeeds. This is the reliable way to solve the classic "database isn't up yet" race on startup.

Everyday Commands

You'll live in a handful of subcommands. Note the modern space-separated form (docker compose):

# Build (if needed) and start everything in the background
docker compose up -d

# Rebuild images then start
docker compose up -d --build

# See what's running
docker compose ps

# Follow logs for all services, or one
docker compose logs -f
docker compose logs -f api

# Run a one-off command inside a running service
docker compose exec api sh
docker compose exec db psql -U app

# Stop and remove containers + network (named volumes are KEPT)
docker compose down

# Also remove the named volumes (deletes your data!)
docker compose down -v

⚠️ down -v deletes your data

Plain docker compose down keeps named volumes, so your database survives. Adding -v wipes them. Reach for -v only when you deliberately want a clean slate.

flowchart LR A[up -d] --> B[ps / logs] B --> C[exec into service] C --> D[edit code & up --build] D --> B B --> E[down]

Hands-on Exercise

🏋️ Compose a Node API with a persistent PostgreSQL

Objective: Stand up a two-service stack where the API reaches the database by name and the data survives a full down/up cycle.

Instructions:

  1. In a project folder, put your API (with its Dockerfile) in ./api.
  2. Write a compose.yaml with an api service (built from ./api) and a db service using postgres:16.
  3. Give db a named volume and a pg_isready healthcheck; gate api on condition: service_healthy.
  4. Point the API's DATABASE_URL at hostname db.
  5. Run docker compose up -d, write a row, then docker compose down and up again — confirm the row is still there.
💡 Hint

If the data vanishes after down/up, check two things: the volume is declared in the top-level volumes: block, and you did not pass -v to down. If the API crashes on boot, your healthcheck gate is probably missing.

✅ Solution
services:
  api:
    build: ./api
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://app:secret@db:5432/app
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: app
    volumes:
      - db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      timeout: 3s
      retries: 5

volumes:
  db_data:

The named db_data volume is kept by down, so restarting preserves the row. The healthcheck gate guarantees the API never starts before Postgres accepts connections.

🎯 Quick Quiz

Question 1: Inside a Compose network, how does the api service reach the db service?

Question 2: What does plain docker compose down do to your named volumes?

Question 3: Why add a healthcheck and condition: service_healthy instead of a plain depends_on?

Summary & Quiz

🎉 Key Takeaways

  • Docker Compose declares a whole multi-container stack in one file and manages it with one command.
  • Start the file with services: — the old version: key is obsolete.
  • Compose's private network gives every service DNS by name, so db:5432 just works.
  • Named volumes persist data across down/up; bind mounts live-edit source in development.
  • Gate startup with a healthcheck + service_healthy to avoid "database isn't ready" races.

📚 Further Reading

🚀 What's Next?

Compose isn't just for running apps — VS Code can build your entire coding environment from a Compose file too. Next we'll turn these ideas into a development container that boots a ready-to-code workspace.

🎉 Nice work!

You can now orchestrate a full stack of services from a single file. Let's make your dev environment just as reproducible.