Skip to main content

🐳 Docker Compose Basics

Running one container by hand is fine for a demo. Running a web server, an API, and a database β€” each with its own image, ports, and environment β€” turns into a wall of docker run flags you have to remember perfectly every time. Docker Compose replaces all of that with a single file you commit to git and a single command: docker compose up.

Week 11 · Day 3 (Wednesday: Docker Compose) · Lecture 1

🎯 Learning Objectives

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

  • Explain what Docker Compose solves and when to reach for it
  • Write a modern compose.yaml using the docker compose v2 CLI (no legacy version: key)
  • Configure a service with image/build, ports, environment, env_file, and depends_on
  • Persist database data with a named volume and reach services by name over the default network
  • Drive the stack with up -d, down, logs, ps, and exec
  • Apply .env variable substitution and a healthcheck-gated depends_on

Estimated Time: 60 minutes

Practice: Author a three-service compose.yaml (web + API + Postgres) and run it end to end.

In This Lesson

Why Docker Compose?

Docker Compose is a tool for defining and running multi-container applications. You describe your whole system β€” services, networks, volumes β€” in one YAML file, then bring it all up (or tear it all down) with a single command. The file becomes the single source of truth that anyone on your team can check out and run identically.

🎻 The orchestra analogy

A single container is a musician who plays one instrument well. Your compose.yaml is the score every musician reads from. The docker compose command is the conductor β€” deciding when each part starts and keeping them in time. The running application is the symphony: something no single container could produce alone.

Before Compose, spinning up a web app with a database meant a growing list of imperative commands β€” create a network, run the database with the right flags, run the API pointing at it, run the web server pointing at that. Miss a flag and nothing connects. Compose turns that fragile ritual into declarative, version-controlled configuration.

graph TD A["compose.yaml"] --> B["docker compose up"] B --> C[Docker Engine] C --> D[web container] C --> E[api container] C --> F[db container] D <--> E E <--> F G[Named Volume] --- F

When to reach for it

Great fitWhy
Local developmentOne command spins up your entire stack, identically for every teammate
Automated testing / CIBring services up, run integration tests, tear everything down
Single-host deploymentsSmall production apps on one server (a VPS or homelab box)
Sharing reproducible demosA tutorial anyone can run with docker compose up

πŸ’‘ Compose vs. orchestrators

Compose is designed for a single host. When you need to spread containers across many machines with self-healing and rolling updates, that's the job of an orchestrator like Kubernetes β€” a later topic. Compose remains the fastest path from zero to a running stack on your laptop.

The compose.yaml File

The heart of Compose is a YAML file that declares your services. Modern Compose (the docker compose v2 plugin that ships with Docker Desktop and recent Docker Engine) reads a file named compose.yaml by default. The older docker-compose.yml name still works, but new projects should prefer compose.yaml.

⚠️ Two things that changed in modern Compose

  • The command is docker compose (a space), not docker-compose (a hyphen). The hyphenated v1 tool is deprecated; v2 is a subcommand of the Docker CLI written in Go.
  • The top-level version: key is obsolete. You'll see version: '3.8' in older tutorials β€” modern Compose ignores it and will warn you. Just delete it and start with services:.

Minimal structure

services:
  web:
    # configuration for the web service

  api:
    # configuration for the api service

volumes:
  # named volumes declared here

networks:
  # custom networks declared here (optional β€” a default one is created for you)

Only services: is required. volumes: and networks: are declared at the top level when you want named, reusable ones β€” otherwise Compose creates a default network automatically and connects every service to it.

Anatomy of a Service

Each entry under services: becomes one container. The key you choose (like web or db) is both the container's name and its DNS hostname on the network β€” that's how services find each other. Here are the options you'll use constantly:

The common keys inside a single Compose service definition api: build: ./api # build from a Dockerfile image: node:20 # …or pull a prebuilt image ports: "3000:3000" # host:container environment: key=value pairs env_file: .env # load vars from a file volumes: mount data or code depends_on: start order + health restart: unless-stopped
The service keys you'll reach for in almost every Compose file.
KeyWhat it does
imageUse a prebuilt image from a registry, e.g. postgres:16
buildBuild an image from a local Dockerfile instead of pulling one
portsPublish a container port to the host β€” "HOST:CONTAINER"
environmentSet environment variables inside the container
env_fileLoad environment variables from a file (keeps secrets out of the YAML)
volumesMount a named volume (persistence) or a host path (bind mount for dev)
depends_onControl start order, optionally waiting for a healthcheck to pass
restartRestart policy, e.g. unless-stopped for long-running services

