The problem Compose solves
A real application is rarely one container. A web API needs a database, the database needs a volume so its data survives a restart, and then you add a Redis cache and a background worker. That is four docker run commands, each with its own flags for ports, volumes, environment variables and a shared network. In the right order. Every time you sit down to work.
Docker Compose replaces those commands with one file and one command. You describe the containers and how they connect in a file called compose.yaml, and docker compose up starts all of them. If images and docker run are new to you, read what Docker is and how containers work first.
What the compose file describes
A compose file has a handful of top-level keys. The one you always use is services — each service is a container Compose will run.
| |
Two containers. api is built from the Dockerfile in the current directory and publishes port 8000. db runs the official postgres:18 image and keeps its data in a named volume, so the data is still there when the container is recreated.
Two details do the real work here:
apireachesdbat the hostnamedb. Compose puts every service on a shared network and registers each service name as a DNS name. No IP addresses, no legacy--link.DATABASE_URLpoints atdb:5432and it resolves.depends_onwithcondition: service_healthyholdsapiback until Postgres actually answers. Plaindepends_onwaits only for the container to start, not for the database process inside it to accept connections — that gap is the usual reason an app throws “connection refused” on the first boot.
The three top-level keys: services, volumes, networks
Three top-level keys map straight onto Docker concepts you already know:
| Key | What it defines | Done by hand with |
|---|---|---|
services | the containers to run | docker run |
volumes | named volumes for data that must persist | docker volume create |
networks | networks between services | docker network create |
You rarely declare networks. Compose creates one network per project and attaches every service to it, which is why the Postgres example needed no network config at all. Declare networks explicitly only when you want to keep groups of services from reaching each other:
| |
The commands you actually use
Run these from the directory that holds compose.yaml.
| |
up and down are the pair you type most. up is safe to run repeatedly: after you edit the file, it recreates only the services whose config changed and leaves the rest alone.
What docker compose up prints on the first run
Run the Postgres example above and you get roughly this:
| |
The names are prefixed with the project name (the directory name by default) and suffixed with a number, because Compose is ready to run more than one replica of a service. app-db-1 reports Healthy before app-api-1 starts — that is the condition: service_healthy doing its job.
How to keep passwords out of the compose file
Compose reads a file named .env in the project directory and substitutes ${VAR} references in the compose file:
| |
| |
Keep .env out of version control and commit a .env.example with blank or dummy values instead. For anything genuinely sensitive in a deployed environment, use Docker secrets rather than environment variables.
Optional services with profiles
Not every service should start every time. A profiles entry keeps a service idle unless you ask for it:
| |
docker compose up starts api and db and ignores seed entirely. docker compose --profile tools run --rm seed runs the seeder when you ask for it. Use profiles for anything one-shot: seeders, migrations, a debugging shell, a load generator you only want during a test.
Compose v2 vs the old docker-compose
If a guide tells you to run docker-compose with a hyphen, it predates 2023. That was v1, written in Python, now end of life. The current tool is docker compose as a subcommand of the Docker CLI; it comes with Docker Desktop and with the Compose plugin package for Docker Engine.
You will also meet this line at the top of older files:
| |
The version key has no effect in Compose v2. Compose validates against the current Compose Specification and warns you when the key is present. Remove it and the warning goes away.
The default file name moved too: compose.yaml is current, docker-compose.yml still works, and Compose looks for either.
Live reload during development
docker compose watch (Compose 2.22 and later) updates containers as you edit. Add a develop block to the service:
| |
sync copies changed files straight into the running container, so a code edit shows up without a rebuild. rebuild triggers a full image rebuild when a file that affects the build changes, like a dependency lock file. Start it with docker compose watch, or add --watch to up.
When Compose is the wrong tool
Compose runs containers on one machine. That is the boundary, and most of what people find missing in Compose is on the other side of it.
It fits local development, automated tests in CI, and small single-host deployments. It has no notion of scheduling containers across several servers, replacing one when a node dies, rolling updates gated on health checks, or autoscaling.
Those belong to an orchestrator: Kubernetes, or Docker Swarm if you want something lighter. The two are not in competition. A compose file is usually the rough draft that later becomes a set of Kubernetes manifests, and plenty of teams keep using Compose locally long after production has moved to a cluster.
Podman users get a compatible path here too: podman compose and podman-compose read the same file format, with the caveats covered in Docker vs Podman.
Common mistakes
- Secrets committed in
compose.yaml. The file lives in Git. Use.env(gitignored) or Docker secrets. - Trusting
depends_onto wait for readiness. On its own it waits for the container to start, not for the service to accept connections. Pair it with ahealthcheckandcondition: service_healthy. - Setting
container_name. It stops you running more than one copy of the project and breaksdocker compose up --scale. Let Compose name containers. - Bind-mounting over an installed dependency directory. Mounting your project folder into a Node or Python container can shadow the
node_modulesor virtualenv created during the build. Mount source subdirectories, or put an anonymous volume on the dependency path.
How to move an existing app to Compose
Take the app you currently start with a shell script full of docker run lines and move it into a compose.yaml one service at a time. Bring it up, read the logs, fix what breaks, add the next service. Give a healthcheck to anything other services depend on — that is the step people skip, and it is the one that stops the “connection refused” on a cold start.
You will know it worked when docker compose down -v && docker compose up rebuilds the entire environment from nothing and the app comes back the same way every time.