Docker Compose lets you describe a multi-container app (a frontend, a backend, a database, a cache) in one YAML file, then start or stop all of it with a single command. It turns a page of docker run commands you have to remember into something you can commit, share and run the same way every time.
The problem with starting containers by hand
A real app is rarely one container. A typical web project might have a frontend, an API, a PostgreSQL database and Redis for caching. With plain Docker, each of those needs its own docker run command, and they all have to agree with each other: the same network so they can talk, the right ports published, the right environment variables, and volumes so the database keeps its data.
By hand, it looks something like this:
docker network create shop
docker volume create db-data
docker run -d --name db --network shop \
-e POSTGRES_PASSWORD=secret \
-v db-data:/var/lib/postgresql/data \
postgres:16
docker run -d --name cache --network shop redis:7
docker run -d --name api --network shop \
-p 8080:8080 my-apiThat is three of the four services, and it is already fragile. Run them in the wrong order and the API starts before its database exists. Forget the network flag and two containers can't see each other. A new teammate has to copy all of it from a README that may be out of date.
One file that describes the whole stack
Compose replaces those commands with a declarative file, usually called compose.yaml (older projects use docker-compose.yml, which still works). Instead of saying how to start each container, you describe what the app looks like, and Compose works out the steps.
The file's main section is services. Each service becomes a container, and its settings map closely to the docker run flags you would otherwise type:
imageorbuild: run an existing image, or build one from a Dockerfile.ports: publish a container port on your machine, ashost:container.environment: set environment variables.volumes: mount a named volume or a folder from your machine.depends_on: say which services must start first.
Two other top-level sections are common: volumes, which declares the named volumes Compose should create, and networks, for when you need more than the default one.
A worked example
Here is the same app as one Compose file, with all four services: a frontend, an API, PostgreSQL and Redis.
services:
frontend:
build: ./frontend
ports:
- "3000:3000"
depends_on:
- api
api:
build: ./api
ports:
- "8080:8080"
environment:
DATABASE_URL: postgres://app:secret@db:5432/app
REDIS_URL: redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: app
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
retries: 5
cache:
image: redis:7
volumes:
db-data:A few things worth noticing:
- The API reaches the database at
db:5432and Redis atcache:6379. Those hostnames are the service names. - Only the frontend and the API publish ports. The database and cache are reachable by the other services but not from your machine, which is usually what you want.
- The
db-datavolume keeps the database's files outside the container, so the data survives when the container is replaced. - The API waits for the database's health check to pass, not just for its container to start. The common mistakes section below explains why that matters.
How services find each other
When you start the stack, Compose creates a network for the project, named after its folder (for example shop_default), and attaches every service to it. On that network, Docker's built-in DNS resolves each service name to its container's address. That is why the API's connection string says db rather than an IP address: a container gets a new IP when it is recreated, but the name stays the same.
It also means localhost inside a container refers to that container itself, not to your machine and not to the other services. An API that connects to localhost:5432 finds nothing, because PostgreSQL is running in a different container.
Published ports are for reaching a service from outside. 8080:8080 makes the API available on your machine's port 8080, so your browser can reach it. Between services you use the container port directly (db:5432), and nothing needs publishing.
The everyday commands
The two you will use most are up and down. Run them in the folder that holds the Compose file.
# build what's missing, create, start, show logs
docker compose up
# the same, but in the background
docker compose up -d
# rebuild images first, after a Dockerfile change
docker compose up --build
# stop and remove the containers and network
docker compose downup is also safe to run again. Compose compares what is running with the file, recreates the containers whose configuration has changed and leaves the rest alone. Change one service's environment variables, run docker compose up -d again, and that service is replaced with the new settings.
A few more are useful once the stack is running:
# list the stack's containers
docker compose ps
# follow one service's logs
docker compose logs -f api
# run a command inside a running service
docker compose exec db psql -U app
# stop the containers without removing them
docker compose stopdown removes the containers and the network but keeps named volumes, so your database survives. docker compose down -v removes the volumes too, which wipes the data. That is handy for a clean start and painful by accident.
Why Compose suits local development
Compose's biggest win is on a developer's machine. The whole stack is described in a file that lives in the repository, so:
- A new teammate clones the repo and runs
docker compose up. There is no installing PostgreSQL or Redis locally, and no version mismatches. - Everyone runs the same database and cache versions, pinned in the file.
- Switching projects is clean:
downin one,upin the next, and nothing is left installed on your machine. - CI can start the same stack to run integration tests against a real database.
For quick code changes, you can bind-mount your source folder into a container (./api:/app under volumes), so a dev server inside the container sees your edits without a rebuild. Compose also merges a compose.override.yaml on top of compose.yaml automatically when one exists, which is a common home for development-only settings like those mounts.
When not to use it
Compose runs containers on one machine. That is its main limit.
- No multi-server scaling or failover. If the machine goes down, so does the app. Systems that need several servers, rolling updates and self-healing usually use an orchestrator such as Kubernetes, or a managed container service.
- Secrets in plain text. Passwords written straight into
compose.yaml, as in the example, are fine for a throwaway local database and wrong for anything real. Keep real values out of the file, for example in an untracked.envfile, which Compose reads for variable substitution, or in a secrets manager. - It doesn't design your images. Compose starts containers from images. What goes into those images, and how small and safe they are, is still the Dockerfile's job: see distroless images for one way to slim them down.
Plenty of small projects do run Compose in production on a single server, and that can be a reasonable trade-off. Just know which guarantees you are giving up.
Common mistakes
- Connecting to
localhost. Inside a container, use the service name (db,cache), notlocalhost. - Trusting
depends_onalone. A plaindepends_ononly waits for the other container to start, not for the program inside it to be ready. A database can take a few seconds to accept connections, so the API fails on its first try. Give the dependency ahealthcheckand usecondition: service_healthy, as in the example, or make the app retry its connection. - Losing data with
down -v. The-vflag deletes named volumes. Leave it off unless you mean to reset. - Forgetting to rebuild. After changing a Dockerfile or the dependencies it installs, a plain
upcan reuse the old image. Useup --build. - Publishing every port. Only publish what you need to reach from your machine. Services talk to each other over the project network without it.
- Copying old syntax. The top-level
version:key is obsolete, and current Compose ignores it with a warning. The old standalonedocker-composecommand has been replaced bydocker compose, which is built into Docker.
Key takeaways
- Docker Compose describes a multi-container app in one YAML file, usually
compose.yaml, that lives with your code. docker compose upcreates the network, volumes and containers and starts the whole stack;docker compose downstops and removes it, keeping named volumes.- Services reach each other by service name on the project network, never through
localhost. depends_onsets the start order, but only a health check makes a service wait until its dependency is actually ready.- Compose shines for local development and testing on one machine; multi-server production needs an orchestrator.