Docker Compose Tutorial: Run a Multi-Container App
Docker Compose describes a whole application, with its containers, networks and volumes, in one YAML file. Instead of five long docker run commands, you write the setup down once and start it with a single command.
Install check
Compose ships with Docker Engine as a plugin:
docker compose version
Use docker compose (with a space). The old docker-compose binary is deprecated.
A realistic example
Create a project folder with a compose.yaml and a .env file.
# compose.yaml
services:
db:
image: mariadb:11
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MARIADB_DATABASE: app
MARIADB_USER: app
MARIADB_PASSWORD: ${DB_PASSWORD}
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
web:
image: nginx:alpine
restart: unless-stopped
ports:
- "127.0.0.1:8080:80"
volumes:
- ./site:/usr/share/nginx/html:ro
depends_on:
db:
condition: service_healthy
volumes:
db_data:
# .env (never commit this file)
DB_ROOT_PASSWORD=change-me-long-random
DB_PASSWORD=change-me-too
Generate real passwords with openssl rand -hex 16, and add .env to .gitignore.
What the file means
| Key | Purpose |
|---|---|
services |
One entry per container. The name (db, web) becomes its hostname on the network |
image / build |
Use a ready image, or build from a Dockerfile (build: .) |
ports |
HOST:CONTAINER. Prefixing 127.0.0.1: keeps it private |
volumes |
db_data: is a named volume (persistent). ./site:/... is a bind mount of a host folder |
environment |
Variables for the container. ${VAR} is read from .env |
depends_on + healthcheck |
Start web only after db is actually ready, not just started |
restart |
Come back after a crash or reboot |
The top-level version: key is obsolete, so leave it out.
Because services share a network, your app connects to the database at host db, port 3306. You don't need to publish the database port at all, and you generally shouldn't.
Everyday commands
docker compose up -d # create and start everything in the background
docker compose ps # status
docker compose logs -f web # follow one service's logs
docker compose exec web sh # shell in a running service
docker compose restart web
docker compose stop # stop, keep containers
docker compose down # stop and remove containers + network (volumes are kept)
docker compose down -v # ALSO delete volumes, which means your database is gone
docker compose config # validate and print the final merged config
Updating to newer images
docker compose pull
docker compose up -d # recreates only the containers whose image changed
docker image prune -f # remove the old image layers
If you use build:, rebuild with docker compose up -d --build.
Back up the database
docker compose exec db sh -c 'mariadb-dump -uroot -p"$MARIADB_ROOT_PASSWORD" --all-databases' \
| gzip > backup-$(date +%F).sql.gz
Restore:
gunzip -c backup-2026-10-16.sql.gz | docker compose exec -T db sh -c 'mariadb -uroot -p"$MARIADB_ROOT_PASSWORD"'
Common mistakes
- Data disappears after
down. You used a container path with no volume, or you randown -v. Use named volumes for anything that must persist. - "Connection refused" to the database at startup. The app started before the DB was ready. Use
healthcheck+depends_on: condition: service_healthy. - Using
localhostbetween containers. Inside a container,localhostis itself. Use the service name (db). - Changed
.envbut nothing changed. Re-rundocker compose up -dso containers are recreated with the new values. - Port already in use. Another service owns the host port. See which process is using a port.
- Secrets in the compose file. Keep them in
.envor Docker secrets, not in Git.
Put it behind a reverse proxy
For several sites on one server, bind each app to 127.0.0.1:PORT as above and let Apache or Nginx handle HTTPS and domain names in front. Your containers stay unreachable from the internet except through the proxy.