Skip to main content

🐳 Lesson 5: Docker Compose

Stop juggling a dozen docker run flags. Describe your whole multi-container app in one declarative file and bring it up — services, network, and volumes — with a single command.

🎯 Learning Objectives

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

  • Write a modern compose.yaml that defines multiple services, networks, and volumes — with no obsolete version: key
  • Run and manage a full stack with docker compose up, logs, ps, and down
  • Explain how services discover each other by name on the default Compose network
  • Use docker compose watch, override files, and profiles to build a smooth local dev workflow

Estimated Time: 45–55 minutes

Prerequisite: Lessons 1–4 (containers, images, and Dockerfiles).

In This Lesson

The Problem Compose Solves

A real application is rarely a single container. A typical web app is a web frontend, an API, and a database — three containers that must be built, configured, networked together, and started in the right order. Run that by hand and you're typing something like this, over and over:

docker network create app-net
docker volume create db-data
docker run -d --name db --network app-net -v db-data:/var/lib/postgresql/data \
  -e POSTGRES_PASSWORD=secret postgres:16-alpine
docker run -d --name api --network app-net -e DATABASE_URL=postgres://db:5432/app \
  -p 8000:8000 myapp-api
docker run -d --name web --network app-net -p 8080:80 myapp-web

That's fragile, hard to share, and impossible to remember. Docker Compose replaces all of it with a single declarative file. You describe the desired state — which services exist, how they're built, what they connect to — and Compose makes it real with one command:

docker compose up -d
💡 It's docker compose, with a space. Compose is now a plugin built into the Docker CLI (Compose v2). The old standalone docker-compose binary (v1, hyphenated) reached end-of-life and should no longer be used. Every command in this lesson uses the modern docker compose form.
graph LR A["compose.yaml"] --> B["docker compose up -d"] B --> C["web"] B --> D["api"] B --> E["db"] C --> F["Running app stack"] D --> F E --> F

📖 Definition

Docker Compose: a tool for defining and running multi-container applications from a single YAML file. One file declares every service, network, and volume; one command brings the whole stack up or tears it down.

Anatomy of compose.yaml

The canonical filename today is compose.yaml. The older names docker-compose.yml and docker-compose.yaml still work and are auto-detected, but compose.yaml is what the Compose Specification recommends for new projects.

🚫 No more version:

Old tutorials start the file with version: "3.8". Don't. The top-level version key is obsolete and ignored by Compose v2 — it belongs to the legacy schema. A modern compose file starts directly with services:. If you still have it, Compose prints a warning telling you to remove it.

Here is a minimal, valid file — note it opens straight into services::

services:
  web:
    image: nginx:alpine
    ports:
      - "8080:80"

Each service supports a rich set of keys. The ones you'll reach for constantly:

KeyWhat it does
imagePull a prebuilt image from a registry (e.g. postgres:16-alpine).
buildBuild from a local Dockerfile instead — a path, or a context/dockerfile pair.
portsPublish container ports to the host, "HOST:CONTAINER".
environmentSet environment variables inside the container.
env_fileLoad variables from a file (e.g. .env) — keep secrets out of the compose file.
volumesMount a named volume for persistence, or bind-mount host code for development.
networksAttach the service to one or more named networks.
depends_onControl start order — optionally waiting on a healthcheck.
restartRestart policy: no, always, on-failure, or unless-stopped.

image vs build

Use image when someone else already published what you need (databases, caches, proxies). Use build when the service is your code:

services:
  db:
    image: postgres:16-alpine          # pull a ready-made image
  api:
    build:                             # build your own from a Dockerfile
      context: ./api
      dockerfile: Dockerfile

depends_on and healthchecks

A plain depends_on only waits for a container to start, not for the program inside to be ready. A database process takes a moment to accept connections, so your API can still crash on boot. The robust fix is a healthcheck plus the condition: service_healthy form:

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

  api:
    build: ./api
    depends_on:
      db:
        condition: service_healthy     # wait until db reports healthy
⚠️ Note: Even with healthchecks, well-written apps should still retry their first database connection. Startup ordering reduces flakiness; it isn't a substitute for resilient code.

A Complete Worked Stack

Let's assemble a realistic three-tier app: an Nginx web frontend, a Node API we build ourselves, and a Postgres database whose data lives in a named volume — all on a private network.

