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:
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.ymldocker-compose.yamldocker-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 ofimage:).ports,environment,volumes- same concepts asdocker run.depends_onwithcondition: service_healthy- wait fordbto be healthy before startingweb.healthcheck- Docker periodically runs this command; the container is marked healthy or unhealthy.restart: unless-stopped- same asdocker run --restart=unless-stopped.${LOG_LEVEL:-info}- readLOG_LEVELfrom your shell env; default toinfo.
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:
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):
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¶
-
Create a compose.yaml for a simple web + database stack:
-
Run:
-
Open
http://localhost:8081in your browser. You should see Adminer's UI. Connect: server=db, user=postgres, password=secret. You can browse the (mostly empty) Postgres database. -
Shell into a service:
Type\dt(list tables - empty),\qto quit. -
Stop:
(Volumepgdatasurvives.docker compose down -vwould delete it too.) -
Edit the compose.yaml - change the Adminer port to 8082.
docker compose up -dagain. 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.yamldefining 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_onfor ordering.