TL;DR
- Docker Compose v2 is a Docker CLI plugin (
docker composenotdocker-compose). - Define multi-container apps in
compose.yaml(new default filename). - Profiles for environment-specific services (dev-only tools).
- Health checks + depends_on conditions for proper startup ordering.
- Secrets management built-in for sensitive data.
Step 1: Compose v2 Basics
Docker Compose was invented because managing multi-container applications with individual docker run commands is error-prone and unrepeatable. A single YAML file declares your entire application stack (database, backend, cache, worker) and brings it all up with one command. Compose v2 replaced the Python-based v1 with a Go plugin integrated directly into the Docker CLI, bringing better performance, BuildKit support, and features like profiles and watch mode that make local development feel seamless.
v1 vs v2
| Feature | v1 (Legacy) | v2 (Current) |
|---|---|---|
| Command | docker-compose up |
docker compose up |
| Config file | docker-compose.yml |
compose.yaml |
| Implementation | Python standalone binary | Go plugin for Docker CLI |
| Performance | Slower | Faster |
| Features | Limited | Profiles, watch mode, include |
Basic compose.yaml
# compose.yaml
services:
app:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgres://postgres:password@db:5432/myapp
depends_on:
db:
condition: service_healthy
volumes:
- ./src:/app/src # Hot reload in development
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: password
POSTGRES_DB: myapp
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
volumes:
postgres_data:
Step 2: Health Checks & Dependency Ordering
Health checks exist because depends_on alone is dangerously misleading — it only waits for the container to start, not for the service inside to be ready. Your Node.js app starts in 200ms but PostgreSQL needs 3 seconds to accept connections, causing connection refused errors on first requests. Health checks + depends_on: condition: service_healthy solve this properly, and they're also what orchestrators like Kubernetes use to decide whether to route traffic to your container.
Without health checks, depends_on only waits for the container to start — not for the service inside to be ready.
depends_on Conditions
services:
app:
depends_on:
db:
condition: service_healthy # Wait until healthy
redis:
condition: service_healthy
migrations:
condition: service_completed_successfully # Wait until exit 0
Common Health Checks
# PostgreSQL
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
# Redis
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
# HTTP service
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 15s
# MySQL
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 5s
timeout: 3s
retries: 5
Step 3: Profiles — Environment-Specific Services
Profiles solve the problem of having services that should only run in certain contexts: a debug dashboard in development, a metrics exporter in staging, or a seed-data service that runs once. Without profiles, teams maintained multiple compose files or commented services in and out. Profiles let you tag services and activate groups with --profile dev or --profile monitoring, keeping one source of truth for your entire stack.
Profiles let you define services that only run in specific contexts:
services:
app:
build: .
ports: ["3000:3000"]
# No profile = always runs
db:
image: postgres:16-alpine
# No profile = always runs
# Dev-only tools
adminer:
image: adminer
ports: ["8080:8080"]
profiles: ["dev"] # Only runs with --profile dev
mailhog:
image: mailhog/mailhog
ports: ["1025:1025", "8025:8025"]
profiles: ["dev"]
# Testing tools
test-runner:
build:
context: .
target: test
profiles: ["test"]
# Monitoring (production-like)
prometheus:
image: prom/prometheus
profiles: ["monitoring"]
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
Usage
# Start core services only
docker compose up
# Start with dev tools
docker compose --profile dev up
# Start with multiple profiles
docker compose --profile dev --profile monitoring up
# Run tests
docker compose --profile test run test-runner
Step 4: Secrets Management
Secrets management in Compose exists because environment variables for sensitive data (passwords, API keys) are visible in docker inspect, process listings, and often end up in version control. Docker secrets mount sensitive values as files at /run/secrets/, which are tmpfs-backed (never written to disk), only accessible to services that declare them, and never appear in image layers or container metadata.
File-Based Secrets
services:
app:
build: .
secrets:
- db_password
- api_key
environment:
# Read from mounted secret files at runtime
DB_PASSWORD_FILE: /run/secrets/db_password
secrets:
db_password:
file: ./secrets/db_password.txt
api_key:
file: ./secrets/api_key.txt
Secrets are mounted as files at /run/secrets/<name> inside the container.
Reading Secrets in App
import { readFileSync } from "fs";
function getSecret(name: string): string {
const secretPath = `/run/secrets/${name}`;
try {
return readFileSync(secretPath, "utf8").trim();
} catch {
// Fallback to environment variable in development
return process.env[name.toUpperCase()] ?? "";
}
}
const dbPassword = getSecret("db_password");
Step 5: Override Files & Environments
Override files let you maintain a single base docker-compose.yml while layering environment-specific changes on top. This was invented because development needs volume mounts and debug ports, staging needs different resource limits, and production needs replicas and health checks — but 90% of the service definitions are identical. Compose automatically merges docker-compose.override.yml on top of the base file, and you can chain multiple files with -f flags.
Development Override
# compose.yaml — base (production-like)
services:
app:
image: myapp:latest
ports: ["3000:3000"]
# compose.override.yaml — auto-loaded in development
services:
app:
build: .
volumes:
- ./src:/app/src # Hot reload
- ./nodemon.json:/app/nodemon.json
command: ["npm", "run", "dev"]
environment:
- NODE_ENV=development
- DEBUG=app:*
# Development (auto-loads compose.override.yaml)
docker compose up
# Production (explicitly skip override)
docker compose -f compose.yaml up
# Custom override for CI
docker compose -f compose.yaml -f compose.ci.yaml up
Step 6: Watch Mode (Live Reload)
Watch mode was added in Compose v2.22 because bind-mounting source code with volumes has pain points: file permission issues, slow filesystem performance on macOS/Windows, and .node_modules conflicts. Watch mode uses file-syncing to push changes into running containers without volumes, and can trigger rebuilds or restarts based on which files changed. It gives you hot-reload developer experience with container isolation.
Docker Compose v2.22+ has watch for file-syncing without volume mounts:
services:
app:
build: .
develop:
watch:
# Sync source code changes
- action: sync
path: ./src
target: /app/src
# Rebuild on dependency changes
- action: rebuild
path: ./package.json
# Restart on config changes
- action: sync+restart
path: ./config
target: /app/config
# Start with file watching
docker compose watch
Step 7: Full Production Stack Example
A production-ready compose file brings together everything: health checks ensuring dependency ordering, resource limits preventing runaway containers, restart policies for self-healing, named volumes for data persistence, networks for service isolation, and proper logging configuration. This example shows how a real application stack (API + database + cache + worker) is structured with all the production concerns addressed in one declarative file.
# compose.yaml — Production-ready 4-service stack
services:
app:
build:
context: .
target: runner
args:
NODE_ENV: production
ports: ["3000:3000"]
environment:
DATABASE_URL: postgres://postgres:${DB_PASSWORD}@db:5432/myapp
REDIS_URL: redis://redis:6379
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
deploy:
replicas: 2
restart_policy:
condition: on-failure
max_attempts: 3
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/health"]
interval: 10s
timeout: 5s
retries: 3
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: myapp
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
redis:
image: redis:7-alpine
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
nginx:
image: nginx:alpine
ports: ["80:80", "443:443"]
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./certs:/etc/nginx/certs:ro
depends_on:
app:
condition: service_healthy
volumes:
postgres_data:
redis_data:
Essential Commands
# Start services (detached)
docker compose up -d
# View logs
docker compose logs -f app
# Scale a service
docker compose up -d --scale app=3
# Rebuild after Dockerfile changes
docker compose up -d --build
# Stop everything
docker compose down
# Stop and remove volumes (⚠️ data loss)
docker compose down -v
# Execute command in running container
docker compose exec app sh
# Run one-off command
docker compose run --rm app npm test
Interview Questions
-
What's the difference between
depends_onand health checks?depends_onalone only waits for container start. Withcondition: service_healthy, it waits until the health check passes — ensuring the service is actually ready.
-
What are Compose profiles?
- Labels on services that make them opt-in. Services without profiles always run. Services with profiles only start when that profile is activated with
--profile.
- Labels on services that make them opt-in. Services without profiles always run. Services with profiles only start when that profile is activated with
-
How do you manage secrets in Docker Compose?
- Define secrets pointing to files. They're mounted at
/run/secrets/inside containers — never in environment variables or image layers.
- Define secrets pointing to files. They're mounted at
-
What's the difference between
volumesandwatchmode?- Volumes mount host directories directly (can have performance issues on macOS/Windows). Watch mode syncs files and can trigger rebuilds or restarts on specific changes.