Docker Compose for Multi-Container Applications: A Practical Guide
If you've been running Docker containers with docker run one at a time, you're missing the real power of containerization. Real-world applications are rarely a single container — they're a web server, a database, a cache, a queue worker, and more. Docker Compose is the tool that ties them all together with a single YAML file.
This guide walks through a complete multi-container setup, from a basic web + database stack to production-ready patterns with health checks, networking, and environment management.
Why Not Just docker run?
Managing multiple containers manually is tedious. Each docker run call needs port mappings, volume mounts, network assignments, and environment variables. You end up with a shell script that's impossible to maintain:
# The manual approach — don't do this
docker network create myapp
docker volume create pgdata
docker run -d --name db --network myapp \
-v pgdata:/var/lib/postgresql/data \
-e POSTGRES_PASSWORD=secret \
postgres:16
docker run -d --name redis --network myapp redis:7-alpine
docker run -d --name api --network myapp \
-p 3000:3000 \
-e DATABASE_URL=postgres://... \
myapp-api
Docker Compose replaces all of that with a declarative YAML file that you can version-control, review, and share.
Your First Compose File
Here's a typical web application with a Python API, PostgreSQL, and Redis:
# docker-compose.yml
version: "3.9"
services:
api:
build: ./api
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://app:secret@db:5432/appdb
- REDIS_URL=redis://redis:6379/0
depends_on:
- db
- redis
db:
image: postgres:16-alpine
volumes:
- pgdata:/var/lib/postgresql/data
environment:
- POSTGRES_USER=app
- POSTGRES_PASSWORD=secret
- POSTGRES_DB=appdb
redis:
image: redis:7-alpine
volumes:
pgdata:
Run it with a single command:
docker compose up -d
Docker Compose automatically creates a network for all services, so they can reach each other by service name (db, redis). No manual network setup needed.
Key Concepts
Service Discovery
Compose creates a default network where each service is reachable by its name. Your API connects to db:5432 and redis:6379 — no need to look up IPs or manage /etc/hosts.
Volumes
Named volumes (like pgdata above) persist data across container restarts. Bind mounts are better for development with hot-reload:
services:
api:
build: ./api
volumes:
- ./api/src:/app/src # bind mount for live code reload
Dependency Ordering
depends_on controls startup order but doesn't wait for the service to be ready. For that, you need health checks.
Health Checks and Startup Ordering
PostgreSQL takes a few seconds to start. If your API connects before the database is ready, it crashes. Solve this with health checks:
services:
db:
image: postgres:16-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
interval: 5s
timeout: 5s
retries: 5
api:
build: ./api
depends_on:
db:
condition: service_healthy
ports:
- "3000:3000"
Now Compose waits until pg_isready returns success before starting the API.
Networking: Custom Networks
For security, isolate your database on an internal network that's not exposed to the internet:
services:
nginx:
image: nginx:alpine
ports:
- "80:80"
networks:
- frontend
api:
build: ./api
networks:
- frontend
- backend
db:
image: postgres:16-alpine
networks:
- backend
networks:
frontend:
backend:
internal: true # no external access
Nginx can reach the API, but only the API can reach the database. The database has no route to the outside world.
Environment Management with .env
Never hardcode secrets in docker-compose.yml. Use a .env file:
# .env
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
DB_NAME=appdb
API_PORT=3000
# docker-compose.yml
services:
db:
image: postgres:16-alpine
environment:
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${DB_NAME}
Compose automatically reads .env from the project directory. For production, use Docker secrets or an external vault.
Profiles: Dev vs Production
Use profiles to include dev-only services like a database admin tool:
services:
db:
image: postgres:16-alpine
# ...
pgadmin:
image: dpage/pgadmin4
profiles:
- dev
ports:
- "5050:80"
environment:
- PGADMIN_DEFAULT_EMAIL=dev@local
- PGADMIN_DEFAULT_PASSWORD=dev
# Run with dev tools
docker compose --profile dev up -d
# Production — no pgadmin
docker compose up -d
Resource Limits
Prevent runaway containers from starving the host:
services:
api:
build: ./api
deploy:
resources:
limits:
cpus: "0.5"
memory: 512M
reservations:
cpus: "0.25"
memory: 256M
Real-World Pattern: Full Stack App
Here's a complete example combining everything:
# docker-compose.yml
version: "3.9"
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- api
networks:
- frontend
logging: *default-logging
api:
build:
context: ./api
target: production
expose:
- "3000"
environment:
- DATABASE_URL=postgresql://app:${DB_PASSWORD}@db:5432/appdb
- REDIS_URL=redis://redis:6379/0
- SECRET_KEY=${SECRET_KEY}
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
networks:
- frontend
- backend
deploy:
resources:
limits:
memory: 512M
restart: unless-stopped
logging: *default-logging
worker:
build:
context: ./api
target: production
command: celery -A tasks worker --loglevel=info
environment:
- REDIS_URL=redis://redis:6379/0
depends_on:
- redis
networks:
- backend
deploy:
resources:
limits:
memory: 256M
logging: *default-logging
db:
image: postgres:16-alpine
volumes:
- pgdata:/var/lib/postgresql/data
environment:
- POSTGRES_USER=app
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=appdb
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
interval: 5s
timeout: 5s
retries: 5
networks:
- backend
restart: unless-stopped
logging: *default-logging
redis:
image: redis:7-alpine
volumes:
- redisdata:/data
networks:
- backend
restart: unless-stopped
logging: *default-logging
volumes:
pgdata:
redisdata:
networks:
frontend:
backend:
internal: true
Common Commands
| Command | What it does |
|---|---|
docker compose up -d | Start all services in background |
docker compose down -v | Stop and remove volumes |
docker compose logs -f api | Follow logs for a specific service |
docker compose exec api bash | Shell into a running container |
docker compose ps | List running services and ports |
docker compose build --no-cache | Rebuild images from scratch |
Pitfalls to Avoid
- Hardcoding secrets — always use
.envor Docker secrets. Never commit.envto git. - Missing health checks —
depends_onalone is not enough. Your app will crash on startup if the database isn't ready. - No resource limits — a single container with a memory leak can bring down the entire host.
- Exposing everything — use
internal: truenetworks for databases and caches. - Ignoring restart policies — set
restart: unless-stoppedso services recover from crashes.
Summary
Docker Compose turns a fragile collection of shell scripts into a declarative, version-controlled infrastructure definition. Start with a simple web + database stack, then layer in health checks, custom networks, resource limits, and profiles as your application grows.
The same docker-compose.yml can run on your laptop, a CI runner, or a production server — making it one of the most practical tools in the DevOps toolbox.