Docker Compose Tutorial: Run Multi-Service Apps Locally
A practical Docker Compose tutorial. Define services, networks, and volumes in one YAML file, then start and stop a full local stack with a single command.
What you'll learn
- ✓What problem Docker Compose actually solves
- ✓How to structure a compose.yaml for a small stack
- ✓How service names become DNS hostnames automatically
- ✓How to wire environment variables and volumes
- ✓The everyday compose commands you will use daily
Prerequisites
- •Comfortable with the Linux command line
Once a project grows past a single container, the docker run flags start to feel ridiculous. You end up pasting long commands into a README and praying nobody mistypes them. Docker Compose replaces that with a single declarative file that describes every service, network, and volume in your stack. Run docker compose up and the whole thing starts.
What Compose actually is
Compose is a thin layer on top of the Docker Engine that reads a YAML file, usually named compose.yaml, and creates the containers, networks, and volumes it describes. It is bundled with modern Docker Desktop and available as a plugin for Docker Engine on Linux. Nothing magical happens under the hood — it is just an orchestrator for one host.
The mental model is: each top-level entry under services: becomes one container. Compose attaches them to a private network named after your project, so they can find each other by service name.
A first compose.yaml
Let us build a tiny stack: a Node API and a Postgres database.
services:
api:
image: node:20-alpine
working_dir: /app
command: node server.js
volumes:
- ./api:/app
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://app:secret@db:5432/app
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
Two things deserve attention. First, the API talks to the database at the hostname db, which is just the service name — Compose runs a DNS server inside the project network. Second, pgdata is a named volume, so the database survives docker compose down.
Starting and stopping
The day-to-day commands are short and consistent.
docker compose up -d # start everything in the background
docker compose ps # see what is running
docker compose logs -f api # tail logs from one service
docker compose exec api sh # open a shell inside the api container
docker compose down # stop and remove containers and networks
down removes containers and the project network, but it keeps named volumes by default. Add -v if you also want the volumes gone — useful when you want a fresh database.
Environment variables and .env
Hardcoding passwords in compose.yaml is fine for a throwaway demo and a bad idea otherwise. Compose automatically loads a .env file from the same directory, and you can reference variables with ${NAME} syntax.
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
Then in .env:
DB_PASSWORD=supersecret
Add .env to .gitignore and commit a .env.example instead, so teammates know which keys to set.
Building images, not just pulling them
If your service needs a custom image, point Compose at a Dockerfile.
services:
api:
build:
context: ./api
dockerfile: Dockerfile
ports:
- "3000:3000"
docker compose build builds the image, docker compose up --build rebuilds before starting. Compose tags the result with a name based on the project and service.
Healthchecks and depends_on
depends_on controls start order, but it does not wait for a service to be ready. For that, add a healthcheck and use condition: service_healthy.
services:
db:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
timeout: 3s
retries: 5
api:
image: my-api:latest
depends_on:
db:
condition: service_healthy
Now the API does not even start until Postgres reports ready, which removes a whole class of flaky-startup bugs.
Profiles for optional services
Sometimes you want a stack with optional pieces — say, a monitoring service that you only want during debugging. Profiles let you opt in.
services:
prometheus:
image: prom/prometheus
profiles: ["monitoring"]
Run it with docker compose --profile monitoring up. Without the flag, Prometheus is skipped.
When to outgrow Compose
Compose is excellent for local development, CI test environments, and small single-host deployments. It is not a cluster orchestrator. The moment you need rolling updates across multiple machines, autoscaling, or self-healing across nodes, you are looking at Kubernetes or a managed equivalent. Until then, Compose keeps your stack reproducible with almost no ceremony.
A well-written compose.yaml is one of the highest-leverage files in a project. New contributors can clone the repo, run one command, and have a working stack in minutes. That is worth the half hour it takes to write properly.
Related articles
- Docker Docker Compose vs Kubernetes: When to Use Which
A pragmatic comparison of Docker Compose and Kubernetes covering scope, operational cost, and the signals that tell you it is time to graduate.
- DevOps Docker Volumes Explained: Persisting Data the Right Way
Learn how Docker volumes keep your data alive between container restarts. Compare bind mounts, named volumes, and tmpfs, and see when to use each in real projects.
- Docker Docker Compose Network Aliases Tutorial
Network aliases let containers reach each other under multiple names. Learn how aliases work in Compose, when to use them, and the gotchas to avoid.
- Docker Docker Compose Profiles Tutorial: Optional Services Done Right
Use Docker Compose profiles to start only the services you need. Learn the syntax, common patterns, and pitfalls when mixing profiles with depends_on.