What is it? #
Compose describes several containers, their networks and their volumes in one file, and runs them with one command.
Running an application, a database, a cache and a worker by hand means four long commands in the right order. Compose makes it one file that lives in the repository.
Its greatest value is local development and CI. A new developer clones the repository and runs one command; CI runs the same file for integration tests.
It also works for small production deployments on a single server, though anything needing multiple machines wants an orchestrator instead.
Think of it like this #
A stage plan for a production rather than instructions shouted to each performer separately.
Everyone knows where to stand, what they depend on and when to start, and the whole thing can be set up or cleared away in one action.
Simple example #
An application with PostgreSQL, Redis and a background worker. One file defines all four, with health checks so the application waits for the database to be ready.
Code #
# compose.yaml
services:
db:
image: postgres:16
environment:
POSTGRES_DB: shop
POSTGRES_USER: appuser
POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser -d shop"]
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
cache:
image: redis:7-alpine
command: redis-server --save 60 1 --loglevel warning
volumes:
- cache-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
retries: 5
restart: unless-stopped
app:
build:
context: .
target: production
environment:
DATABASE_URL: postgresql://appuser:${DB_PASSWORD}@db:5432/shop
REDIS_URL: redis://cache:6379
ports:
- "127.0.0.1:8000:8000" # host-local only
depends_on:
db: { condition: service_healthy }
cache: { condition: service_healthy }
restart: unless-stopped
worker:
build: .
command: python -m app.worker
environment:
DATABASE_URL: postgresql://appuser:${DB_PASSWORD}@db:5432/shop
REDIS_URL: redis://cache:6379
depends_on:
db: { condition: service_healthy }
restart: unless-stopped
volumes:
db-data:
cache-data:
# compose.override.yaml — applied automatically in development
services:
app:
build:
target: development
volumes:
- .:/app # live code reload
environment:
DEBUG: "true"
command: uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
docker compose up -d # start everything
docker compose ps # status and health
docker compose logs -f app # follow one service
docker compose exec app bash # a shell inside a running service
docker compose restart app
docker compose down # stop and remove containers and network
docker compose down -v # ...and the volumes: deletes data
docker compose up -d --build # rebuild images and restart
docker compose -f compose.yaml -f compose.prod.yaml up -d # explicit overrides
How it works #
Compose creates a network for the project and attaches every service to it, so services reach each other by service name — db, cache — with no extra configuration.
depends_on with condition: service_healthy waits for the health check to pass, not merely for the container to start. Plain depends_on only orders startup, which is the cause of the classic "application starts before the database is accepting connections" failure.
The health checks are what make that work. pg_isready returns success only once PostgreSQL is genuinely accepting connections.
${DB_PASSWORD:?...} fails immediately with a clear message if the variable is unset, which is better than starting with an empty password.
Publishing as 127.0.0.1:8000:8000 binds only to the host's loopback, so the service is not exposed on the network. On a server, a reverse proxy would sit in front of it.
compose.override.yaml is applied automatically when present, which is how development differences — bind-mounted source, a reload command, debug settings — stay out of the base file.
docker compose down -v removes the volumes. That deletes the database, which is occasionally what you want and frequently not.
Real-world use #
Compose is the standard way to run a multi-service project locally. It replaces a page of setup instructions with one command.
In CI it provides real dependencies for integration tests — an actual PostgreSQL rather than a mock — in a clean state for every run.
For production on a single server it is workable: the same file, an override for production settings, and a reverse proxy in front. Beyond one machine, an orchestrator handles scheduling, rolling updates and health-based routing.
Secrets should come from an environment file rather than being written into the Compose file, which is committed.
The health check discipline pays off beyond Compose. The same checks are what orchestrators and load balancers use to decide whether an instance should receive traffic.
Common mistakes #
- Using plain
depends_onand starting the application before the database is ready. - Committing passwords in the Compose file instead of using an env file.
- Running
docker compose down -vand deleting the development database. - Publishing ports on all interfaces on a server instead of binding to localhost.
- Keeping development-only settings in the base file rather than an override.
Practice #
Write a Compose file with an application, a database and a cache, using health checks and dependency conditions. Confirm the application waits for the database. Then add a development override with a bind mount and live reload, and verify the code changes appear without rebuilding.