Why Compose?
Consider a typical web application: a frontend, an API server, a database, a cache, a message queue, and perhaps a background worker. Running each component as a separate docker run command with the correct network, volume, and environment configuration is tedious and error-prone. Docker Compose solves this by defining your entire application stack declaratively in a single YAML file.
The Multi-Container Reality
Without Compose, deploying a three-service application requires multiple commands with careful coordination:
# Without Compose: manual multi-container deployment
# Step 1: Create a shared network
docker network create myapp-net
# Step 2: Start the database with volume persistence
docker run -d \
--name postgres \
--network myapp-net \
-v pgdata:/var/lib/postgresql/data \
-e POSTGRES_PASSWORD=secret \
-e POSTGRES_DB=myapp \
postgres:16
# Step 3: Start Redis cache
docker run -d \
--name redis \
--network myapp-net \
redis:7-alpine
# Step 4: Start the application (depends on both services being ready)
docker run -d \
--name webapp \
--network myapp-net \
-p 8000:8000 \
-e DATABASE_URL=postgres://postgres:secret@postgres:5432/myapp \
-e REDIS_URL=redis://redis:6379 \
myapp:latest
# Cleanup requires stopping and removing each container + network + volumes
docker stop webapp redis postgres
docker rm webapp redis postgres
docker network rm myapp-net
With Compose, this entire stack is defined once and managed with a single command:
# With Compose: one command does everything
docker compose up -d # Start all services
docker compose down # Stop and clean up everything
docker compose logs -f # Stream all logs together
compose.yaml file serves as living documentation of your application's architecture, dependencies, and configuration. New team members can understand and run the entire stack in seconds.
Compose File Versions & History
Docker Compose has evolved significantly since its inception as "Fig" in 2014. Understanding this history helps you navigate legacy projects and modern best practices.
| Era | Version | Key Features | Status |
|---|---|---|---|
| 2014 | v1 (Fig) | Basic container linking | Deprecated |
| 2016 | v2 / v2.x | Networks, volumes, build args, healthchecks | Legacy |
| 2017 | v3 / v3.x | Swarm deploy, configs, secrets | Legacy |
| 2020+ | Compose Specification | Unified spec, no version: needed, profiles, watch | Current |
The Compose Specification (Current Standard)
The modern Compose Specification (maintained at compose-spec.io) obsoletes all version numbers. You no longer need a version: key at the top of your file — if present, it's ignored by modern Docker Compose (v2+).
# Modern compose.yaml — NO version: key needed
# The Compose Specification is the current standard
services:
webapp:
image: nginx:alpine
ports:
- "80:80"
# Legacy format (still works but unnecessary):
# version: "3.8"
# services:
# webapp:
# image: nginx:alpine
compose.yaml (preferred) or docker-compose.yml (legacy). Compose searches for files in this order: compose.yaml, compose.yml, docker-compose.yaml, docker-compose.yml.
Core Concepts
A Compose file defines five top-level elements that together describe your application:
flowchart TB
subgraph ComposeFile["compose.yaml"]
S["services:
Define containers"]
N["networks:
Define connectivity"]
V["volumes:
Define persistence"]
C["configs:
Define configuration"]
SE["secrets:
Define credentials"]
end
subgraph Runtime["Docker Engine Runtime"]
S --> C1["Container 1
(webapp)"]
S --> C2["Container 2
(database)"]
S --> C3["Container 3
(cache)"]
N --> NET1["frontend-net"]
N --> NET2["backend-net"]
V --> VOL1["db-data"]
V --> VOL2["cache-data"]
end
C1 -.->|attached to| NET1
C1 -.->|attached to| NET2
C2 -.->|attached to| NET2
C3 -.->|attached to| NET2
C2 -.->|mounts| VOL1
C3 -.->|mounts| VOL2
| Element | Purpose | Maps To |
|---|---|---|
services | Define containers to run (image, build, config) | docker run |
networks | Define how services communicate | docker network create |
volumes | Define persistent data storage | docker volume create |
configs | Define non-sensitive configuration files | Mounted as read-only files |
secrets | Define sensitive data (passwords, keys) | Mounted at /run/secrets/ |
Service Definition Deep Dive
Services are the heart of Compose. Each service defines a container with its image, configuration, resources, and relationships. Here's a comprehensive reference of service options:
# Complete service definition reference
services:
webapp:
# Image source (use image OR build, not both for clarity)
image: myregistry.io/myapp:1.2.3
# Container naming
container_name: myapp-web # Fixed name (avoid in scaled services)
hostname: webapp # Internal hostname
# Port mapping: HOST:CONTAINER
ports:
- "8080:80" # Map host 8080 to container 80
- "443:443" # HTTPS
- "127.0.0.1:9090:9090" # Bind to localhost only
expose:
- "3000" # Expose to other services only (no host mapping)
# Environment configuration
environment:
NODE_ENV: production
DATABASE_URL: postgres://db:5432/myapp
env_file:
- .env # Load from .env file
- .env.local # Override with local values
# Volume mounts
volumes:
- app-data:/data # Named volume
- ./src:/app/src # Bind mount (development)
- ./config:/etc/app:ro # Read-only bind mount
# Network attachment
networks:
- frontend
- backend
# Restart policy
restart: unless-stopped # always | on-failure | no | unless-stopped
# Resource limits (Compose Specification)
deploy:
resources:
limits:
cpus: "2.0"
memory: 512M
reservations:
cpus: "0.5"
memory: 128M
replicas: 3 # Scale to 3 instances
# Dependency ordering
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
# Health monitoring
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# Security options
user: "1000:1000"
read_only: true
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
# Logging configuration
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# Command override
command: ["node", "server.js", "--port=80"]
entrypoint: ["/docker-entrypoint.sh"]
working_dir: /app
Build Configuration
Instead of using pre-built images, Compose can build images from Dockerfiles as part of the deployment process. This is essential for development workflows where you're iterating on the application code.
# Build configuration options
services:
# Simple build (Dockerfile in current directory)
api:
build: .
ports:
- "3000:3000"
# Full build configuration
frontend:
build:
context: ./frontend # Build context directory
dockerfile: Dockerfile.prod # Custom Dockerfile name
args: # Build arguments
NODE_VERSION: "20"
BUILD_DATE: "2026-05-14"
target: production # Multi-stage build target
cache_from:
- myregistry.io/frontend:cache
labels:
- "com.example.version=1.0"
platforms:
- linux/amd64
- linux/arm64
image: myregistry.io/frontend:latest # Tag the built image
ports:
- "80:80"
# Multi-stage build targeting development stage
api-dev:
build:
context: ./api
dockerfile: Dockerfile
target: development # Stop at 'development' stage
volumes:
- ./api/src:/app/src # Mount source for hot-reload
command: ["npm", "run", "dev"]
# Build commands
docker compose build # Build all services with build: config
docker compose build --no-cache api # Rebuild api without cache
docker compose build --parallel # Build services in parallel
docker compose up --build # Build and start (rebuild if changed)
docker compose push # Push built images to registry
Networking in Compose
Compose automatically creates a default network for your project. All services join this network and can reach each other by service name as the DNS hostname. Custom networks provide isolation and control.
flowchart LR
subgraph frontend-net["frontend network"]
NGINX["nginx
(reverse proxy)"]
APP["webapp
(application)"]
end
subgraph backend-net["backend network"]
APP2["webapp
(application)"]
DB["postgres
(database)"]
REDIS["redis
(cache)"]
end
CLIENT["External
Client"] -->|port 80/443| NGINX
NGINX -->|proxy_pass| APP
APP2 -->|port 5432| DB
APP2 -->|port 6379| REDIS
APP -.->|same container
on both networks| APP2
# Multi-network isolation pattern
services:
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
networks:
- frontend # Only on frontend network
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
webapp:
build: ./app
networks:
- frontend # Reachable by nginx
- backend # Can reach database and cache
environment:
DATABASE_URL: postgres://postgres:secret@db:5432/myapp
REDIS_URL: redis://redis:6379
db:
image: postgres:16
networks:
- backend # Only on backend (nginx cannot reach it)
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD: secret
POSTGRES_DB: myapp
redis:
image: redis:7-alpine
networks:
- backend # Only on backend
# Custom network definitions
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true # No external internet access
volumes:
pgdata:
# Network aliases and external networks
services:
webapp:
image: myapp:latest
networks:
backend:
aliases:
- api # Reachable as 'api' AND 'webapp'
- app-service
shared:
aliases:
- myapp
networks:
backend:
driver: bridge
ipam:
config:
- subnet: 172.28.0.0/16 # Custom subnet
shared:
external: true # Use pre-existing network
name: infrastructure_shared # Actual network name
webapp can connect to the database at db:5432 — no IP addresses needed. Docker's embedded DNS server handles resolution automatically.
Volumes in Compose
Volumes provide persistent data storage that survives container restarts and removals. Compose supports named volumes (managed by Docker), bind mounts (host directories), and tmpfs mounts (in-memory).
# Volume strategies: development vs production
services:
db:
image: postgres:16
volumes:
# Named volume: persistent, Docker-managed, survives 'docker compose down'
- pgdata:/var/lib/postgresql/data
# Init scripts: bind mount for database initialization
- ./init-scripts:/docker-entrypoint-initdb.d:ro
webapp:
build: ./app
volumes:
# Development: bind mount source code for hot-reload
- ./src:/app/src
# Development: bind mount for live config changes
- ./config/dev.yaml:/app/config.yaml:ro
# Named volume: persist node_modules (avoid host/container conflicts)
- node_modules:/app/node_modules
# tmpfs: in-memory for temp files (fast, ephemeral)
- type: tmpfs
target: /app/tmp
tmpfs:
size: 100000000 # 100MB limit
redis:
image: redis:7-alpine
volumes:
- redis-data:/data
command: redis-server --appendonly yes # Enable persistence
# Named volume definitions
volumes:
pgdata:
driver: local # Default driver
node_modules: # No options needed
redis-data:
driver: local
driver_opts:
type: none
o: bind
device: /mnt/fast-ssd/redis-data # Custom host path
# Volume management commands
docker compose down # Stop containers (volumes PRESERVED)
docker compose down -v # Stop containers AND delete named volumes
docker compose down --volumes # Same as -v
# List volumes created by this project
docker volume ls --filter "label=com.docker.compose.project=myproject"
# Backup a named volume
docker run --rm -v myproject_pgdata:/data -v $(pwd):/backup \
alpine tar czf /backup/pgdata-backup.tar.gz -C /data .
# Restore a volume from backup
docker run --rm -v myproject_pgdata:/data -v $(pwd):/backup \
alpine tar xzf /backup/pgdata-backup.tar.gz -C /data
Environment Variables
Compose provides multiple mechanisms for injecting configuration into containers. Understanding the precedence order is critical for managing configurations across development, staging, and production environments.
# compose.yaml with environment variables
services:
webapp:
image: myapp:latest
# Method 1: Inline key-value pairs
environment:
NODE_ENV: production
PORT: "3000"
# Variable substitution from host environment or .env file
DATABASE_URL: postgres://${DB_USER}:${DB_PASS}@db:5432/${DB_NAME}
# Default values with :- syntax
LOG_LEVEL: ${LOG_LEVEL:-info}
# Required variable (fails if unset)
API_KEY: ${API_KEY:?API_KEY must be set}
# Method 2: Load from file(s)
env_file:
- .env # Base configuration
- .env.${ENVIRONMENT:-development} # Environment-specific overlay
# .env file (auto-loaded by Compose from project root)
# Used for variable substitution in compose.yaml AND passed to containers
# Database configuration
DB_USER=myapp
DB_PASS=development-password-only
DB_NAME=myapp_dev
# Application settings
LOG_LEVEL=debug
API_KEY=dev-key-not-for-production
# Compose project settings
COMPOSE_PROJECT_NAME=myapp
COMPOSE_PROFILES=debug
Variable Precedence (highest to lowest):
| Priority | Source | Example |
|---|---|---|
| 1 (highest) | Shell environment | DB_PASS=prod docker compose up |
| 2 | environment: in compose.yaml | Inline values override env_file |
| 3 | env_file: contents | Loaded in order, later files override |
| 4 | .env file (auto-loaded) | Default values for substitution |
| 5 (lowest) | Dockerfile ENV | Baked into image |
# Interpolation syntax reference
${VARIABLE} # Simple substitution
${VARIABLE:-default} # Use 'default' if VARIABLE is unset or empty
${VARIABLE-default} # Use 'default' only if VARIABLE is unset
${VARIABLE:+replacement} # Use 'replacement' if VARIABLE is set and non-empty
${VARIABLE:?error msg} # Error with message if VARIABLE is unset or empty
# Validate your interpolated config
docker compose config # Shows the fully resolved compose file
Healthchecks & Dependency Ordering
Container startup order is one of the most misunderstood aspects of Compose. depends_on without conditions only ensures containers start in order — it doesn't wait for services to be ready. Combining healthchecks with condition: service_healthy solves this.
# Proper dependency ordering with healthchecks
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: secret
POSTGRES_DB: myapp
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d myapp"]
interval: 5s
timeout: 5s
retries: 5
start_period: 10s # Grace period before first check
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 3
migrations:
build: ./app
command: ["python", "manage.py", "migrate"]
depends_on:
db:
condition: service_healthy # Wait for DB to accept connections
restart: "no" # Run once and exit
webapp:
build: ./app
ports:
- "8000:8000"
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
migrations:
condition: service_completed_successfully # Wait for migrations to finish
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
volumes:
pgdata:
flowchart LR
DB["db
(postgres)"] -->|service_healthy| MIG["migrations"]
DB -->|service_healthy| APP["webapp"]
REDIS["redis"] -->|service_healthy| APP
MIG -->|service_completed_successfully| APP
APP -->|service_healthy| NGINX["nginx
(reverse proxy)"]
depends_on: [db] without a condition only means "start db container first" — it does NOT wait for PostgreSQL to accept connections. Your app will crash with "connection refused" if it starts before the database is ready. Always use healthchecks with condition: service_healthy.
Dependency conditions available:
| Condition | Waits Until | Use Case |
|---|---|---|
service_started | Container has started (default) | Services that start fast |
service_healthy | Healthcheck passes | Databases, APIs |
service_completed_successfully | Container exits with code 0 | Migrations, setup scripts |
Compose CLI Commands
The docker compose CLI (note: space, not hyphen) is the modern replacement for the legacy docker-compose binary. It's built into Docker Desktop and the Docker CLI plugin.
| Command | Purpose | Common Flags |
|---|---|---|
docker compose up | Create and start all services | -d (detach), --build, --force-recreate |
docker compose down | Stop and remove containers, networks | -v (remove volumes), --rmi all |
docker compose build | Build or rebuild services | --no-cache, --parallel |
docker compose logs | View output from services | -f (follow), --tail=100 |
docker compose exec | Execute command in running container | -it (interactive), -u root |
docker compose run | Run one-off command in new container | --rm, --no-deps |
docker compose ps | List containers and their status | -a (all), --format json |
docker compose restart | Restart services | -t (timeout) |
docker compose config | Validate and show resolved config | --services, --volumes |
docker compose pull | Pull service images | --quiet, --ignore-buildable |
docker compose stop | Stop services (don't remove) | -t (timeout) |
docker compose watch | Watch for changes and sync/rebuild | (Compose 2.22+) |
# Essential workflow commands
# Start everything in background
docker compose up -d
# View logs from specific services
docker compose logs -f webapp db
# Execute a shell in a running service
docker compose exec webapp sh
# Run a one-off command (e.g., database migration)
docker compose run --rm webapp python manage.py migrate
# Scale a service (if no fixed container_name)
docker compose up -d --scale webapp=3
# View resource usage
docker compose top
# Validate compose file without running
docker compose config --quiet && echo "Valid!" || echo "Invalid!"
# Remove everything including volumes and images
docker compose down -v --rmi all
Real-World Project: Flask + Redis + PostgreSQL
Let's build a complete, production-ready multi-service application: a Flask API with Redis caching and PostgreSQL persistence. This demonstrates services, networking, volumes, healthchecks, and proper dependency ordering.
# compose.yaml — Flask + Redis + PostgreSQL
# Production-ready with healthchecks, resource limits, and security
services:
# === PostgreSQL Database ===
db:
image: postgres:16-alpine
container_name: myapp-db
environment:
POSTGRES_USER: ${DB_USER:-myapp}
POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD required}
POSTGRES_DB: ${DB_NAME:-myapp}
volumes:
- pgdata:/var/lib/postgresql/data
- ./init-db:/docker-entrypoint-initdb.d:ro
networks:
- backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-myapp} -d ${DB_NAME:-myapp}"]
interval: 5s
timeout: 5s
retries: 5
start_period: 10s
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
restart: unless-stopped
# === Redis Cache ===
redis:
image: redis:7-alpine
container_name: myapp-redis
command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
volumes:
- redis-data:/data
networks:
- backend
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 3
deploy:
resources:
limits:
memory: 256M
cpus: "0.5"
restart: unless-stopped
# === Flask Application ===
webapp:
build:
context: ./app
dockerfile: Dockerfile
target: production
container_name: myapp-web
ports:
- "${APP_PORT:-8000}:8000"
environment:
FLASK_ENV: production
DATABASE_URL: postgresql://${DB_USER:-myapp}:${DB_PASSWORD}@db:5432/${DB_NAME:-myapp}
REDIS_URL: redis://redis:6379/0
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY required}
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
networks:
- frontend
- backend
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 20s
deploy:
resources:
limits:
memory: 256M
cpus: "1.0"
replicas: 2
restart: unless-stopped
read_only: true
security_opt:
- no-new-privileges:true
# === Celery Worker (Background Tasks) ===
worker:
build:
context: ./app
target: production
command: ["celery", "-A", "app.celery", "worker", "--loglevel=info"]
environment:
DATABASE_URL: postgresql://${DB_USER:-myapp}:${DB_PASSWORD}@db:5432/${DB_NAME:-myapp}
REDIS_URL: redis://redis:6379/0
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
networks:
- backend
deploy:
resources:
limits:
memory: 512M
restart: unless-stopped
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true # No internet access for backend services
volumes:
pgdata:
redis-data:
# .env file for the Flask project
DB_USER=myapp
DB_PASSWORD=change-me-in-production
DB_NAME=myapp
SECRET_KEY=dev-secret-key-change-in-production
APP_PORT=8000
Network Isolation in This Stack
The backend network has internal: true, meaning containers on it cannot reach the internet. The database and Redis are completely isolated — only the webapp and worker (which are on both networks) can reach them. External traffic enters only through the webapp's published port on the frontend network.
Real-World Project: Nginx Reverse Proxy + App + DB
A production-like deployment with Nginx as a reverse proxy handling TLS termination, rate limiting, and static file serving — fronting your application server.
# compose.yaml — Production Nginx + Node.js + PostgreSQL
services:
# === Nginx Reverse Proxy ===
nginx:
image: nginx:1.25-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./certbot/www:/var/www/certbot:ro
- ./certbot/conf:/etc/letsencrypt:ro
- static-files:/var/www/static:ro
depends_on:
app:
condition: service_healthy
networks:
- frontend
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/nginx-health"]
interval: 10s
timeout: 5s
retries: 3
restart: unless-stopped
# === Node.js Application ===
app:
build:
context: ./app
target: production
expose:
- "3000" # Internal only — Nginx proxies to it
environment:
NODE_ENV: production
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/myapp
REDIS_URL: redis://redis:6379
TRUST_PROXY: "true" # Trust Nginx X-Forwarded-* headers
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
networks:
- frontend
- backend
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/health').then(r=>{if(!r.ok)throw Error();process.exit(0)}).catch(()=>process.exit(1))"]
interval: 10s
timeout: 5s
retries: 3
start_period: 15s
deploy:
replicas: 2
resources:
limits:
memory: 256M
cpus: "1.0"
restart: unless-stopped
# === PostgreSQL ===
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
POSTGRES_DB: myapp
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
deploy:
resources:
limits:
memory: 512M
restart: unless-stopped
# === Redis ===
redis:
image: redis:7-alpine
command: redis-server --save 60 1000 --loglevel warning
volumes:
- redis-data:/data
networks:
- backend
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 3
restart: unless-stopped
# === Certbot (Let's Encrypt) ===
certbot:
image: certbot/certbot
volumes:
- ./certbot/www:/var/www/certbot
- ./certbot/conf:/etc/letsencrypt
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done'"
depends_on:
- nginx
profiles:
- production # Only runs in production profile
networks:
frontend:
backend:
internal: true
volumes:
pgdata:
redis-data:
static-files:
# nginx/conf.d/default.conf
upstream app_servers {
# Docker's DNS resolves 'app' to all replicas
server app:3000;
}
server {
listen 80;
server_name example.com;
# Health check endpoint for Compose healthcheck
location /nginx-health {
access_log off;
return 200 "healthy\n";
}
# Certbot challenge
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Redirect to HTTPS
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# Static files served directly by Nginx
location /static/ {
alias /var/www/static/;
expires 1y;
add_header Cache-Control "public, immutable";
}
# Proxy to Node.js application
location / {
proxy_pass http://app_servers;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Profiles & Multiple Environments
Compose profiles let you define optional services that only start when explicitly activated. Combined with override files, this provides powerful environment management without maintaining separate compose files.
# compose.yaml with profiles
services:
webapp:
build: ./app
ports:
- "8000:8000"
# No profiles = always starts
db:
image: postgres:16
# No profiles = always starts
# Debug tools — only with 'debug' profile
pgadmin:
image: dpage/pgadmin4
ports:
- "5050:80"
environment:
PGADMIN_DEFAULT_EMAIL: admin@example.com
PGADMIN_DEFAULT_PASSWORD: admin
profiles:
- debug
# Monitoring — only with 'monitoring' profile
prometheus:
image: prom/prometheus
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
profiles:
- monitoring
grafana:
image: grafana/grafana
ports:
- "3000:3000"
profiles:
- monitoring
# Testing — only with 'test' profile
test-runner:
build:
context: ./app
target: test
command: ["pytest", "--cov=app", "-v"]
depends_on:
db:
condition: service_healthy
profiles:
- test
# Profile activation
docker compose up -d # Only webapp + db (no profiles)
docker compose --profile debug up -d # webapp + db + pgadmin
docker compose --profile monitoring up -d # webapp + db + prometheus + grafana
docker compose --profile debug --profile monitoring up -d # All optional services
# Multiple profiles via environment variable
export COMPOSE_PROFILES=debug,monitoring
docker compose up -d
# Run tests
docker compose run --rm --profile test test-runner
Override files provide environment-specific configuration without modifying the base file:
# compose.override.yaml — automatically loaded for development
# Overrides/extends the base compose.yaml
services:
webapp:
build:
target: development # Use dev stage instead of production
volumes:
- ./src:/app/src # Hot-reload source code
environment:
DEBUG: "true"
LOG_LEVEL: debug
command: ["npm", "run", "dev"]
db:
ports:
- "5432:5432" # Expose DB port for local tools
# File loading behavior
docker compose up # Loads: compose.yaml + compose.override.yaml (auto)
docker compose -f compose.yaml up # Loads: ONLY compose.yaml (skips override)
# Production: explicitly specify files
docker compose -f compose.yaml -f compose.prod.yaml up -d
# Or use COMPOSE_FILE environment variable
export COMPOSE_FILE=compose.yaml:compose.prod.yaml
docker compose up -d
Compose Watch (Development Hot-Reload)
Compose Watch (introduced in Docker Compose 2.22) provides automatic file synchronization and rebuild triggers for development workflows. It replaces manual bind mounts with intelligent sync that handles file events properly.
# compose.yaml with watch configuration
services:
webapp:
build: ./app
ports:
- "3000:3000"
develop:
watch:
# Sync source files without rebuild (hot-reload)
- action: sync
path: ./app/src
target: /app/src
ignore:
- node_modules/
- "**/*.test.js"
# Sync and restart the container (config changes)
- action: sync+restart
path: ./app/config
target: /app/config
# Full rebuild when dependencies change
- action: rebuild
path: ./app/package.json
# Rebuild on Dockerfile changes
- action: rebuild
path: ./app/Dockerfile
frontend:
build: ./frontend
ports:
- "5173:5173"
develop:
watch:
- action: sync
path: ./frontend/src
target: /app/src
- action: rebuild
path: ./frontend/package.json
# Start with watch mode
docker compose watch
# Or start detached services and watch
docker compose up -d
docker compose watch # Watches in foreground, syncs changes
# Watch actions explained:
# sync → Copy changed files into container (no restart)
# sync+restart → Copy files and restart the container process
# rebuild → Trigger full docker compose build + recreate
Exercises
compose.yaml for a WordPress site with MySQL. Include healthchecks for both services, a named volume for database persistence, and proper dependency ordering. Verify the site starts with docker compose up -d.
docker compose exec frontend ping db (should fail).
debug profile adds pgAdmin and Redis Commander, and a test profile runs your test suite. Demonstrate activating different profiles.
compose.yaml and compose.override.yaml pair. The base file defines production settings; the override adds development features (exposed ports, bind mounts, debug mode). Show the resolved configuration with docker compose config.
Conclusion & Next Steps
Docker Compose transforms multi-container chaos into declarative, reproducible application stacks. The key concepts we've mastered:
- Services define what containers to run, how to build them, and what resources they need
- Networks provide service-to-service communication with DNS resolution and isolation
- Volumes persist data beyond container lifecycle with different strategies for development and production
- Healthchecks with
depends_onconditions ensure proper startup ordering and service readiness - Profiles and override files manage environment-specific configuration without file duplication
- Compose Watch provides modern hot-reload for development workflows
However, Compose operates on a single Docker host. It cannot schedule containers across multiple machines, automatically scale based on load, or self-heal when nodes fail. These capabilities require a container orchestrator — and preparing your containers for orchestration is the focus of our next article.
Next in the Series
In Part 19: Container Orchestration Readiness, we'll bridge from Docker to Kubernetes — designing stateless containers, implementing health probes, handling graceful shutdown, and adopting the twelve-factor principles that make containers orchestration-ready.