Lesson: Multi-Service Apps with Compose
What you'll learn
- Explain what Docker Compose is and why a YAML file beats a pile of
docker runcommands. - Read and write a
compose.yamlwith services, networks, volumes, and environment. - Use
up,down,logs, andpsto run a whole stack with one command. - Control start order and readiness with depends_on (and its limits).
By the end you can define a multi-container application as code and bring it up or tear it down reproducibly — the everyday workflow for local dev and small deployments.
The lesson
1. Why Compose exists
A real app is rarely one container. A web API needs a database; maybe a cache; maybe a worker. Starting each by hand — remembering every -p, every -e, every --network — is error-prone and impossible to share. Docker Compose lets you describe the whole stack in a single declarative file, compose.yaml, and manage it with one command. It is infrastructure as code for your local container stack: commit the file to git and a teammate gets the identical setup.
Compose builds directly on what you already know — it just creates the containers, the user-defined network (Chapter 3), and the volumes for you.
2. The shape of a compose.yaml
A minimal two-service stack — a web API and a Postgres database:
# compose.yaml
services:
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: <REDACTED>
POSTGRES_DB: appdb
volumes:
- db-data:/var/lib/postgresql/data
networks:
- appnet
api:
image: 10.100.100.6/myorg/api:sha-abc123 # from the lab registry
ports:
- "8080:8080"
environment:
DATABASE_URL: postgres://app:<REDACTED>@db:5432/appdb
depends_on:
- db
networks:
- appnet
networks:
appnet:
volumes:
db-data:
Note @db:5432 in the URL: Compose puts both services on the appnet network, so the API reaches the database by its service name db (the DNS trick from Chapter 3).
3. The four building blocks
services — each entry is one container role. Common keys:
image:use a prebuilt image, orbuild: ./dirto build from a local Dockerfile.ports:publish to the host, sameHOST:CONTAINERsyntax as-p.environment:set env vars (or load a file withenv_file:).restart: unless-stoppedto auto-restart on crash/reboot.
networks — declare named networks; list them under each service. If you omit networks entirely, Compose still creates one default network and joins every service to it — so name-based discovery works out of the box.
volumes — named volumes persist data beyond a container's life. Containers are ephemeral (Chapter 1); a volume is where databases keep data that must survive a restart. Two forms:
volumes:
- db-data:/var/lib/postgresql/data # named volume (managed by Docker)
- ./config:/etc/app:ro # bind mount: host folder -> container (read-only)
environment — configuration without rebuilding the image. Keep secrets out of git; in real setups use an .env file or Docker secrets rather than inline passwords.
4. The everyday commands
Run all of these from the folder containing compose.yaml:
docker compose up -d # create & start the whole stack in background
docker compose ps # what's running in this stack
docker compose logs -f # tail logs from ALL services
docker compose logs -f api # tail just one service
docker compose exec api sh # shell into a running service
docker compose down # stop & remove containers + the network
docker compose down -v # ALSO delete named volumes (destroys data!)
docker compose pull # fetch newer images
docker compose up -d --build # rebuild images that use `build:` then start
Mind the difference: down keeps your named volumes (data safe); down -v wipes them. The -d flag means detached (background), exactly as with docker run.
docker compose up -d
+---------------------------------------+
| network: appnet (auto-created) |
| [api]:8080 --DNS--> [db]:5432 |
| | | |
| -p 8080 volume db-data
+---------------------------------------+
5. Controlling start order: depends_on
depends_on makes Compose start db before api. But beware: by default it only waits for the container to start, not for the database to be ready to accept connections. A database process can take a few seconds to initialise after its container starts.
To wait for genuine readiness, combine depends_on with a healthcheck and the condition: service_healthy:
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: <REDACTED>
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
api:
image: 10.100.100.6/myorg/api:sha-abc123
depends_on:
db:
condition: service_healthy # wait until db reports healthy
Even with this, well-written apps should still retry their database connection on startup — networks and dependencies are never guaranteed instantly. depends_on is a convenience, not a hard guarantee.
6. File naming and tips
- The modern filename is
compose.yaml(older guides saydocker-compose.yml; both still work). Usecompose.yaml. - Pin image tags (e.g.
postgres:16, or a sha tag from the lab registry) — never rely onlatest, which silently changes. - One project per folder; Compose names everything after the folder, so two folders give two independent stacks.
docker compose configprints the fully-resolved file — great for catching YAML/indentation mistakes before you run.
7. Putting it together
The workflow you will use constantly: edit compose.yaml -> docker compose up -d -> check docker compose ps and docker compose logs -f -> make changes -> docker compose up -d again (Compose recreates only what changed) -> docker compose down when finished. That loop is the heart of local container development, and it scales straight into Assignment 2.
Dig deeper
- Docker Compose overview
- Compose file reference (services, networks, volumes)
depends_onand service startup order- Compose
compose.yamlquickstart - Volumes in Docker
Search terms
docker compose yaml tutorial beginnerdocker compose services networks volumes explaineddocker compose up down logs commandsdocker compose depends_on service_healthy healthcheckdocker compose named volume vs bind mountdocker compose environment env_file
Check yourself
- What problem does Compose solve compared with running many
docker runcommands? - In the example, how does the
apiservice reach the database, and why does the namedbwork? - What is the difference between
docker compose downanddocker compose down -v? - Why is plain
depends_onnot enough to guarantee the database is ready, and what makes it wait for real readiness? - What is the difference between a named volume and a bind mount, and when would you use each?
No comments to display
No comments to display