5.5 KiB
Lesson 10: A Multi-Service Stack
This is where Docker stops being "a fancier way to run a single program" and starts being "I can describe a whole system on one laptop." We'll build a tiny web app that talks to a real database. Two containers, one file, one command.
What we're building
db— a Postgres database, using the official image, with persistent storage.adminer— a small web-based UI that connects to the database, so we can poke at it without installing any database tools on the host.
This is a deliberately small example. But the pattern — one or more services, plus a database, plus some glue — covers a huge fraction of what people actually deploy.
The file
In a fresh folder, create docker-compose.yml:
services:
db:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_USER: workshop
POSTGRES_PASSWORD: secret
POSTGRES_DB: projects
volumes:
- pgdata:/var/lib/postgresql/data
adminer:
image: adminer:5
restart: unless-stopped
ports:
- "8080:8080"
depends_on:
- db
volumes:
pgdata:
Read it carefully. A few things worth noticing:
- Two services,
dbandadminer. Compose will start each as a separate container. - No
ports:ondb. The database is not exposed to your host. It doesn't need to be — only the other container needs to reach it, and they can reach each other on the internal network. adminer'sports:make its UI available athttp://localhost:8080.depends_on: - dbtells Compose to startdbbeforeadminer. (It doesn't wait for Postgres to be ready — just for the container to exist. For real production you'd add a healthcheck. For a workshop, this is fine.)- A named volume
pgdatapersists Postgres's data acrossdown/upcycles.
Bring it up
docker compose up -d
Then visit http://localhost:8080. You'll see Adminer's login screen. Fill in:
- System: PostgreSQL
- Server:
db← this is the key part: services reach each other by service name - Username:
workshop - Password:
secret - Database:
projects
You should land in a (mostly empty) Postgres database. Create a table, insert a row, browse it — whatever you like. You're using a database server you didn't install, through a UI you didn't install, both running on your laptop in containers.
The thing that's quietly amazing
In the Adminer login, you typed db as the server. Not an IP address, not localhost, not 127.0.0.1. Just the service name. Compose set up a private network for this stack and made every service reachable by its name.
This is the same mechanism that lets a "backend" container reach a "database" container with a URL like postgres://user:pass@db:5432/projects. The hostname is just the service's name in the compose file. Rename the service, the hostname changes with it.
Persistence in action
Stop the stack:
docker compose down
Both containers are gone. Bring it back up:
docker compose up -d
Log back into Adminer. Your table and row are still there — because the pgdata volume survived the down.
Now (carefully) try the destructive form:
docker compose down -v
docker compose up -d
Log in again. Empty database. The -v flag deleted the named volumes. This is the one Compose flag to fear — it's how you nuke your data, and it looks like every other innocent flag.
A taste of what scales from here
What you just did was a two-container stack. The same compose file format, with the same services: / volumes: / depends_on: / ports: structure, can describe ten services. Twenty. A frontend, a backend, a worker queue, a Redis cache, a search index, a model server. All in one file.
Some real-world patterns you'll see in compose files:
build:for your own services,image:for stock components. Your code is in a Dockerfile; the database and cache come from the registry.- Healthchecks so dependent services wait for upstreams to be ready (not just running).
- Profiles so you can opt into extra services (
docker compose --profile gpu up). .envfiles that Compose reads automatically — you reference${POSTGRES_PASSWORD}in the YAML and it gets substituted from.env.
When your project grows past what fits comfortably in Compose — usually because you want to run across multiple machines, or want managed health/restart/scaling — the natural next step is Kubernetes. Critically: the containers don't change. The compose file becomes a different deployment file. Your backend:1.0 image is still your backend:1.0 image.
Try it yourself
- Bring up the stack above, log into Adminer, create a table with two columns, insert a row, log out,
down,up, log back in. Confirm the row survived. - Now
down -v. Bring it back up. Confirm the row is gone. Feel that fear; remember it. - Add a third service of your own — say, a
python:3.12container withcommand: ["sleep", "infinity"]so it just sits there. Rundocker compose exec python_service bashto drop into it. Note that from inside that container,ping db(afterapt-get install -y iputils-ping) works — the database is reachable by name. - Look at
../../examples/image_meaning_db/docker-compose.ymland read it line by line. Almost every concept is one you've now seen. - Move on to
11_cleanup_and_next_steps.md— disk reclamation, and where to go from here.