Skip to content

08 - Docker Compose

What this session is

About 45 minutes. You'll learn Docker Compose - declarative YAML that describes a multi-container app (services, networks, volumes), so you stop typing 50-line docker run invocations.

The problem Compose solves

By page 07 you could run a multi-container app, but the commands were:

docker network create app-net
docker volume create pgdata
docker run -d --name db --network app-net -v pgdata:/var/lib/postgresql/data \
  -e POSTGRES_PASSWORD=secret postgres:16
docker run -d --name web --network app-net -p 8080:8080 \
  -e DATABASE_URL=postgres://postgres:secret@db:5432/postgres my-app:1.0

Hard to reproduce. Hard to share. Hard to remember.

Compose makes it:

# compose.yaml
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: secret
    volumes:
      - pgdata:/var/lib/postgresql/data

  web:
    image: my-app:1.0
    ports:
      - "8080:8080"
    environment:
      DATABASE_URL: postgres://postgres:secret@db:5432/postgres
    depends_on:
      - db

volumes:
  pgdata:

Then:

docker compose up -d

That parses the YAML, creates the network, the volume, both containers, links them. One command.

Modern vs legacy

Two CLIs you'll encounter:

  • docker compose (Compose V2, no hyphen) - built into Docker Desktop and modern Docker Engine. The current standard.
  • docker-compose (Compose V1, with hyphen) - legacy Python tool. Deprecated. Still works on older systems.

Use docker compose. The YAML format is the same; only the CLI invocation differs.

File naming

Compose looks for these files automatically in the current directory:

  • compose.yaml (preferred)
  • compose.yml
  • docker-compose.yaml
  • docker-compose.yml

For overrides: compose.override.yaml is merged on top of compose.yaml automatically. Useful for dev-vs-prod variants.

A real-world compose.yaml

services:
  web:
    build: .                      # build from a Dockerfile in this directory
    ports:
      - "8080:8080"
    environment:
      DATABASE_URL: postgres://postgres:secret@db:5432/postgres
      LOG_LEVEL: ${LOG_LEVEL:-info}    # from env, default "info"
    volumes:
      - ./src:/app/src:ro         # bind mount for dev
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 30s
      timeout: 3s
      retries: 3
    restart: unless-stopped

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

  cache:
    image: redis:7-alpine
    ports:
      - "127.0.0.1:6379:6379"     # only localhost; not external

volumes:
  pgdata:

What's new:

  • services: - each entry is a container.
  • build: . - build the image from a Dockerfile in this directory (instead of image:).
  • ports, environment, volumes - same concepts as docker run.
  • depends_on with condition: service_healthy - wait for db to be healthy before starting web.
  • healthcheck - Docker periodically runs this command; the container is marked healthy or unhealthy.
  • restart: unless-stopped - same as docker run --restart=unless-stopped.
  • ${LOG_LEVEL:-info} - read LOG_LEVEL from your shell env; default to info.

Common commands

docker compose up                    # foreground, follow logs (Ctrl-C to stop)
docker compose up -d                 # detached
docker compose down                  # stop and remove containers
docker compose down -v               # also remove named volumes (lose data!)
docker compose ps                    # list services
docker compose logs                  # all logs
docker compose logs -f web           # follow web's logs
docker compose restart web           # restart one service
docker compose build                 # build (or rebuild) images
docker compose build --no-cache web  # rebuild without cache
docker compose pull                  # pull latest images
docker compose exec web bash         # shell into the web container
docker compose run --rm web bash     # run a new throwaway container

up -d and down are the two you'll use most.

Networking in Compose

Compose automatically creates a network per project (named after the project directory). All services on that network. They reach each other by service name (the key in services:).

So in the example above, web reaches the database at db:5432. No docker network create needed.

You can also define explicit networks if you want isolation:

services:
  web:
    networks:
      - frontend
      - backend
  db:
    networks:
      - backend
  cache:
    networks:
      - backend

