Skip to content

docker-compose.yml Explained Line by Line

A line-by-line walkthrough of a real docker-compose.yml file: services, image, ports, volumes, environment, depends_on, networks, and the top-level keys.

SDSysadmin Desk September 5, 2026 8 min read
Annotated docker-compose.yml file with callouts pointing at the services, ports, volumes, and environment keys

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.

Frequently asked questions

Is it docker-compose.yml or compose.yaml?

Both are valid. The Compose specification prefers compose.yaml, and the docker compose command looks for it first, then falls back to docker-compose.yml. The older name is still fully supported and is what you'll find in most existing projects. Pick one per project and keep it consistent.

Do I still need the version line at the top?

No. Compose v2 ignores the top-level version: key and warns that it's obsolete. Start the file directly with the services: block. The schema version is now derived from the keys you use, not from a version string.

Why does my YAML fail with a mapping or indentation error?

YAML is whitespace-sensitive. Use two spaces per level and never tabs. A single misaligned key or a tab character will break parsing. Run docker compose config to print the merged, validated file before you try to start anything.

What's the difference between image and build?

image pulls a prebuilt image from a registry like Docker Hub. build points at a local Dockerfile and builds the image yourself. Use image for off-the-shelf software and build for your own application code. A service can use build and tag the result with image.

How do services find each other inside Compose?

Compose puts every service on a shared network and registers each service name as a DNS hostname. A web service reaches a database service named db at the hostname db, on the container's internal port. You don't need IP addresses or links.

Sources & further reading

Official vendor documentation referenced while writing this guide.

SD

Sysadmin Desk

Infrastructure & Cloud

Hands-on guidance for infrastructure, virtualization, and containers — Hyper-V, VMware, Docker, and the day-to-day operations work that keeps environments running.

MCSA Guru provides independent, educational IT guidance. Microsoft, Windows, Windows Server, Microsoft 365, Exchange, and Microsoft Teams are trademarks of Microsoft Corporation; Docker is a trademark of Docker, Inc. MCSA Guru is not affiliated with or endorsed by Microsoft or Docker. Always test changes in a safe environment before applying them in production.

Related guides

Fixing something right now?

Jump straight into the guide library or search for the exact error or task you are dealing with.