Docker Compose — Multi-Container Apps in One File
You have a web app that needs Postgres, Redis, and Nginx. Running four separate docker run commands with all their flags is painful. Forgetting one flag breaks everything. Docker Compose lets you define your entire application stack in a single YAML file and manage it with one command.
Why Compose Exists
Without Compose, running a multi-container app looks like this:
# Create network
docker network create myapp
# Start database
docker run -d --name db --network myapp -v pg-data:/var/lib/postgresql/data -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=myapp postgres:16-alpine
# Start cache
docker run -d --name redis --network myapp -v redis-data:/data redis:7-alpine redis-server --appendonly yes
# Start app
docker run -d --name app --network myapp -p 3000:3000 -e DATABASE_URL=postgresql://postgres:secret@db:5432/myapp -e REDIS_URL=redis://redis:6379 myapp:latest
One typo in any of those commands and you are debugging for an hour. Compose replaces all of this with a declarative file that is version-controlled, shareable, and repeatable.
The docker-compose.yml Structure
A Compose file has three top-level sections: services, networks, and volumes.
services:
app:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://postgres:secret@db:5432/myapp
REDIS_URL: redis://redis:6379
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
networks:
- frontend
- backend
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: secret
POSTGRES_DB: myapp
volumes:
- pg-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
networks:
- backend
redis:
image: redis:7-alpine
command: redis-server --appendonly yes
volumes:
- redis-data:/data
networks:
- backend
networks:
frontend:
backend:
volumes:
pg-data:
redis-data:
Building vs Pulling Images
Compose can build images from a Dockerfile or pull them from a registry.
services:
# Build from Dockerfile in current directory
app:
build: .
# Build with custom Dockerfile and context
api:
build:
context: ./services/api
dockerfile: Dockerfile.prod
args:
NODE_ENV: production
# Pull from registry
db:
image: postgres:16-alpine
# Build AND tag for later push
worker:
build: ./worker
image: myregistry.com/worker:latest
# Build all services
docker compose build
# Build a specific service
docker compose build api
# Build without cache
docker compose build --no-cache
depends_on and Health Check Ordering
depends_on controls startup order, but by default it only waits for the container to start, not for the service to be ready. A Postgres container starts in milliseconds, but the database takes seconds to accept connections.
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: secret
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
api:
build: .
depends_on:
db:
condition: service_healthy # Waits until healthcheck passes
Without condition: service_healthy, your app will crash on startup because the database is not ready yet, and you will wonder why it works "sometimes."
Environment Variables and .env Files
Hardcoding secrets in your Compose file is a bad idea. Use .env files instead.
# .env file in the same directory as docker-compose.yml
POSTGRES_PASSWORD=supersecret
POSTGRES_DB=production
REDIS_PASSWORD=anothersecret
APP_PORT=3000
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
app:
build: .
ports:
- "${APP_PORT}:3000"
env_file:
- .env.app # Load all variables from this file into the container
# Verify variable substitution before starting
docker compose config
The docker compose config command is invaluable. It shows the fully resolved Compose file with all variables substituted, so you can catch mistakes before they cause runtime failures.
Profiles: Optional Services
Not every service should run every time. Use profiles to group optional services.
services:
app:
build: .
ports:
- "3000:3000"
db:
image: postgres:16-alpine
volumes:
- pg-data:/var/lib/postgresql/data
# Only starts when 'debug' profile is active
adminer:
image: adminer:latest
ports:
- "8080:8080"
profiles:
- debug
# Only starts when 'monitoring' profile is active
prometheus:
image: prom/prometheus:latest
ports:
- "9090:9090"
profiles:
- monitoring
volumes:
pg-data:
# Start only core services (app + db)
docker compose up -d
# Start core services + debug tools
docker compose --profile debug up -d
# Start everything
docker compose --profile debug --profile monitoring up -d
Essential Compose Commands
# Start all services in background
docker compose up -d
# Start and rebuild if Dockerfile changed
docker compose up -d --build
# Stop all services (keeps volumes)
docker compose down
# Stop and remove volumes (DESTROYS DATA)
docker compose down -v
# View logs (all services)
docker compose logs -f
# View logs for one service
docker compose logs -f api
# Execute command in running service
docker compose exec db psql -U postgres
# Run a one-off command in a new container
docker compose run --rm api npm test
# Scale a service
docker compose up -d --scale worker=3
# Check service status
docker compose ps
Real-World Example: WordPress + MySQL + Redis
Here is a production-style WordPress setup with object caching and proper health checks.
services:
wordpress:
image: wordpress:6-apache
ports:
- "8080:80"
environment:
WORDPRESS_DB_HOST: mysql
WORDPRESS_DB_USER: wp_user
WORDPRESS_DB_PASSWORD: ${WP_DB_PASSWORD}
WORDPRESS_DB_NAME: wordpress
volumes:
- wp-content:/var/www/html/wp-content
depends_on:
mysql:
condition: service_healthy
restart: unless-stopped
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: wordpress
MYSQL_USER: wp_user
MYSQL_PASSWORD: ${WP_DB_PASSWORD}
volumes:
- mysql-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
redis:
image: redis:7-alpine
command: redis-server --maxmemory 64mb --maxmemory-policy allkeys-lru
volumes:
- redis-data:/data
restart: unless-stopped
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./certs:/etc/nginx/certs:ro
depends_on:
- wordpress
restart: unless-stopped
volumes:
wp-content:
mysql-data:
redis-data:
# Create .env file
echo "WP_DB_PASSWORD=changeme123" > .env
echo "MYSQL_ROOT_PASSWORD=rootsecret" >> .env
# Launch the stack
docker compose up -d
# Check everything is healthy
docker compose ps
Wrapping Up
Docker Compose transforms chaotic docker run commands into clean, declarative infrastructure. It handles networking automatically (every Compose project gets its own network), manages volumes, respects startup ordering with health checks, and gives you a single source of truth for your application stack.
In the next post, we will focus on Dockerfile Best Practices — 12 rules that separate amateur container images from production-grade ones, with before-and-after examples that show the real difference.