networks:
  frontend:
  backend:

web can talk to both db and cache. The frontend network might also be reached by a reverse proxy. db and cache are isolated from the frontend.

Environment files

Hardcoding POSTGRES_PASSWORD: secret in YAML is bad practice. Use a .env file:

# .env
POSTGRES_PASSWORD=secret
LOG_LEVEL=debug

Compose loads .env automatically. Reference variables in YAML with ${VAR} or ${VAR:-default}.

Add .env to .gitignore. Commit a .env.example template with placeholder values.

Profiles

For services you only want to run sometimes (debug containers, ops tools):

services:
  web: ...
  db: ...

  pgadmin:
    image: dpage/pgadmin4
    profiles: [debug]
    ports: ["5050:80"]

docker compose up starts only web and db. docker compose --profile debug up also starts pgadmin.

A typical dev workflow

# First time:
docker compose up -d
docker compose logs -f       # watch startup
# Stop following with Ctrl-C; containers keep running

# During development (you change code):
# If using bind mount: containers see changes immediately
# If you changed the Dockerfile or dependencies:
docker compose build web
docker compose up -d web     # recreate just web

# Exec into a container:
docker compose exec web bash

# Done for the day:
docker compose down

# Forget everything (including volumes - careful):
docker compose down -v

Real-world examples to read

Many OSS projects ship a compose.yaml so you can run them locally with one command. Some good ones to look at:

  • Plausible Analytics (analytics - plausible/community-edition)
  • Sentry (error tracking - getsentry/onpremise)
  • Mastodon (social network)
  • Nextcloud

Reading their compose.yaml teaches you patterns for production-shape multi-service setups.

Exercise

  1. Create a compose.yaml for a simple web + database stack:

    # ~/practice/compose-test/compose.yaml
    services:
      db:
        image: postgres:16
        environment:
          POSTGRES_PASSWORD: secret
        volumes:
          - pgdata:/var/lib/postgresql/data
    
      adminer:
        image: adminer
        ports:
          - "8081:8080"
        depends_on:
          - db
    
    volumes:
      pgdata:
    

  2. Run:

    cd ~/practice/compose-test
    docker compose up -d
    docker compose ps
    

  3. Open http://localhost:8081 in your browser. You should see Adminer's UI. Connect: server=db, user=postgres, password=secret. You can browse the (mostly empty) Postgres database.

  4. Shell into a service:

    docker compose exec db psql -U postgres
    
    Type \dt (list tables - empty), \q to quit.

  5. Stop:

    docker compose down
    
    (Volume pgdata survives. docker compose down -v would delete it too.)

  6. Edit the compose.yaml - change the Adminer port to 8082. docker compose up -d again. Adminer should be reachable at the new port; Postgres unchanged (Compose recreates only what changed).

What you might wonder

"What's a Compose 'project'?" The collection of services defined in a single compose file. Project name defaults to the parent directory's name. docker compose -p myproj up overrides.

"Should I use Compose in production?" For small deployments on a single host, sure. For anything beyond ~5 services or that needs scaling/HA, look at Kubernetes (separate path).

"How do I run my own images instead of pulling?" build: . (Dockerfile in current dir) or build: ./path/to/Dockerfile. Compose builds and uses the resulting image. Combined with image tagging: build: . + image: myapp:dev builds AND tags.

"What's extends? x-... ?" x-name: defines reusable YAML anchors (custom keys starting with x- are ignored by Compose but available for YAML's anchor/alias feature). extends: lets one service inherit from another. Both for DRYing up large compose files. Recognize when you see them.

Done

  • Write a compose.yaml defining services, networks, volumes.
  • Use up, down, logs, exec, ps, build, pull.
  • Use service names for container-to-container DNS.
  • Use environment files for secrets.
  • Use health checks and depends_on for ordering.

Next: Slimming images →

Comments