Quick Answer

Docker Compose lets you define a group of related containers — say, a web app, a database, and a cache — in one YAML file, then start, stop, and network all of them together with a single command. Instead of running several long docker run commands by hand and manually connecting containers to each other, you describe the whole stack declaratively in a docker-compose.yml and run docker compose up.

Why One Container Stops Being Enough

A single docker run command is fine when your app is genuinely one process. The moment a real app needs a database, and probably a cache, and maybe a background worker, you're running multiple containers that all need to exist at the same time, find each other over the network, and start up in something resembling the right order. Doing that by hand means a growing pile of docker run commands with manually chosen container names, a manually created docker network create, and remembering the exact flags every single time you want to bring the whole thing up — and remembering to tear it all down again, correctly, when you're done.

Docker Compose replaces that pile of commands with one YAML file, typically named docker-compose.yml, that declares every container (Compose calls them services) your app needs, how they're networked, and what data they persist. One command, docker compose up, reads that file and starts everything it describes; docker compose down tears it all back down. The file itself becomes documentation of your whole stack that lives in version control next to your code, instead of living only in one developer's shell history.

This post assumes you already know what a container and an image are — if you need that foundation first, Priodemy has a separate "Docker for Beginners" post that covers single-container basics. Everything here is specifically about running several containers together as one coordinated unit.

Anatomy of a docker-compose.yml

Here's a real three-service stack — a web app, a Postgres database, and a Redis cache — that was checked for valid YAML syntax before being included here:

services:
  web:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgres://appuser:apppass@db:5432/appdb
      - REDIS_URL=redis://cache:6379
    depends_on:
      - db
      - cache
    networks:
      - appnet

  db:
    image: postgres:16
    environment:
      - POSTGRES_USER=appuser
      - POSTGRES_PASSWORD=apppass
      - POSTGRES_DB=appdb
    volumes:
      - db-data:/var/lib/postgresql/data
    networks:
      - appnet

  cache:
    image: redis:7
    networks:
      - appnet

volumes:
  db-data:

networks:
  appnet:
    driver: bridge

Notice there's no top-level version: key at the start of this file. Older Compose file examples you'll find online almost always start with something like version: "3.8" — that key is obsolete in current Docker Compose, since the modern Compose Specification is versionless, and recent Compose releases print a deprecation warning if you still include it. Just start the file with services: directly.

Each top-level entry under services is one container. web builds its image from a Dockerfile in the current directory (build: .), while db and cache pull pre-built images from a registry (image: postgres:16). You can mix both approaches freely in one file — build the services that are your own code, pull the services that are off-the-shelf infrastructure.

Networking: How Services Find Each Other by Name

The single most useful thing Compose does for you automatically is networking. Every service in a compose file joins a shared network — in the file above, the explicit appnet network — and on that network, each service can reach the others using its service name as a hostname. That's exactly why web's DATABASE_URL points at db and not at an IP address: Compose runs an internal DNS server that resolves the service name db to whatever container IP Postgres actually got assigned, which can, and does, change every time you recreate the container.

If you'd run these as three separate docker run containers on the default Docker network, none of that DNS resolution happens for you — you'd have to create a user-defined network yourself with docker network create, attach every container to it explicitly, and even then you'd only get name-based resolution on that specific user-defined network, never on the default bridge network. Compose gives you this behavior automatically for every project, which is a big part of why "just add a database" is a one-line change in a compose file and a much bigger production of flags without it.

You don't strictly need to declare a custom network the way this example does — if you omit the networks section entirely, Compose creates one default network per project automatically and puts every service on it. Declaring your own network explicitly is mainly useful once you need multiple isolated networks in one file, for example keeping a database reachable only by your backend service and not by a public-facing frontend service.

Volumes: Keeping Data Alive When a Container Restarts

Containers are meant to be disposable — when you stop and remove one, anything written to its own filesystem disappears with it. That's a problem the instant you have a database: you don't want your Postgres data wiped every time you run docker compose down and back up. The volumes section solves this by mapping a path inside the container to storage that Docker manages independently of the container's lifecycle.

In the example above, db-data:/var/lib/postgresql/data mounts a Docker-managed named volume called db-data at Postgres's actual data directory inside the container. That volume is declared once more at the bottom of the file, under a top-level volumes: key — it needs to exist there so Compose knows to actually create and track it, not just treat the string as a typo. Once that's in place, you can run docker compose down and docker compose up repeatedly and your database rows survive, because the volume lives independently of any particular container instance.

There's a second kind of volume worth knowing: a bind mount, where instead of a name you give a real path on your host machine, like ./src:/app/src. Bind mounts are what you reach for during local development — mapping your actual source code directory into the container so changes you make in your editor show up inside the running container immediately, without rebuilding the image. Named volumes are the right choice for data you want Docker to own and manage, like a database's files; bind mounts are the right choice for your own source code during development.

