Docker Compose is often introduced as a development convenience -- a way to spin up a database alongside your app with a single command. But Compose is far more capable than that. With the right patterns, it can drive reliable production deployments for single-host services, staging environments, and even small-scale microservice stacks behind a reverse proxy. The key is treating your docker-compose.yml with the same rigor you would apply to Kubernetes manifests or Terraform configs.
This guide covers the patterns that separate a throwaway dev setup from a production-grade Compose configuration. If you are new to Docker, start with The Complete Guide to Docker for foundational concepts.
The most common mistake in Compose files is assuming that depends_on means "wait until the dependency is ready." By default, depends_on only waits until the container has started -- not until the service inside it is accepting connections.
# This does NOT wait for Postgres to be ready
services:
app:
image: myapp:latest
depends_on:
- db
db:
image: postgres:16
Your app container starts as soon as the Postgres container is running, but Postgres might still be initializing its data directory. The app crashes, restarts, and you get a race condition on every deployment.
services:
app:
image: myapp:latest
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
restart: unless-stopped
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
restart: unless-stopped
With condition: service_healthy, Compose waits until the health check passes before starting the dependent service. The start_period gives the container grace time during startup before health check failures count against the retry limit.
# MySQL / MariaDB
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
# MongoDB
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 10s
timeout: 5s
retries: 5
# Nginx / HTTP services
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost/ || exit 1"]
interval: 10s
timeout: 5s
retries: 3
# RabbitMQ
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "check_running"]
interval: 15s
timeout: 10s
retries: 5
start_period: 30s
Tip: Avoid using
wgetorcurlin health checks when the image does not include them. TheCMDform with native CLI tools (likepg_isready,redis-cli,mysqladmin) is more reliable and does not require installing extra packages in your images.
Hardcoding credentials in docker-compose.yml is a security risk and makes multi-environment deployment impossible. Compose offers several layers of variable management.
Compose automatically reads a .env file in the same directory as docker-compose.yml:
# .env
POSTGRES_USER=rawops
POSTGRES_PASSWORD=s3cur3-p4ssw0rd
POSTGRES_DB=myapp
APP_PORT=3000
# docker-compose.yml
services:
db:
image: postgres:16
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
When different services need different variables, use env_file to load from separate files:
services:
app:
image: myapp:latest
env_file:
- .env
- ./config/app.env
worker:
image: myapp:latest
command: ["node", "worker.js"]
env_file:
- .env
- ./config/worker.env
For production, Docker Secrets are more secure than environment variables. Secrets are mounted as files inside the container rather than being visible in process environment or docker inspect output:
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
app:
image: myapp:latest
secrets:
- db_password
- api_key
secrets:
db_password:
file: ./secrets/db_password.txt
api_key:
file: ./secrets/api_key.txt
The application reads the secret from /run/secrets/db_password instead of an environment variable. Many official images (Postgres, MySQL, MariaDB) support the _FILE suffix convention natively.
Rules for secret management:
.env files with real credentials -- add them to .gitignore.env.example with placeholder values as documentationRunning the same Compose stack across development, staging, and production requires a strategy for managing configuration differences.
Compose automatically merges docker-compose.yml with docker-compose.override.yml when both exist:
# docker-compose.yml -- base configuration (production defaults)
services:
app:
image: registry.example.com/myapp:latest
restart: unless-stopped
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
db:
image: postgres:16
volumes:
- db_data:/var/lib/postgresql/data
restart: unless-stopped
volumes:
db_data:
# docker-compose.override.yml -- development overrides (auto-loaded)
services:
app:
build: .
volumes:
- ./src:/app/src
ports:
- "3000:3000"
environment:
NODE_ENV: development
db:
ports:
- "5432:5432"
In development, docker compose up merges both files. The override adds build context, source mounts for hot-reload, exposed ports, and development environment variables. In production, you explicitly skip the override:
# Development (auto-merges override)
docker compose up
# Production (base only)
docker compose -f docker-compose.yml up -d
# Staging (base + staging-specific)
docker compose -f docker-compose.yml -f docker-compose.staging.yml up -d
Compose profiles let you define services that only start when explicitly activated:
services:
app:
image: myapp:latest
db:
image: postgres:16
# Only starts with --profile debug
adminer:
image: adminer:latest
ports:
- "8080:8080"
profiles:
- debug
# Only starts with --profile monitoring
prometheus:
image: prom/prometheus:latest
profiles:
- monitoring
grafana:
image: grafana/grafana:latest
profiles:
- monitoring
# Normal start -- only app and db
docker compose up -d
# With database admin tool
docker compose --profile debug up -d
# With monitoring stack
docker compose --profile monitoring up -d
# Everything
docker compose --profile debug --profile monitoring up -d
Profiles are ideal for debug tools, monitoring stacks, and testing services that you do not want running in every environment.
Compose creates a default network for each project, and all services can reach each other by service name. For production, you should go further by isolating service tiers and controlling which services can communicate.
services:
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
networks:
- frontend
app:
image: myapp:latest
networks:
- frontend
- backend
db:
image: postgres:16
networks:
- backend
redis:
image: redis:7-alpine
networks:
- backend
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true
In this setup, Nginx can reach the app (both on frontend), the app can reach the database and Redis (it is on both networks), but Nginx cannot reach the database directly. The internal: true flag on the backend network also prevents containers on that network from accessing the internet -- useful for database containers that should never make outbound connections.
Every service gets a DNS entry matching its service name within its networks. You can add aliases for flexibility:
services:
db:
image: postgres:16
networks:
backend:
aliases:
- postgres
- database
Now other services on the backend network can reach the database as db, postgres, or database.
When running multiple Compose stacks that need to communicate (for example, a shared reverse proxy), use external networks:
# In your Caddy/Nginx reverse proxy stack
networks:
proxy:
name: proxy_network
driver: bridge
# In your application stack
services:
app:
image: myapp:latest
networks:
- proxy
- default
networks:
proxy:
external: true
name: proxy_network
The reverse proxy and the application share the proxy_network, while the application's internal services (database, cache) stay on the default network, invisible to the proxy stack. Generate a production-ready Nginx configuration for this pattern with the Nginx Config Generator.
Without resource limits, a single runaway container can consume all host memory or CPU and take down every other service. Production Compose files should always define limits.
services:
app:
image: myapp:latest
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
reservations:
memory: 256M
cpus: "0.25"
db:
image: postgres:16
deploy:
resources:
limits:
memory: 1G
cpus: "2.0"
reservations:
memory: 512M
cpus: "0.5"
redis:
image: redis:7-alpine
deploy:
resources:
limits:
memory: 256M
cpus: "0.5"
reservations:
memory: 64M
cpus: "0.1"
Key distinctions:
limits -- the hard ceiling. The container is killed (OOMKilled) or throttled if it exceeds these.reservations -- the guaranteed minimum. Docker ensures this much resource is always available to the container.Sizing guidelines:
docker statsdocker inspect --format='{{.State.OOMKilled}}' container_nameNote: The
deploykey works withdocker compose upsince Compose v2. You do not need Docker Swarm mode for resource limits.
Default Docker logging (json-file driver) writes container logs to disk without any size limits. On a busy production server, a verbose application can fill your disk in hours.
services:
app:
image: myapp:latest
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
tag: "myapp-{{.Name}}"
db:
image: postgres:16
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
This limits each service to 5 log files of 10 MB each (50 MB total per service). Without these settings, a single container can produce gigabytes of logs. If you have experienced this, the Disk Space Exhausted runbook covers emergency cleanup.
Instead of repeating logging config on every service, set a default with the top-level x-logging extension and YAML anchors:
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "5"
services:
app:
image: myapp:latest
logging: *default-logging
worker:
image: myapp:latest
command: ["node", "worker.js"]
logging: *default-logging
db:
image: postgres:16
logging: *default-logging
For multi-service stacks, consider forwarding logs to a centralized system:
services:
app:
image: myapp:latest
logging:
driver: syslog
options:
syslog-address: "tcp://logserver:514"
syslog-facility: "daemon"
tag: "myapp"
Alternatives include the fluentd driver for Fluentd/Fluent Bit collectors, the gelf driver for Graylog, or simply running a log shipper as a sidecar container that tails json-file logs and forwards them.
Every production service should have a restart policy. The options are:
services:
# Recommended for most services
app:
restart: unless-stopped
# For critical infrastructure (databases, proxies)
db:
restart: always
# For one-shot tasks (migrations, backups)
migrate:
restart: "no"
command: ["python", "manage.py", "migrate"]
unless-stopped -- restarts on crash and on daemon restart, but stays stopped if you explicitly stopped it. Best for application services.always -- always restarts, even after docker compose stop. Best for critical infrastructure."no" -- never restarts. Best for one-shot initialization or migration containers.When Docker stops a container, it sends SIGTERM and waits for stop_grace_period (default 10 seconds) before sending SIGKILL. Applications that need more time to drain connections or finish processing should increase this:
services:
app:
image: myapp:latest
stop_grace_period: 30s
worker:
image: myapp:latest
command: ["node", "worker.js"]
stop_grace_period: 120s # Long-running jobs need more time
Make sure your application actually handles SIGTERM. A Node.js app needs:
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully');
server.close(() => process.exit(0));
});
Without signal handling, the grace period is wasted and Docker will SIGKILL the process after the timeout.
Control how services are updated during docker compose up -d:
services:
app:
image: registry.example.com/myapp:latest
deploy:
update_config:
order: start-first # Start new container before stopping old one
failure_action: rollback
rollback_config:
order: start-first
resources:
limits:
memory: 512M
With order: start-first, Compose starts the new container and waits for it to be healthy before stopping the old one, achieving near-zero-downtime deployments. If the new container fails its health check, failure_action: rollback reverts to the previous version.
Run database migrations or other setup tasks before the main application starts:
services:
migrate:
image: myapp:latest
command: ["python", "manage.py", "migrate"]
depends_on:
db:
condition: service_healthy
restart: "no"
app:
image: myapp:latest
depends_on:
db:
condition: service_healthy
migrate:
condition: service_completed_successfully
restart: unless-stopped
db:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
restart: unless-stopped
The service_completed_successfully condition ensures the migration finishes with exit code 0 before the app starts. If the migration fails, the app will not start at all -- much better than starting with an outdated schema.
When your Compose stack builds images from source, optimizing the build saves time on every deployment.
services:
app:
build:
context: .
dockerfile: Dockerfile
target: production # Only build up to the production stage
cache_from:
- registry.example.com/myapp:latest
image: registry.example.com/myapp:latest
The target key stops the build at a specific multi-stage target, so development dependencies and test stages are not included in the production image. The cache_from key pulls an existing image to use as a layer cache, dramatically speeding up rebuilds when the base layers have not changed.
Compose v2 builds independent services in parallel by default. You can control this explicitly:
# Build all services in parallel (default behavior)
docker compose build
# Build with increased parallelism
COMPOSE_PARALLEL_LIMIT=4 docker compose build
# Build only specific services
docker compose build app worker
Keep your build-time configuration separate from runtime:
services:
app:
build:
context: .
dockerfile: Dockerfile
args:
NODE_VERSION: "20"
BUILD_DATE: "2026-02-19"
image: myapp:latest
environment:
# Runtime vars -- different from build args
NODE_ENV: production
PORT: "3000"
Build args (args) are available only during the build. Environment variables (environment) are available only at runtime. Never pass secrets as build args -- they are baked into the image layers and visible with docker history. Use the Dockerfile Generator to scaffold secure, multi-stage Dockerfiles that follow these patterns.
Here is a complete production-ready Compose file that applies every pattern discussed in this guide:
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "5"
services:
nginx:
image: nginx:1.27-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/certs:/etc/nginx/certs:ro
networks:
- frontend
depends_on:
app:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost/health || exit 1"]
interval: 10s
timeout: 5s
retries: 3
deploy:
resources:
limits:
memory: 128M
cpus: "0.5"
logging: *default-logging
restart: always
app:
image: registry.example.com/myapp:latest
env_file:
- .env
secrets:
- db_password
- api_key
networks:
- frontend
- backend
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
migrate:
condition: service_completed_successfully
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"]
interval: 10s
timeout: 5s
retries: 3
start_period: 15s
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
reservations:
memory: 256M
update_config:
order: start-first
failure_action: rollback
stop_grace_period: 30s
logging: *default-logging
restart: unless-stopped
migrate:
image: registry.example.com/myapp:latest
command: ["node", "migrate.js"]
env_file:
- .env
secrets:
- db_password
networks:
- backend
depends_on:
db:
condition: service_healthy
logging: *default-logging
restart: "no"
db:
image: postgres:16
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
volumes:
- db_data:/var/lib/postgresql/data
networks:
- backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
deploy:
resources:
limits:
memory: 1G
cpus: "2.0"
reservations:
memory: 512M
logging: *default-logging
restart: always
redis:
image: redis:7-alpine
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: 5
deploy:
resources:
limits:
memory: 256M
cpus: "0.5"
logging: *default-logging
restart: unless-stopped
secrets:
db_password:
file: ./secrets/db_password.txt
api_key:
file: ./secrets/api_key.txt
volumes:
db_data:
redis_data:
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true
Docker Compose is production-capable when you apply the right patterns. The difference between a fragile dev setup and a reliable production deployment comes down to these practices:
condition: service_healthy -- eliminate startup race conditionsenv_file -- keep credentials out of version control and docker inspect outputinternal: true for databasesFor hands-on practice with these patterns:
docker run commands to a structured Compose file