The Compose File Model
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.
compose.yaml hangs off three top-level keyscompose.yamlservices · networks · volumesweb · db · proxyA 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.
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.
- Publishing every service's port to the host out of habit — only
proxyneeds80/443exposed; mappingweb's8000anddb's5432to the host bypasses the proxy and exposes Postgres to anything that can reach the machine. - Writing
links:to connect services —linksis 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-levelvolumes: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.
- Publish ports only on the edge service that needs them —
proxyon80/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 (web→driftwood/web) andimage: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/networkskeys, 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
linksor hard-coded IPs, since the service name is the stable address that survives restarts.
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?
buildproduces the image from the Dockerfile andimagenames the tag the build result gets- Compose raises a validation error because one service cannot specify both
buildandimage - Compose ignores the
buildkey entirely and pullsdriftwood/webfrom 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/hostsfile on each start - The service name
dbis 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 ordb'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