depends_on and the Startup-Order Trap

depends_on looks like it guarantees that db is ready before web starts, since web lists db and cache as dependencies in the example above. What it actually guarantees, by default, is much weaker: Docker starts the dependency container before starting the dependent one, but it does not wait for Postgres to finish initializing and start accepting connections — only for the container process to have started running at all. Postgres, MySQL, and most databases take a real, sometimes multi-second warm-up before they're actually ready to accept queries, and your app container can easily start and try to connect before that warm-up finishes, producing a connection-refused error on the very first startup.

The fix is a healthcheck combined with a condition on depends_on:

services:
  web:
    build: .
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser"]
      interval: 5s
      timeout: 5s
      retries: 5

Here, Compose runs pg_isready inside the db container every 5 seconds and only considers it "healthy" once that check succeeds, retrying up to 5 times. web's depends_on now waits for that healthy status specifically, not just for the container to exist — which actually solves the race condition instead of just usually working most of the time on a fast machine. This is worth setting up on any service where "started" and "actually ready" are meaningfully different, which in practice means almost every database and message queue.

The Commands You'll Actually Use

A handful of subcommands cover the vast majority of day-to-day Compose usage:

  • docker compose up -d — start every service defined in the file, in the background. Without -d, it runs in the foreground and streams every container's logs to your terminal until you Ctrl+C, which stops everything.
  • docker compose down — stop and remove all the containers and the network Compose created. Named volumes survive this by default; add -v if you specifically want to delete volumes too, which deletes your data, so use it deliberately.
  • docker compose logs -f web — stream logs from just the web service, following new output as it arrives. Drop the service name to follow every service's logs interleaved.
  • docker compose exec web sh — open a shell inside the already-running web container, useful for poking around or running a one-off command like a database migration.
  • docker compose ps — list the services in this project and their current status, which is faster than hunting through docker ps for the right container names.
  • docker compose build — rebuild images for any services with a build: key, without starting anything, useful after you've changed a Dockerfile.

One naming note: the standalone docker-compose binary, with a hyphen, a separate Python-based tool, is the older, now-deprecated way to run Compose. Current Docker installations ship Compose as a plugin invoked as docker compose, a space and no hyphen, integrated directly into the Docker CLI. If you see a tutorial using the hyphenated form, the commands themselves are almost identical — the underlying compose file format is the same either way.

Scaling Services and Optional Profiles

Compose can run more than one instance of a given service with docker compose up -d --scale web=3, which starts three containers from the web service definition instead of one. This mostly matters for local load-testing or trying out a stateless service under concurrent load — for real production scaling and load balancing across multiple hosts, you'd move to an orchestrator like Kubernetes or Docker Swarm, which Compose is not trying to replace. Note that scaling a service with a fixed host port mapping like "3000:3000" will fail past the first instance, since two containers can't both bind the same host port — scaled services typically omit the fixed host port and get load-balanced by something in front of them instead.

A second feature worth knowing is profiles, which let you mark certain services as optional, only starting when explicitly requested:

services:
  web:
    build: .
  db:
    image: postgres:16
  adminer:
    image: adminer
    profiles:
      - debug

A plain docker compose up starts web and db but skips adminer entirely, because it's tagged with the debug profile. Running docker compose --profile debug up starts all three. This is the clean way to keep a debugging tool, a seed-data script, or a service only your team's power users need available in the same file without it starting up, and consuming resources, for everyone by default.

Frequently Asked Questions

Do I still need a version: key at the top of my docker-compose.yml? No — current Docker Compose, the Compose Specification, is versionless, and the version key is obsolete. Just start the file with services: directly; recent Compose releases will warn if you still include a version key.
What's the difference between docker compose and docker-compose with a hyphen? docker-compose is the older, standalone Python tool, now deprecated. docker compose, a space and no hyphen, is the current version, built into the Docker CLI as a plugin. The compose file format is the same for both.
My app container starts before the database is actually ready to accept connections — how do I fix that? Add a healthcheck to the database service and set the dependent service's depends_on entry to condition: service_healthy instead of a plain dependency list. Plain depends_on only waits for the container to start, not for the service inside it to be ready.
Where does my data go when I run docker compose down? Named volumes, declared under the top-level volumes key, are preserved by default — only the containers and the network are removed. Add the -v flag if you specifically want to delete volumes too, which permanently deletes that data.
Can I use variables from a .env file in my compose file? Yes. Compose automatically reads a .env file in the same directory as your compose file and lets you reference those variables with ${VARIABLE_NAME} syntax anywhere in the YAML, which keeps secrets and environment-specific values out of the committed file.
How is Docker Compose different from Kubernetes? Compose is designed for running a defined set of containers on a single host — development, small deployments, CI environments. Kubernetes orchestrates containers across a cluster of many machines, with automatic scaling, self-healing, and rolling updates — a different scale of problem.