A Real web + api + db Stack

Let's put it all together. This is the classic three-tier layout: an Nginx web front end, a Node.js API built from a local Dockerfile, and a Postgres database whose data survives restarts thanks to a named volume. Study it closely β€” it's the template you'll adapt for nearly every project.

# compose.yaml β€” no "version:" key needed in modern Compose
services:
  web:
    image: nginx:alpine
    ports:
      - "8080:80"              # visit http://localhost:8080
    volumes:
      - ./site:/usr/share/nginx/html:ro   # bind mount static files, read-only
    depends_on:
      - api

  api:
    build: ./api               # builds ./api/Dockerfile
    ports:
      - "3000:3000"
    environment:
      NODE_ENV: development
      # "db" is the service name β†’ Compose resolves it to the db container's IP
      DATABASE_URL: postgres://app:secret@db:5432/appdb
    depends_on:
      db:
        condition: service_healthy   # wait until the DB passes its healthcheck

  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: appdb
    volumes:
      - db_data:/var/lib/postgresql/data   # named volume β†’ data persists
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  db_data:                     # declared here so Docker manages it

Three ideas in this file do all the heavy lifting, and they're worth calling out because they're what beginners miss:

  • Service-name DNS. The API's DATABASE_URL points at the host db, not localhost. Compose runs an internal DNS server so every service can reach every other by its service name. There is no IP address to hard-code.
  • A named volume for the database. db_data lives outside the container's writable layer, so docker compose down and a fresh up keep your rows intact. Bind-mounting the web server's static files (with :ro) is the right call there because those are just files on disk you want to edit live.
  • Healthcheck-gated startup. Plain depends_on only waits for the container to start, not for Postgres to be ready to accept connections. Adding condition: service_healthy plus a healthcheck makes the API wait until pg_isready succeeds β€” eliminating the classic "connection refused" race on the first boot.
graph LR Client[Browser] -->|"localhost:8080"| Web[web: nginx] Web -->|"api:3000"| Api[api: node] Api -->|"db:5432"| Db[db: postgres] Db --- Vol[(db_data volume)]

βœ… Why this beats a pile of docker run commands

Everything above β€” build steps, ports, the database password, the startup order, the persistence β€” lives in one reviewable file. A teammate clones the repo and types docker compose up. No README full of "first run this, then that." That reproducibility is the whole point.

Essential Commands

Every command runs from the directory containing your compose.yaml. Here is the daily-driver set β€” memorize these five and you can run any Compose project.

# Build (if needed) and start everything in the foreground; Ctrl-C to stop
docker compose up

# Start in the background (detached) β€” the usual choice
docker compose up -d

# Rebuild images before starting (after changing a Dockerfile)
docker compose up -d --build

# See what's running, with status and published ports
docker compose ps

# Follow the combined logs; add a service name to narrow it down
docker compose logs -f
docker compose logs -f api

# Open a shell inside a running service
docker compose exec api sh
docker compose exec db psql -U app -d appdb

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

# …and also delete named volumes (DESTROYS your database data)
docker compose down -v

⚠️ down vs. down -v

Plain docker compose down keeps your named volumes, so your database data is safe. Adding -v deletes them too. Reach for -v only when you deliberately want a clean slate β€” it is the single fastest way to accidentally wipe local data.

The container lifecycle at a glance

Compose lifecycle from up to down up -d create & start ps / logs / exec observe & interact stop / start pause & resume down remove containers
Bring the stack up, observe it, and tear it down β€” the everyday loop.

Output of docker compose ps

NAME          IMAGE          SERVICE   STATUS         PORTS
app-web-1     nginx:alpine   web       Up 2 minutes   0.0.0.0:8080->80/tcp
app-api-1     app-api        api       Up 2 minutes   0.0.0.0:3000->3000/tcp
app-db-1      postgres:16    db        Up 2 minutes   5432/tcp

Environment & .env Files

Hard-coding ports and passwords into compose.yaml is fine for a throwaway demo and a bad habit everywhere else. Compose gives you two complementary tools: a project-level .env file for values it substitutes into the YAML, and the per-service env_file for variables passed into a container.

1. A .env file for substitution

Compose automatically reads a file named .env sitting next to compose.yaml and substitutes ${VARIABLE} references in the YAML:

# .env  (commit a .env.example, never the real secrets)
POSTGRES_PASSWORD=secret
API_PORT=3000
PG_VERSION=16
services:
  api:
    build: ./api
    ports:
      - "${API_PORT}:3000"
  db:
    image: postgres:${PG_VERSION}
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      # Provide a fallback if the variable is unset:
      LOG_LEVEL: ${LOG_LEVEL:-info}

2. env_file for values inside a container

When a service needs a whole batch of variables, point it at a file instead of listing each one:

services:
  api:
    build: ./api
    env_file:
      - ./api/.env.local

πŸ’‘ Two different jobs

The top-level .env file feeds Compose's own variable substitution (the ${...} in your YAML). A service's env_file feeds variables straight into that container's environment. They're often both used, and it's worth keeping the distinction clear. Always add .env to .gitignore and commit a redacted .env.example so teammates know which keys to set.

Practice & Quiz

πŸ‹οΈ Exercise 1: Your first two-service stack

Goal: Write a compose.yaml that runs Nginx serving a static folder plus a Postgres database with persistent storage. Nginx should be reachable at http://localhost:8080.

# compose.yaml β€” fill in the blanks
services:
  web:
    image: nginx:alpine
    # TODO: publish container port 80 on host 8080
    # TODO: bind mount ./site read-only into /usr/share/nginx/html

  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: secret
    # TODO: persist /var/lib/postgresql/data with a named volume

# TODO: declare the named volume
πŸ’‘ Hint

Ports go under a ports: list as "8080:80". A read-only bind mount ends in :ro. A named volume needs both a mount line in the service (db_data:/var/lib/postgresql/data) and a top-level volumes: entry naming db_data.

βœ… Solution
services:
  web:
    image: nginx:alpine
    ports:
      - "8080:80"
    volumes:
      - ./site:/usr/share/nginx/html:ro

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

volumes:
  db_data:

Run it with docker compose up -d, confirm with docker compose ps, and tear it down with docker compose down (your data stays because you did not pass -v).

πŸ‹οΈ Exercise 2: Wire the API to the database

Goal: Add an api service (built from ./api) that connects to the db service by name and only starts once the database is healthy.

βœ… Solution
  api:
    build: ./api
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://postgres:secret@db:5432/postgres
    depends_on:
      db:
        condition: service_healthy

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

The key detail: the host in DATABASE_URL is db β€” the service name β€” not an IP or localhost.

🎯 Quick Quiz

Question 1: In modern Docker, which command starts a Compose project?

Question 2: How does the api service reach the db service?

Question 3: Which command removes containers but keeps your database's named volume?

Best Practices & Pitfalls

βœ… Do

  • Name the file compose.yaml and use the docker compose (v2) CLI
  • Commit compose.yaml to git alongside your application code
  • Use named volumes for databases so data survives down and up
  • Keep secrets in a .gitignored .env; commit a redacted .env.example
  • Gate depends_on with condition: service_healthy for databases
  • Give services clear names β€” they double as DNS hostnames

❌ Don't

  • Add a top-level version: key β€” it's obsolete and triggers a warning
  • Use the hyphenated docker-compose for new work β€” it's the deprecated v1
  • Reach a service via localhost from another container β€” use the service name
  • Run down -v out of habit β€” it wipes your named volumes
  • Assume plain depends_on waits for a database to be ready β€” it only waits for the container to start

⚠️ The "connection refused on first run" trap

The most common Compose bug: the API boots faster than Postgres finishes initializing and crashes with ECONNREFUSED. The fix is the healthcheck-gated depends_on shown earlier, plus retry-with-backoff logic in your app's database client. Never rely on start order alone.

Summary

πŸŽ‰ Key Takeaways

  • Compose declares a whole stack in one compose.yaml and runs it with docker compose up
  • Modern Compose uses the space command docker compose and no version: key
  • Services find each other by service name over the automatic default network
  • Named volumes persist database data; bind mounts share host files for dev
  • Master up -d, down, logs, ps, exec β€” and know down -v deletes data
  • Gate depends_on with a healthcheck to avoid startup races

πŸ“š Additional Resources

πŸš€ What's Next?

You can now define and run a stack. Next we scale that skill up: designing genuinely multi-container applications β€” service-to-service communication, sensible architecture patterns, and a real e-commerce example that puts several services and databases to work together.

πŸŽ‰ Well done!

You've replaced a wall of docker run flags with one clean, version-controlled file. That's the foundation everything else this week builds on.