Most people learn Docker Compose by copying a file that works and changing a few values until it breaks. That gets you running, but it leaves gaps. You hit an error, you don’t know which line caused it, and you’re back to guessing.
This walkthrough goes the other way. We’ll take one realistic docker-compose.yml and read every line, top to bottom, so you know what each key does and why it’s there. By the end you’ll be able to look at any Compose file and explain it without squinting.
The example is a small web app: an Nginx front end and a PostgreSQL database, with a named volume for the data and a custom network connecting them. It’s the same shape you’ll see in real projects, just trimmed down.
The file we’re reading
Here’s the whole thing. We’ll break it apart piece by piece after this.
services:
web:
image: nginx:1.27
container_name: app-web
restart: unless-stopped
ports:
- "8080:80"
volumes:
- ./site:/usr/share/nginx/html:ro
depends_on:
- db
networks:
- app-net
db:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: change-me
POSTGRES_DB: appdb
volumes:
- db-data:/var/lib/postgresql/data
networks:
- app-net
volumes:
db-data:
networks:
app-net:
driver: bridge
That’s a complete, valid file. Save it, run docker compose up -d, and you get a running web server and database that can talk to each other. Now let’s understand why.
Top-level keys: the skeleton
A Compose file has a handful of top-level keys — keys with no indentation. This file uses three:
services:— the containers you want to run. This is the only key you truly need.volumes:— named storage that Docker manages, so data survives a container rebuild.networks:— the networks your services attach to.
There’s no version: line, and that’s correct for Compose v2. Old guides start every file with version: "3.8". The current tooling ignores that field and may print an “obsolete” warning, so leave it out.
The web service, line by line
web:
image: nginx:1.27
container_name: app-web
restart: unless-stopped
web: is the service name. You choose it. It becomes the container’s hostname on the network and the name you use in commands like docker compose logs web.
image: nginx:1.27 tells Compose which image to run and pins it to tag 1.27. Pinning a real version matters. If you write nginx:latest, a rebuild months from now may pull a newer major version that behaves differently. A specific tag keeps the stack predictable.
container_name: app-web sets a fixed name for the container. Without it, Compose generates one from the project and service name, like myproject-web-1. A fixed name is easier to type, but it stops you from scaling that service to more than one replica, so use it only when you need a stable name.
restart: unless-stopped is the restart policy. It tells Docker to bring the container back after a crash or a host reboot, but to leave it stopped if you stopped it on purpose. This is the policy most long-running services want.
ports:
- "8080:80"
ports: publishes a container port to the host. The format is "HOST:CONTAINER". Here, port 80 inside the Nginx container is reachable as 8080 on your machine, so you browse to http://localhost:8080. The left number is yours to pick; the right number is whatever the app listens on inside the container.
volumes:
- ./site:/usr/share/nginx/html:ro
This mounts a host folder into the container — a bind mount. The local ./site directory (relative to the Compose file) appears inside the container at /usr/share/nginx/html, which is where Nginx serves files from. The :ro suffix makes it read-only, so the container can read your HTML but can’t modify it. Drop :ro if the container needs to write back.
Bind mounts and named volumes behave differently, and mixing them up causes real confusion. If you’re unsure which to reach for, the bind mount vs named volume guide lays out the trade-offs.
depends_on:
- db
depends_on controls start order. Compose starts db before web. That’s the limit of what it does by default — it waits for the database container to start, not for PostgreSQL to be ready to accept connections. Those are different moments. For real readiness, add a healthcheck to the db service and use the long form of depends_on with condition: service_healthy.
networks:
- app-net
This attaches the service to the app-net network defined at the bottom of the file. Both services join the same network, which is how they reach each other.
The db service, line by line
db:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: change-me
POSTGRES_DB: appdb
environment: sets environment variables inside the container. The official Postgres image reads POSTGRES_USER, POSTGRES_PASSWORD, and POSTGRES_DB on first start to create a user and an initial database. These names aren’t arbitrary — they come from the image’s documentation, so always check the image page on Docker Hub for the variables it expects.
volumes:
- db-data:/var/lib/postgresql/data
This is a named volume, not a bind mount — notice there’s no ./ or / at the start, just a name. db-data is declared in the top-level volumes: block, and Docker manages where it actually lives on disk. PostgreSQL stores its database files at /var/lib/postgresql/data, so this line is what keeps your data alive across docker compose down and up cycles.
The bottom blocks: volumes and networks
volumes:
db-data:
networks:
app-net:
driver: bridge
The top-level volumes: block declares named volumes so Docker creates and tracks them. db-data: with nothing after it means “use the defaults,” which is what you want most of the time. A service can only mount a named volume that’s declared here.
The networks: block defines app-net and sets its driver: bridge. Bridge is the standard driver for containers on a single host. If you left the networks block out entirely, Compose would still create a default network and put both services on it — so this explicit definition is optional here, but it’s good practice once you have several services and want clear boundaries. The Docker networking guide for beginners covers the driver types in more depth.
Here’s the quick reference for every key in the file:
Every key in this file, explained
| services | Top-level. The containers to run. The one required block. |
|---|---|
| image | Which image to pull, with a pinned tag like postgres:16. |
| container_name | Fixed container name. Blocks scaling; use only when you need a stable name. |
| restart | Restart policy. unless-stopped survives reboots but respects manual stops. |
| ports | Publish a port as "HOST:CONTAINER". Host port on the left. |
| volumes (service) | Bind mount (./path) or named volume (name) into the container. |
| depends_on | Start order only. Doesn't wait for the app to be ready without a healthcheck. |
| environment | Env vars inside the container, e.g. database credentials. |
| networks (service) | Which networks the service attaches to. |
| volumes (top-level) | Declares named volumes so Docker manages them. |
| networks (top-level) | Defines networks and their driver, e.g. bridge. |
Indentation: the thing that actually breaks files
YAML uses indentation to express structure, so spacing is not cosmetic. Two rules keep you out of trouble:
- Use two spaces per level of nesting.
- Never use tabs. A tab where a space belongs throws a parser error that’s hard to spot by eye.
A list item starts with - (dash space). That’s why ports and volumes entries have a dash but environment entries don’t — ports and volumes are lists, while environment here is a mapping of key-value pairs.
Sanity-check a Compose file
- Two-space indentation throughout, no tabs anywhere
- Image tags are pinned to a real version, not 'latest'
- Host ports on the left of the colon, container ports on the right
- Named volumes used by a service are declared in the top-level volumes: block
- Secrets live in .env, not hard-coded in the file
- Run 'docker compose config' and confirm it prints without errors
Reading any Compose file from here
The file you just read covers the keys you’ll meet in the large majority of projects. When you open someone else’s docker-compose.yml, work the same way: find the top-level keys first to see the skeleton, then read each service as a unit — image, ports, volumes, environment, dependencies. The structure repeats, so once you can read this one, bigger files are just more of the same.
If you’re newer to Compose overall, the Docker Compose beginner guide walks through the everyday up, down, and logs commands. From there, healthchecks and .env files are the two next steps that make the biggest difference.
For more container walkthroughs and fixes, browse the rest of the Docker library below.