Chapter 8: Docker Compose
Topic 47

The Compose File Model

SchemaCompose

A compose.yaml file has three top-level maps that matter: services, networks, and volumes. services is where the work is — each service is one container (or scaled copies of one) with its image or build, ports, environment, mounts, and dependencies. networks and volumes declare the named infrastructure the services attach to, mirroring exactly the docker network create and docker volume create you did by hand in Chapters 6 and 7.

Once you can read those three keys, a Compose file stops being magic and becomes a flat description of the stack: what containers exist, what networks they share, what volumes hold their data. Everything in the file hangs off one of the three.

The Three Top-Level Keys

services defines the containers, networks defines the user-defined networks, and volumes defines the named volumes. A minimal stack needs only services, because Compose supplies a default network and any anonymous volumes a service mounts. You add the top-level networks and volumes keys when you want named, declared infrastructure — a network you reference by name, a volume whose data must outlive the container.

Driftwood uses all three: services for web, db, and proxy; networks to declare driftwood-net; volumes to declare driftwood-db-data. The named network and volume are the two pieces of Chapter 7 and Chapter 6 plumbing, now written down instead of typed each time.

How a compose.yaml hangs off three top-level keys
compose.yaml
the root document
services · networks · volumes
the three top-level keys
web · db · proxy
the services, one container each

A Service, Field by Field

Under each service name sits a small set of fields you already know as docker run flags. image or build says where the image comes from — pull a published one or build a local Dockerfile. ports publishes host:container. environment sets container env vars. volumes mounts named volumes or bind mounts. depends_on orders startup. networks picks which networks the service joins. The service block is the docker run line, restructured as YAML and kept in a file.

The Default Network and Name Resolution

Compose auto-creates one network for the project and attaches every service to it, so web reaches db at the hostname db with no manual wiring. This is the embedded-DNS service discovery from Chapter 7 topic 41, now free — Compose registers each service's name as a DNS entry on the project network. You address a service by its name in the file, and the name resolves to whatever IP the container currently holds, so restarts and replacements never break a connection string.

image vs build

image: postgres:16 pulls a published image — Driftwood's db and proxy both use upstream images this way. build: ./web runs the Dockerfile in that directory and tags the result, which is how Driftwood's own first-party web service gets built. A service can carry both: build produces the image, and image names the tag it gets, so build: ./web with image: driftwood/web builds the code and stamps it as driftwood/web in one step.

Driftwood as One File

Here is the whole Driftwood stack in one file. web builds ./web, tags it driftwood/web, and publishes nothing to the host — gunicorn on :8000 stays internal, reachable only by proxy. db runs postgres:16 with driftwood-db-data mounted at the Postgres data directory. proxy runs nginx and is the only service that publishes to the host, on 80 and 443. All three sit on driftwood-net, declared once under the top-level networks key.

compose.yaml — the full Driftwood stack in one file
services:
  web:
    build: ./web
    image: driftwood/web
    environment:
      DATABASE_URL: postgres://driftwood:secret@db:5432/driftwood
    depends_on:
      - db
    networks:
      - driftwood-net

  db:
    image: postgres:16
    environment:
      POSTGRES_USER: driftwood
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: driftwood
    volumes:
      - driftwood-db-data:/var/lib/postgresql/data
    networks:
      - driftwood-net

  proxy:
    image: nginx:1.27-alpine
    ports:
      - "80:80"
      - "443:443"
    depends_on:
      - web
    networks:
      - driftwood-net

networks:
  driftwood-net:

volumes:
  driftwood-db-data:

Read top to bottom, the file is the stack: three services, one network they all share, one volume holding the database. The web service reaches the database at db:5432 in its DATABASE_URL because db is a resolvable name on driftwood-net — no IP, no --link, no manual network create. The next topic fixes the one thing this file gets wrong: it orders startup but does not wait for Postgres to be ready.

Common Mistakes
  • Publishing every service's port to the host out of habit — only proxy needs 80/443 exposed; mapping web's 8000 and db's 5432 to the host bypasses the proxy and exposes Postgres to anything that can reach the machine.
  • Writing links: to connect services — links is legacy and unnecessary; the default network already gives name-based resolution, and reaching for it signals a mental model one version behind.
  • Declaring a named volume under a service's volumes: but forgetting to list it under the top-level volumes: key — Compose errors out, because a named volume must be declared at the top level before a service can mount it.
  • Setting container_name: on services in a stack you might scale or run twice — fixed container names collide and block a second project or a scaled replica; let Compose name the containers.
Best Practices
  • Publish ports only on the edge service that needs them — proxy on 80/443 — and let internal services talk over the default network, so the host's exposed surface is exactly the front door.
  • Use build: for first-party images (webdriftwood/web) and image: for upstream ones (db, proxy), keeping the build of Driftwood's own code in the same file that runs it.
  • Declare every named volume and non-default network under the top-level volumes/networks keys, then reference them from services, so the file's infrastructure is visible in one place.
  • Rely on the default network's name resolution for service-to-service traffic instead of links or hard-coded IPs, since the service name is the stable address that survives restarts.
Comparable tools A docker run script encodes the same fields as flags without the structure Podman podman-compose reads the same schema; Quadlet maps it to systemd unit keys Kubernetes a Deployment + Service + PersistentVolumeClaim decompose one Compose service

Knowledge Check

What do the three top-level keys services, networks, and volumes each declare?

  • The containers, the user-defined networks they attach to, and the named volumes that hold their data
  • The multi-stage build stages, the ports each service exposes, and the secrets, respectively
  • The shared environment variables, the images each service uses, and the host bind mounts
  • The per-container healthchecks, the on-failure restart policies, and the CPU and memory resource limits

A Driftwood service has both build: ./web and image: driftwood/web. What happens?

  • build produces the image from the Dockerfile and image names the tag the build result gets
  • Compose raises a validation error because one service cannot specify both build and image
  • Compose ignores the build key entirely and pulls driftwood/web from a remote registry instead
  • Compose builds one image locally and pulls a second from the registry, running both side by side

Why can web reach the database at the hostname db without any IP or link configuration?

  • Compose attaches both services to the project network and registers each service name in the embedded DNS
  • Compose writes every service's container IP into the host machine's /etc/hosts file on each start
  • The service name db is registered with public DNS so that any machine anywhere can resolve it
  • A links: entry on the service is required and Compose silently adds it for you behind the scenes

Why should only proxy publish ports to the host in the Driftwood stack?

  • Internal services reach each other over the project network, so publishing web's or db's port only adds surface
  • Without a published port the internal services on the project network would be unable to communicate at all
  • Publishing more host ports slows down the image build step, so declaring fewer of them builds faster
  • Compose refuses to start any service that does not publish at least one port to the host machine

You got correct