Docker Compose: run a multi-container app with one file
Stop typing long docker run commands. One compose.yaml starts your whole stack, and one command stops it. Tested example included.

Most real applications are more than one process: a web server, a database, a cache. Starting each with a long docker run command is slow and easy to get wrong. Docker Compose lets you describe the whole stack in one file and start, stop and rebuild it with one command. This article assumes you know basic Docker (images, containers, Dockerfile). The example below was run with Docker Compose v2 before publishing.
The idea
You write a compose.yaml that lists services (containers), their images, ports, volumes and settings. Compose creates a private network so the services can reach each other by service name, and runs everything together.
A working example
A web server (nginx) and a Redis cache. Save as compose.yaml, and put any index.html in a site folder next to it:
services:
web:
image: nginx:alpine
ports:
- "8099:80"
volumes:
- ./site:/usr/share/nginx/html:ro
depends_on:
cache:
condition: service_healthy
restart: unless-stopped
cache:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes"]
volumes:
- cache-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
volumes:
cache-data:
Run it
$ docker compose up -d
Container demo-cache-1 Started
Container demo-cache-1 Healthy
Container demo-web-1 Started
$ docker compose ps
SERVICE IMAGE STATUS PORTS
cache redis:7-alpine Up 13 seconds (healthy) 6379/tcp
web nginx:alpine Up 6 seconds 0.0.0.0:8099->80/tcp
$ curl localhost:8099
<h1>Hello from Compose</h1>
$ docker compose exec cache redis-cli set greeting hello
OK
$ docker compose down # removes containers and network, keeps the volume
$ docker compose up -d
$ docker compose exec cache redis-cli get greeting
hello
$ docker compose down -v # also removes the volume (data is gone)
(Container names depend on your folder or project name, and image sizes and times vary.)
What each part does
services: each key (web,cache) is one container. Its name is also its hostname on the Compose network, so a web app can reach Redis atcache:6379with no IP address.image: which image to run. Usebuild: .instead to build from your own Dockerfile.ports: "8099:80": host port 8099 maps to container port 80. Only publish ports you need. Redis has noportsentry, so it is reachable only by other services, which is safer.volumes:./site:/usr/share/nginx/html:rois a bind mount of a local folder, read-only (:ro).cache-data:/datais a named volume managed by Docker, which stores Redis data outside the container.healthcheck: a command Compose runs repeatedly to decide if the service is healthy.depends_onwithcondition: service_healthy: startwebonly aftercacheis healthy. Plaindepends_ononly waits for the container to start, not to be ready. That difference causes many "connection refused at startup" bugs.restart: unless-stopped: restarts the container after a crash or a server reboot, unless you stopped it yourself.
Everyday commands
| Command | Use |
|---|---|
docker compose up -d |
start the stack in the background |
docker compose ps |
show status |
docker compose logs -f web |
follow one service's logs |
docker compose exec cache sh |
open a shell inside a running service |
docker compose up -d --build |
rebuild images after a code change |
docker compose down |
stop and remove containers and network, keep volumes |
docker compose down -v |
also delete volumes (deletes data) |
docker compose config |
validate the file and show the final result |
Data and volumes: the important lesson
In the run above we stored a key, ran down and up, and the key was still there, because it lives in the named volume. Containers are disposable; volumes are where data lives. down -v removes the volume, so use it only when you really want to erase data. Never keep a database in a container without a volume.
Configuration and secrets
- Put settings in an
.envfile next tocompose.yamland reference them as${VARIABLE}in the file. Never commit.envwith real passwords to Git; add it to.gitignoreand commit an.env.exampleinstead. - Avoid hard-coding passwords in
compose.yaml. - Pin image versions (
redis:7-alpine, notlatest) so an update does not surprise you.
Common problems
- Port already in use: change the left side of
ports("8100:80"). - Service cannot connect to another: use the service name, not
localhost. Inside a container,localhostmeans that container itself. - Changes to code not showing: rebuild with
--build, or use a bind mount in development. - Starts before the database is ready: use a health check with
condition: service_healthy, and make your app retry connections. - Lost data after
down -v: that flag deletes volumes. Back up first.
Compose in production?
Compose is excellent for local development, testing and small single-server deployments. It does not spread containers across many machines or heal them automatically across servers. For that, people use an orchestrator such as Kubernetes. For one small server, Compose with restart: unless-stopped, volumes and backups is a reasonable choice.
Takeaway: describe your stack once in compose.yaml, use service names for networking, use health checks for startup order, keep data in volumes, and keep secrets out of the file.
Linux & DevOps series, Part 3 (everyday operations): 1 of 5.


