example-projects/reference/docker/10_compose_multi_service.md

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, db and adminer. Compose will start each as a separate container.
  • No ports: on db. 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's ports: make its UI available at http://localhost:8080.
  • depends_on: - db tells Compose to start db before adminer. (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 pgdata persists Postgres's data across down/up cycles.

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).
  • .env files 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

  1. 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.
  2. Now down -v. Bring it back up. Confirm the row is gone. Feel that fear; remember it.
  3. Add a third service of your own — say, a python:3.12 container with command: ["sleep", "infinity"] so it just sits there. Run docker compose exec python_service bash to drop into it. Note that from inside that container, ping db (after apt-get install -y iputils-ping) works — the database is reachable by name.
  4. Look at ../../examples/image_meaning_db/docker-compose.yml and read it line by line. Almost every concept is one you've now seen.
  5. Move on to 11_cleanup_and_next_steps.md — disk reclamation, and where to go from here.