# compose.yaml — a web + api + database stack
services:
  web:
    image: nginx:alpine
    ports:
      - "8080:80"
    depends_on:
      - api
    networks:
      - frontend

  api:
    build: ./api
    environment:
      DATABASE_URL: postgres://appuser:secret@db:5432/appdb
    env_file:
      - .env
    depends_on:
      db:
        condition: service_healthy
    networks:
      - frontend
      - backend
    restart: unless-stopped

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

volumes:
  db-data:

networks:
  frontend:
  backend:

Notice the shape: three services, a volumes: block declaring the named db-data volume, and a networks: block declaring two private networks. The db sits only on backend, so the web tier can't reach it directly — only the api bridges both.

Bring it up

docker compose up -d

Output:

[+] Running 5/5
 ✔ Network app_frontend   Created
 ✔ Network app_backend    Created
 ✔ Volume "app_db-data"   Created
 ✔ Container app-db-1     Healthy
 ✔ Container app-api-1    Started
 ✔ Container app-web-1    Started

Check what's running, then watch the logs stream by:

docker compose ps
docker compose logs -f

Output of docker compose ps:

NAME         IMAGE              SERVICE   STATUS                   PORTS
app-api-1    app-api            api       Up 20 seconds            0.0.0.0:8000->8000/tcp
app-db-1     postgres:16-alpine db        Up 25 seconds (healthy)
app-web-1    nginx:alpine       web       Up 19 seconds            0.0.0.0:8080->80/tcp

Scale a service

Need more API workers? Scale horizontally without editing the file:

docker compose up -d --scale api=3

Compose starts three api containers sharing the same network; a reverse proxy or load balancer in front distributes requests across them. (Only stateless services should be scaled this way — never scale a single-writer database.)

Tear it down

docker compose down

This stops and removes the containers and networks — but keeps your named volume, so the database survives. To wipe the data too, add -v:

docker compose down -v      # also removes the db-data volume
⚠️ Careful: docker compose down -v deletes named volumes declared in the file. That erases your database. Use it deliberately.

Service-to-Service Networking

This is the piece that surprises newcomers most. When Compose creates a network, it also runs an embedded DNS server. Every service is reachable from every other service on that network by its service name as the hostname. No IP addresses, no links, no manual config.

So in our stack, the API connects to Postgres at the host db — because the service is named db:

    environment:
      DATABASE_URL: postgres://appuser:secret@db:5432/appdb
      #                                        ^^ the service name

🧭 Host vs container ports

Services talk to each other on the container's internal port (Postgres on 5432) — you do not need a ports: mapping for that. ports: only publishes a service to the host (your laptop). A database usually needs no published port at all; keep it private on the backend network.

A Compose stack where web talks to api, api talks to db by service name, and db persists to a named volume across two private networks 🌐 Your Browser localhost:8080 network: frontend network: backend web nginx:alpine api build: ./api on frontend + backend db postgres:16 volume: db-data http://api:8000 db:5432
Figure 1: Services find each other by name — web calls http://api:8000, api reaches Postgres at db:5432, and db persists to the named db-data volume. Splitting frontend and backend networks keeps the database unreachable from the web tier.

✅ The rule to remember

Inside a Compose network, reach another service at http://SERVICE_NAME:PORT — never localhost. From a container, localhost means that same container, not its neighbors.

Volumes & Persistence

Containers are ephemeral — remove one and everything written inside it is gone. That's fine for stateless apps, catastrophic for a database. Volumes are how Compose keeps data alive across restarts and rebuilds.

Named volumes — for data that must survive

A named volume is managed by Docker and outlives any single container. Declare it under the top-level volumes: key and mount it into the service:

services:
  db:
    image: postgres:16-alpine
    volumes:
      - db-data:/var/lib/postgresql/data   # named volume → survives down/up

volumes:
  db-data:

Bind mounts — for development

A bind mount maps a folder on your host straight into the container. Edit a file on your laptop and the container sees it instantly — perfect for iterating on source code:

services:
  api:
    build: ./api
    volumes:
      - ./api/src:/app/src   # host path : container path
AspectNamed volumeBind mount
Managed byDockerYou (a host path)
Best forDatabases, persistent dataLive-editing source in dev
Syntaxname:/path./host:/path
Survives downYes (unless -v)N/A — it's your folder
💡 Tip: Inspect what Docker is managing with docker volume ls. Compose prefixes volume names with the project name, so db-data shows up as app_db-data.

The Modern Dev Workflow

🚀 docker compose watch — live sync and rebuild

