Docker Compose
Docker Compose runs multi-container applications with a single command. Instead of starting each container manually with docker run and wiring up networks and volumes by hand, you define everything in a compose.yml file. Run docker compose up, and your entire stack -- application, database, cache, message queue -- starts together. It is the standard tool for local development environments and simple production deployments.
When to Use Compose
Compose is the right tool when:
- You need multiple containers for local development (app + database + cache)
- You want a reproducible development environment that new team members can start in one command
- Your production deployment is a single server running a handful of services
- You need a simple way to run integration tests against real dependencies
Compose is not the right tool when:
- You need to run across multiple hosts (use Kubernetes or a container orchestration platform)
- You need auto-scaling, rolling updates, or self-healing in production (use Kubernetes)
- You have dozens of services that need service discovery and load balancing at scale
The Compose File
Modern Docker Compose uses compose.yml (not the older docker-compose.yml). The file uses the Compose Specification, which replaced the versioned format. You no longer need a version: key at the top.
# compose.yml
services:
api:
build: .
ports:
- "8080:8080"
environment:
DATABASE_URL: postgres://app:secret@db:5432/myapp
REDIS_URL: redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
volumes:
- ./src:/app/src
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: myapp
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d myapp"]
interval: 5s
timeout: 5s
retries: 5
cache:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
pgdata:
# Start all services
docker compose up
# Start in the background
docker compose up -d
# View logs
docker compose logs -f api
# Stop everything
docker compose down
# Stop and remove volumes (destroys data)
docker compose down -v
Services
Each entry under services defines a container. A service can be built from a local Dockerfile or pulled from a registry.
Building from a Dockerfile
services:
api:
build:
context: .
dockerfile: Dockerfile
target: development # Use a specific stage from a multi-stage build
ports:
- "8080:8080"
Using a Pre-Built Image
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
Overriding the Command
services:
api:
build: .
command: python -m pytest # Override CMD from Dockerfile
Networks
Compose creates a default network for your project. All services can reach each other by their service name. In the example above, the api service connects to the database using db as the hostname -- not localhost, not an IP address.
# The api service connects to postgres at: db:5432
# The api service connects to redis at: cache:6379
# No manual network configuration needed for most cases
For more complex setups, you can define custom networks:
services:
api:
networks:
- frontend
- backend
db:
networks:
- backend
nginx:
networks:
- frontend
networks:
frontend:
backend:
In this example, nginx can reach api but not db. The api service bridges both networks.
Volumes
Volumes persist data beyond the lifecycle of a container. Without a volume, stopping a database container destroys all its data.
Named Volumes
services:
db:
image: postgres:16-alpine
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata: # Managed by Docker, persists across restarts
Bind Mounts
Bind mounts map a host directory into the container. They are essential for development -- edit code on your host, and the changes are immediately visible inside the container.
services:
api:
build: .
volumes:
- ./src:/app/src # Bind mount: host ./src -> container /app/src
- /app/node_modules # Anonymous volume: prevent host node_modules from overriding container's
The anonymous volume trick for node_modules prevents a common issue: the host's node_modules (possibly empty or built for a different OS) from overwriting the container's installed dependencies.
Environment Variables
Three ways to pass environment variables:
Inline in compose.yml
services:
api:
environment:
DATABASE_URL: postgres://app:secret@db:5432/myapp
LOG_LEVEL: debug
From a .env File
services:
api:
env_file:
- .env
# .env
DATABASE_URL=postgres://app:secret@db:5432/myapp
LOG_LEVEL=debug
Variable Substitution
Compose can interpolate environment variables from your shell:
services:
api:
image: ghcr.io/myorg/api:${API_VERSION:-latest}
environment:
LOG_LEVEL: ${LOG_LEVEL:-info}
API_VERSION=v1.2.3 docker compose up
The :- syntax provides a default value if the variable is not set.
depends_on & Health Checks
depends_on controls startup order. By default, it only waits for the dependency to start, not to be ready. A database container that has started is not necessarily accepting connections yet.
services:
api:
depends_on:
db:
condition: service_healthy # Wait for health check to pass
cache:
condition: service_started # Just wait for container to start
db:
image: postgres:16-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d myapp"]
interval: 5s
timeout: 5s
retries: 5
start_period: 10s
Without condition: service_healthy, your application may crash on startup because the database is not ready. This is one of the most common issues with Docker Compose setups.
A Complete Local Development Setup
Here is a realistic Compose file for a web application with a database, cache, background worker, and reverse proxy:
# compose.yml
services:
api:
build:
context: .
target: development
ports:
- "8080:8080"
environment:
DATABASE_URL: postgres://app:secret@db:5432/myapp
REDIS_URL: redis://cache:6379
ENV: development
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
volumes:
- ./src:/app/src
command: python -m uvicorn main:app --host 0.0.0.0 --reload
worker:
build:
context: .
target: development
environment:
DATABASE_URL: postgres://app:secret@db:5432/myapp
REDIS_URL: redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
volumes:
- ./src:/app/src
command: python -m celery -A tasks worker --loglevel=info
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: myapp
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d myapp"]
interval: 5s
timeout: 5s
retries: 5
cache:
image: redis:7-alpine
ports:
- "6379:6379"
mailhog:
image: mailhog/mailhog:latest
ports:
- "1025:1025"
- "8025:8025"
volumes:
pgdata:
# One command to start everything
docker compose up
# New team member onboarding:
git clone git@github.com:org/repo.git
cd repo
docker compose up
# Done. Full development environment running.
Compose for Testing
Compose is excellent for running integration tests against real dependencies:
# compose.test.yml
services:
test:
build:
context: .
target: test
environment:
DATABASE_URL: postgres://test:test@db:5432/testdb
depends_on:
db:
condition: service_healthy
command: python -m pytest --tb=short
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
healthcheck:
test: ["CMD-SHELL", "pg_isready -U test -d testdb"]
interval: 2s
timeout: 2s
retries: 10
# Run tests and exit
docker compose -f compose.test.yml run --rm test
# In CI
docker compose -f compose.test.yml up --abort-on-container-exit --exit-code-from test
The --exit-code-from test flag makes the docker compose command return the exit code of the test service, so your CI pipeline fails when tests fail.
When Compose Is Enough & When You Need More
Compose is enough when:
- Single-server deployment (one host, a few containers)
- Local development environments
- Integration testing in CI
- Small projects with predictable, stable load
- Internal tools with a handful of users
You need more (Kubernetes, ECS, or similar) when:
- You need to run across multiple hosts for availability
- You need auto-scaling based on load
- You need rolling updates with zero downtime
- You have dozens of services with complex networking
- You need fine-grained resource management across teams
- You need self-healing (automatic restart on different hosts when hardware fails)
Many successful companies run production on Docker Compose for years before needing an orchestrator. Do not prematurely graduate to Kubernetes.
Common Pitfalls
- Hardcoding secrets in compose.yml -- Use
.envfiles (excluded from Git) or Docker secrets. Never commit passwords to source control, even in a compose file. - Forgetting health checks on depends_on -- Without
condition: service_healthy, your app starts before the database is ready and crashes. Always pairdepends_onwith health checks. - Using
latesttags --image: postgres:latesttoday might be Postgres 16. Next month it might be 17 with breaking changes. Pin versions. - Not using volumes for database data -- Without a named volume,
docker compose downdestroys your database. Always define named volumes for stateful services. - Bind-mounting node_modules -- Host
node_modulesoverwrites the container's. Use an anonymous volume to prevent this. - Running Compose in production without thinking about restarts -- Add
restart: unless-stoppedto production services so they survive host reboots and crashes.
Key Takeaways
- Docker Compose defines multi-container applications in a single YAML file and starts them with one command
- Use the modern
compose.ymlformat (noversionkey, Compose Specification syntax) - Services communicate by service name on the default network -- no manual networking needed for most cases
depends_onwithcondition: service_healthyprevents startup race conditions- Bind mounts enable live-reload development; named volumes persist data across restarts
- Compose is sufficient for local development, CI testing, and single-server production deployments
- Graduate to an orchestrator when you need multi-host, auto-scaling, or zero-downtime deployments