The problem Compose solves is not complexity, it is memory. A service running under plain docker run exists as a command somebody typed once, with its ports, volumes, environment and restart policy living nowhere except that machine’s shell history. Six months later nobody can reproduce it.
A compose file is that command written down, in a form you can read, put in git and run somewhere else.
Is it worth your time? If you run anything in Docker on a machine you care about — yes, immediately, even for a single container. The file is shorter than the equivalent command, and it is the difference between a server you can rebuild and one you cannot.
The real no case: if you are running several machines, or need rolling updates, health-based rescheduling or secrets management, Compose is not that and does not pretend to be. It restarts containers on one host. Past that point you want Kubernetes or Nomad, and the honest advice is to stay on Compose until something actually hurts — most self-hosted setups never reach that point.
Installing, and the two things called Compose
The old Python tool invoked as docker-compose (with a hyphen) is dead — support ended in June 2023 and it was removed from Docker Desktop. The current tool is a Go plugin for the Docker CLI, invoked as docker compose, with a space.
# Linux, from Docker's repository
sudo apt install docker-compose-plugin # Debian, Ubuntu
sudo dnf install docker-compose-plugin # Fedora, RHEL
docker compose versionOn Docker Desktop it is already there. Distribution repositories sometimes still carry a docker-compose package containing the dead version — use Docker’s own repository, and if docker-compose --help works on a machine you inherited, that machine is running something unmaintained.
The version numbering will confuse you. Compose went from 2.x straight to 5.0.0 in December 2025, skipping 3 and 4 deliberately — the maintainers wanted to avoid yet more confusion with the old version: "3" line that used to sit at the top of compose files. So a machine reporting v5.x is current, not wildly ahead. The other thing that changed in 5.0 is that Compose no longer has its own image builder; builds are handed to Docker Bake, the same path docker build takes.
A file worth copying
# compose.yaml
services:
app:
image: ghcr.io/example/app:1.4.2
restart: unless-stopped
ports:
- "127.0.0.1:8080:8080"
environment:
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/app
volumes:
- ./config:/etc/app:ro
depends_on:
db:
condition: service_healthy
db:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- dbdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 10s
timeout: 5s
retries: 5
volumes:
dbdata:Four details in there are the ones that matter.
No version: key. The top-level version line is obsolete. It is ignored, it is validated against nothing, and Compose prints a warning if you include it. Every old tutorial still opens with version: "3.8" — delete that line.
127.0.0.1:8080:8080, not 8080:8080. Without the address, the port is published on every interface, and — crucially — Docker writes its own firewall rules, so ufw or firewalld will show the port closed while the world can reach it. Binding to localhost and putting a reverse proxy in front is the correct shape for anything on a public machine.
Pinned image tags. postgres:16 rather than postgres:latest. Pulling latest months later gets you a different major version and a database that will not start.
depends_on with a condition. Bare depends_on waits for the container to start, not to be usable, which is why applications so often crash on first boot against a database that is still initialising. condition: service_healthy plus a healthcheck is what people actually mean.
The commands you will use
docker compose up -d # start everything, in the background
docker compose down # stop and remove containers and networks
docker compose ps # what is running, and its health
docker compose logs -f app # follow one service
docker compose pull && docker compose up -d # update to newer images
docker compose exec app sh # a shell inside a running container
docker compose config # print the file as Compose understands itdocker compose config is the underused one. It resolves variables, merges override files and applies defaults, so it shows you what will actually run — which settles most “why is it using the wrong value” questions in one command.
Note that up -d is also the update command. Compose compares the running containers to the file and recreates only what changed, so editing one service and re-running it leaves the others alone.
Volumes, and the one that deletes your data
| Kind | Written as | Use for |
|---|---|---|
| Named volume | dbdata:/var/lib/... | Data the container owns — databases especially |
| Bind mount | ./config:/etc/app | Files you edit yourself |
| Read-only bind | ./config:/etc/app:ro | Config the container must not change |
Prefer named volumes for anything a database writes. Bind mounts carry the host’s UID numbers straight into the container with nothing translating them, which is the source of most “permission denied” errors inside a container that looks correctly configured.
And the destructive one: docker compose down -v removes the named volumes as well as the containers. It is one keystroke from the harmless down and it deletes your database. There is no confirmation prompt.
Secrets and environment
Compose reads a .env file sitting beside the compose file and substitutes ${VAR} references from it. Keep the compose file in git and the .env file out of it:
# .env - never commit this
DB_PASSWORD=correct-horse-battery-stapleecho '.env' >> .gitignore
chmod 600 .envTwo traps. Values in .env are not shell syntax — quotes are taken literally, so PASSWORD="abc" sets the password to "abc" including the quotes. And a password containing $ needs it doubled as $$, or Compose will try to expand it.
Override files
Compose automatically merges compose.override.yaml on top of compose.yaml if it exists. That is how you keep one base definition and layer a development or staging variation on it without duplicating the file:
# compose.override.yaml - local development only
services:
app:
ports:
- "8080:8080" # reachable from the LAN while developing
environment:
LOG_LEVEL: debug
volumes:
- ./src:/app/src # live code, no rebuild# pick files explicitly, ignoring the automatic override
docker compose -f compose.yaml -f compose.prod.yaml up -dMerging is per-key and mostly intuitive, except that lists such as ports and volumes are appended rather than replaced. An override that adds a port does not remove the base one.
For a development loop, docker compose watch is now a stable feature rather than an experiment: it syncs changed files into the running container, or rebuilds and replaces it, depending on what you declare. It removes most of the reason people bind-mount their source directory.
Where it does not belong
- More than one machine. Compose has no concept of a second host. Swarm mode reads compose files but is effectively in maintenance.
- Zero-downtime deploys.
up -dstops the old container and starts the new one, with a gap in between. - Real secret management. The
.envfile is a plaintext file next to the compose file. That is acceptable on a server only you administer and not much beyond it. - As a substitute for backups. The compose file describes the arrangement, not the data. Named volumes still need backing up.
Common problems
| Symptom | Cause | Fix |
|---|---|---|
docker-compose: command not found | Looking for the retired v1 | Use docker compose |
Warning that version is obsolete | An old tutorial’s first line | Delete it |
| App crashes on first start, fine on restart | depends_on waited for start, not readiness | Healthcheck plus service_healthy |
| Port reachable from the internet despite the firewall | Docker writes its own rules | Publish as 127.0.0.1:8080:8080 |
| Permission denied on a mounted directory | Host UID does not exist in the container | Set user:, or fix ownership on the host |
| Data gone after a restart | down -v, or no volume declared at all | Named volume; never -v casually |
| Variable not substituted | .env in the wrong directory | docker compose config to see the truth |
| Containers do not come back after a reboot | No restart policy | restart: unless-stopped |
Quick reference
docker compose up -d # start or update
docker compose down # stop; add -v ONLY to destroy data
docker compose ps # state and health
docker compose logs -f --tail=50 # follow recent output
docker compose pull # fetch newer images
docker compose exec app sh # get inside
docker compose config # what will actually run
docker compose restart app # one service
docker compose -f a.yaml -f b.yaml up -d # explicit file listRelated reading
- Docker basics — images, volumes and networks first
- Self-hosting your first application — a compose file in its natural habitat
- Moving an existing service into a container — what to put in the file
- Containers explained — what is underneath all this
- Linux firewalls — including the Docker rule that bypasses them