The standout modern feature. Instead of bind-mounting and hoping your framework hot-reloads, docker compose watch lets you declare exactly what should happen when files change: sync files into the running container, or rebuild the image when dependencies change. Add a develop.watch block to the service:

services:
  api:
    build: ./api
    develop:
      watch:
        - action: sync              # copy changed source into the container
          path: ./api/src
          target: /app/src
        - action: rebuild           # rebuild when dependencies change
          path: ./api/package.json

Then start watching:

docker compose watch

Output:

[+] Running 3/3
 ✔ Container app-db-1   Healthy
 ✔ Container app-api-1  Started
 ✔ Container app-web-1  Started
⦿ watching service "api" for changes...
Syncing "api" after changes: api/src/routes.js

Edit api/src/routes.js and Compose syncs it in place; change package.json and it rebuilds the image automatically. It's the cleanest inner-loop Compose has ever had.

Override files

Compose automatically merges a compose.override.yaml on top of your base compose.yaml. Keep production-safe defaults in the base file, and put developer conveniences (bind mounts, debug env vars, exposed ports) in the override:

# compose.override.yaml — merged automatically in dev
services:
  api:
    environment:
      NODE_ENV: development
    volumes:
      - ./api/src:/app/src

To run without the override (e.g. a production-like run), name your files explicitly:

docker compose -f compose.yaml -f compose.prod.yaml up -d

Profiles

Profiles let one file describe optional services that only start when asked for. Tag a service with profiles: and it stays dormant until you enable its profile:

services:
  api:
    build: ./api
  debug-tools:
    image: nicolaka/netshoot
    profiles: [debug]      # only starts when the "debug" profile is active
docker compose --profile debug up -d

🔒 Scanning images for vulnerabilities

When you're ready to ship, scan your built images with docker scout (the modern replacement for the deprecated docker scan):

docker scout quickview
docker scout cves app-api

Try It Yourself

Time to build a real stack from scratch — a small web page served by Nginx plus a Postgres database — bring it up, prove it works, and tear it down.

🏋️ Exercise: A 2-service stack

Objective: Write a modern compose.yaml (no version: key), run it with docker compose, verify both services, then tear it down cleanly.

Instructions:

  1. Create a new folder and add a compose.yaml with two services: a web (nginx:alpine, published on 8080) and a db (postgres:16-alpine) with a named volume for its data.
  2. Run docker compose up -d and confirm both containers are up with docker compose ps.
  3. Open http://localhost:8080 — you should see the Nginx welcome page.
  4. Follow the database logs with docker compose logs -f db and watch it report ready to accept connections.
  5. Tear everything down with docker compose down. Then bring it back up and confirm the database data is still there (the named volume persisted).
💡 Hint

Postgres refuses to start without a password. Set POSTGRES_PASSWORD under the service's environment:. Remember: no top-level version: key — start the file with services:.

✅ Worked solution

A complete compose.yaml:

services:
  web:
    image: nginx:alpine
    ports:
      - "8080:80"

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: secret
    volumes:
      - db-data:/var/lib/postgresql/data

volumes:
  db-data:

Run and verify:

docker compose up -d
docker compose ps
docker compose logs -f db     # Ctrl-C to stop following
docker compose down           # containers gone, db-data volume kept
docker compose up -d          # data is still there

Because the database wrote to the db-data named volume, its data survived the down/up cycle. Only docker compose down -v would have erased it.

Quick Quiz

Question 1: How does one service in a Compose stack connect to another (say, the API to the database)?

Question 2: What should a modern compose.yaml start with?

Question 3: You run docker compose down on a stack with a named volume for the database. What happens to the data?

Summary

🎉 Key Takeaways

  • Compose turns many docker run commands into one declarative file — define services, networks, and volumes, then run docker compose up -d.
  • Use docker compose (a space), not the retired docker-compose, and drop the obsolete version: key — modern files start at services:.
  • Services reach each other by service name on the Compose network — db:5432, never localhost.
  • Named volumes persist data across down/up; docker compose watch gives you a fast, modern dev inner loop.

📚 Additional Resources

🚀 What's Next?

You can now orchestrate a full multi-container stack locally. Next, we'll push into production territory — registries, deeper networking, image optimization, and the deployment strategies that take these ideas beyond your laptop.

🎉 Lesson 5 complete!

One file, one command, an entire stack. That's the power of Compose — see you in Advanced Docker!