Docker Compose Demystified: A Full Stack in a Single File

From scattered containers to one tidy blueprint

If you have followed any of our self-hosting guides, you will have noticed the same quiet hero turning up again and again: a file called compose.yaml. That is no accident. Docker Compose is the tool that turns a sprawling mess of container commands into one readable blueprint you can start, stop, and share. Understanding it properly will make every other containerised project on this blog click into place. This guide explains the problem Compose solves, walks through the anatomy of a Compose file, builds a real three-service stack, and covers the everyday commands you will actually use.

You can absolutely run containers by hand. The trouble is what the command looks like once a container does anything useful. A single database container with persistent storage, a custom network, environment variables, a restart policy, and a published port becomes a sprawling one-liner:

docker run -d --name db --restart unless-stopped \
  -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=app \
  -v db_data:/var/lib/postgresql/data \
  --network appnet -p 5432:5432 postgres:16

That is one container. A real application might have three or four, each with its own equally unwieldy command, and each needing to be started in the right order with matching network names. Now imagine reproducing that on another machine, six months later, from memory. The configuration lives only in your shell history and your head, which is to say it lives nowhere reliable.

Compose fixes this by moving every one of those flags into a declarative file. Instead of remembering commands, you describe the desired state once and let Compose make it so.

Docker Compose is a tool for defining and running multi-container applications. You write a YAML file describing your services, and Compose creates the networks, volumes, and containers to match. It ships as a plugin to the Docker CLI, invoked as docker compose (two words; the old standalone docker-compose hyphenated binary is legacy).

The mental shift is from imperative to declarative. With docker run you issue commands that do things. With Compose you describe what should exist, run one command, and Compose figures out what to create, update, or leave alone. The file becomes the single source of truth: commit it to version control and anyone can reproduce your stack byte for byte.

A Compose file is a YAML document with a handful of top-level keys. The most important is services, under which each container is defined. The common building blocks are:

  • image — the container image to run, such as nginx:alpine.
  • ports — published ports, in host:container form, e.g. "8080:80".
  • volumes — persistent storage or bind mounts, mapping a named volume or host path into the container.
  • environment — environment variables passed into the container.
  • networks — which networks the service joins, so containers can find each other by name.
  • depends_on — declares that one service should start after another.
  • restart — the restart policy, commonly unless-stopped.

Two more top-level keys, volumes and networks, declare the named volumes and networks your services reference. That is genuinely most of it. The vocabulary is small, which is exactly why Compose is approachable.

Theory is thin gruel, so let us build something real: a web application, a PostgreSQL database behind it, and an Nginx reverse proxy in front. Create compose.yaml:

services:
  proxy:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - web
    restart: unless-stopped

  web:
    image: ghcr.io/example/webapp:latest
    environment:
      DATABASE_URL: "postgres://app:${DB_PASSWORD}@db:5432/app"
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_DB: app
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      timeout: 5s
      retries: 5
    restart: unless-stopped

volumes:
  db_data:

Note what is happening. The web service reaches the database at the hostname db, because Compose puts all services on a shared default network where each is reachable by its service name. The proxy depends on the web app, and the web app waits for the database to report healthy before it starts. No manual ordering, no remembered network names.

With the file written, the everyday commands are few. To build and start everything in the background:

docker compose up -d

The -d runs it detached. Compose creates the network, the named volume, and all three containers in dependency order. To see what is running:

docker compose ps

To follow logs, optionally for a single service:

docker compose logs -f web

To open a shell inside a running container, perhaps to inspect the database:

docker compose exec db psql -U app

And to tear it all down:

docker compose down

That stops and removes the containers and network but, importantly, leaves your named volumes intact. Add -v only if you genuinely want to delete the data too, which is a decision you should make deliberately and never by accident.

Containers are ephemeral by design: delete one and anything written inside it vanishes. That is fine for stateless web servers and catastrophic for databases. Volumes are how you keep data alive across the container’s lifecycle.

In the example, db_data is a named volume declared at the bottom of the file and mounted into Postgres’s data directory. Because Docker manages its lifecycle separately from the container, you can recreate, upgrade, or restart the database container and the data persists. You can list and inspect volumes directly:

docker volume ls
docker compose down && docker compose up -d   # data survives

The alternative is a bind mount, mapping a host directory like ./config:/etc/app, which is ideal for configuration files you want to edit on the host. The rule of thumb: named volumes for data the container owns, bind mounts for files you want to read and edit yourself.

Three features turn a tidy Compose file into a properly maintainable one.

Environment files. Notice ${DB_PASSWORD} in the example. Compose automatically reads a file called .env in the same directory and substitutes those variables, keeping secrets and machine-specific values out of the committed YAML:

# .env
DB_PASSWORD=a-long-random-password

Add .env to your .gitignore and your passwords stay out of version control while the structure stays shareable.

Healthchecks. The database service defines a healthcheck that runs pg_isready until the database genuinely accepts connections. This is what makes condition: service_healthy meaningful: the web app does not merely wait for the database container to exist, it waits for the database to actually be ready, which eliminates a whole genre of start-up race conditions.

Profiles. Sometimes you want optional services, like a debug tool or a one-off migration runner, that should not start by default. Tag them with a profile:

  backup:
    image: example/backup
    profiles: ["tools"]

A plain docker compose up -d ignores it; docker compose --profile tools up -d includes it. One file, several modes.

Put together, your daily rhythm with Compose is pleasingly dull, which is the highest praise infrastructure can earn. You edit the compose.yaml, run docker compose up -d, and Compose reconciles reality with your description, recreating only the services that changed. You check docker compose logs -f when something misbehaves, drop into docker compose exec when you need a poke around, and docker compose pull followed by up -d to roll out new image versions.

Because the entire stack lives in one version-controlled file, reproducing it on a new server is a git clone and a single command away. That is the real prize. Every other self-hosting guide on this blog, from media servers to password vaults, leans on exactly these few commands. Master this one file and the rest stop being a pile of fiddly containers and become tidy, reproducible blueprints you can stand up anywhere in minutes.