Docker Compose in Production: When You Don't Need Kubernetes
Run production workloads with Docker Compose using restart policies, healthchecks, resource limits, and log rotation without orchestration complexity.
Kubernetes is powerful, but it’s also a sledgehammer for small teams. If you’re running 5-20 services on a single VPS or a handful of machines, Docker Compose can run production workloads reliably. The catch: you need to understand its safety rails — restart policies, healthchecks, resource limits, secrets, log rotation, and backup strategies. Done right, Compose is boring and predictable. Done wrong, 3 AM pages are inevitable.
This guide walks you through production-ready Compose configurations, shows you when Compose starts to break, and helps you recognize the moment you should move to a proper orchestrator.
Restart Policies: Keep Services Up
The simplest production guarantee is restart policy. When a container dies, Compose restarts it.
version: "3.9"
services:
api:
image: myapp:latest
restart_policy:
condition: on-failure
max_retries: 5
delay: 10s
# If the container exits with a non-zero code, Compose restarts it
# After 5 failures in a row, it gives up and stops restarting
Restart policy options:
| Policy | Behavior | Use Case |
|---|---|---|
no | Do not automatically restart | Development, one-off tasks |
always | Restart regardless of exit code | Production services that must run |
on-failure | Restart only on non-zero exit | Services that fail gracefully |
unless-stopped | Like always but respect manual stop | Long-running daemons |
services:
web:
image: nginx:latest
restart_policy:
condition: always
# Restarts forever, even if it crashes 1000 times
worker:
image: job-processor:latest
restart_policy:
condition: on-failure
max_retries: 5
# Restarts up to 5 times on failure, then stops
Gotcha: If a service has a startup bug, restart: always will spin the CPU at 100% restarting every second. Use on-failure with max_retries to avoid this.
Healthchecks: Know When a Container is Actually Healthy
Restart policies restart dead containers, but what about containers that are running but unresponsive? A deadlocked database or a web server in a bad state will stay running even though it’s serving errors.
Healthchecks run periodically inside the container and report whether it’s healthy. Orchestrators (and Compose) use this signal to restart unhealthy containers.
version: "3.9"
services:
postgres:
image: postgres:17
environment:
POSTGRES_PASSWORD: secret
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 3
start_period: 40s
# If pg_isready fails 3 times in a row, the container is unhealthy
api:
image: myapp:latest
depends_on:
postgres:
condition: service_healthy
# Wait for postgres to be healthy before starting the API
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 3s
retries: 2
# Every 30 seconds, curl the health endpoint
# If it fails twice, mark the container unhealthy
Healthcheck fields:
test: command to run.CMD-SHELLfor shell scripts,CMDfor exec form.interval: how often to check (default 30s).timeout: how long to wait for the test to respond (default 30s).retries: consecutive failures before marking unhealthy (default 3).start_period: grace period before first check (default 0s). Use this for slow startup services.
Resource Limits: Prevent One Service from Starving Others
Without limits, a runaway service can consume all CPU and memory, crashing the entire host.
services:
api:
image: myapp:latest
deploy:
resources:
limits:
cpus: "0.5"
memory: 512M
reservations:
cpus: "0.25"
memory: 256M
# Hard limit: API can use at most 0.5 CPUs and 512 MB
# If it exceeds these, Docker kills it
# Reservation: system reserves 0.25 CPUs and 256 MB for this container
# (Reservation doesn't guarantee, but tells the scheduler to account for it)
When a container hits its memory limit, Docker kills it immediately (OOMKilled). Combine this with a restart policy:
api:
restart_policy:
condition: on-failure
deploy:
resources:
limits:
memory: 512M
# If the API leaks memory and hits 512M, Docker kills it
# on-failure restarts it, and the cycle repeats
# This buys time for alerts to fire, but isn't a fix for the leak
CPU limits are softer: Docker throttles the process instead of killing it. A process limited to 0.5 CPUs will run at half speed instead of using full CPU.
Secrets and Environment Variables
Never hardcode credentials in Compose files. Use secrets or external secret stores.
Secrets (Docker Secrets)
Docker secrets are encrypted at rest and decrypted only inside containers that need them. They’re mounted as files (default /run/secrets/<name>).
secrets:
db_password:
file: ./db_password.txt # File contains "super-secret-pass"
api_key:
file: ./api_key.txt
services:
postgres:
image: postgres:17
secrets:
- db_password
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
# Inside container: /run/secrets/db_password contains the password
api:
image: myapp:latest
secrets:
- api_key
environment:
API_KEY_FILE: /run/secrets/api_key
Limitations:
- Only available in swarm mode (multi-node setups)
- Requires a swarm to be initialized
- Not ideal for single-node production
Environment Files
For single-node production, use a .env file and .gitignore it:
# .env (gitignored!)
DB_PASSWORD=super-secret-pass
API_KEY=secret-api-key
services:
api:
image: myapp:latest
env_file: .env
# Loads all variables from .env
External Secret Store (Best Practice)
For real security, use Vault, AWS Secrets Manager, or similar:
services:
api:
image: myapp:latest
environment:
VAULT_ADDR: https://vault.example.com
VAULT_TOKEN: ${VAULT_TOKEN} # Set by the host
entrypoint: /bin/sh
command: |
-c "
export DB_PASSWORD=$(curl -H "X-Vault-Token: $$VAULT_TOKEN" $$VAULT_ADDR/v1/secret/data/db | jq -r '.data.data.password')
exec /app/start.sh
"
Log Rotation: Prevent Logs from Filling the Disk
Container logs can grow unchecked. Without rotation, your disk will fill in days or hours.
services:
api:
image: myapp:latest
logging:
driver: json-file
options:
max-size: "100m"
max-file: "3"
labels: "service=api"
# Logs are JSON (one object per line)
# Maximum file size: 100 MB
# Keep maximum 3 old files (rotate on the 4th)
# So: api.1.json (100MB) -> api.2.json (100MB) -> api.3.json (varies)
To view rotated logs:
# Current log
docker logs api
# Compressed archives (if kept)
ls -la /var/lib/docker/containers/$(docker inspect -f '{{.Id}}' api)/
Better: ship logs to a centralized store (Loki, ELK, CloudWatch):
services:
api:
logging:
driver: loki
options:
loki-url: "http://loki:3100/loki/api/v1/push"
loki-batch-size: "400"
Updating a Single Service Without Recreating Everything
The --no-deps flag updates only one service, leaving others running. This is critical for zero-downtime updates on a reverse proxy (Traefik, Nginx).
# Update only the API, leave postgres and redis running
docker compose up -d --no-deps --build api
# No-deps means: don't restart services that api depends on
# The postgres and redis containers stay up and running
# This is fast: 1-3 seconds instead of 30 seconds to recreate everything
This works with restart: always and healthchecks:
- New API container starts and registers with reverse proxy
- Reverse proxy detects it and routes new requests to it
- Old API container drains existing connections (if configured with grace period)
- Old container stops
For this to work smoothly, your reverse proxy needs to detect container changes in real time. Traefik does this automatically via Docker socket. Nginx requires a sidecar (or manual config reload).
Database Backups: Prevent Data Loss
Databases are your most critical asset. Backup them regularly and test restores.
services:
postgres:
image: postgres:17
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD: secret
backup:
image: postgres:17
depends_on:
postgres:
condition: service_healthy
volumes:
- postgres_data:/postgres_data:ro
- ./backups:/backups
entrypoint: |
/bin/sh -c "
BACKUP_FILE=/backups/postgres_backup_\$(date +%Y%m%d_%H%M%S).sql.gz
pg_dump -h postgres -U postgres | gzip > \$$BACKUP_FILE
echo 'Backup written to '\$$BACKUP_FILE
"
# Runs once at startup (no restart policy)
# In production, use a cron job or a sidecar that sleeps + backs up periodically
Better: use a cron service or a dedicated backup container:
# From the host, run daily backups at 2 AM
# Add to crontab:
0 2 * * * docker compose -f /path/to/docker-compose.yml \
exec -T postgres pg_dump -U postgres | gzip > \
/backups/postgres_backup_$(date +\%Y\%m\%d).sql.gz
Test restores regularly:
# Restore to a test container
docker run -e POSTGRES_PASSWORD=secret --rm -i postgres:17 \
psql -h <postgres-host> -U postgres -d postgres \
< backups/postgres_backup_20260521.sql.gz
When Compose Stops Being Enough
Docker Compose is great until it isn’t. You’ll know it’s time to move to Kubernetes or another orchestrator when:
-
Multiple machines — Compose runs on one host. If you need to distribute services across many machines for redundancy, you need an orchestrator.
-
Automatic scaling — you need more API instances based on CPU load. Compose doesn’t scale services dynamically.
-
Zero-downtime rolling updates at scale — Compose can do blue-green deployments on one host, but coordinating across many machines requires orchestration.
-
Self-healing across nodes — a machine dies, and all its containers are automatically rescheduled elsewhere. Compose can restart on the same host, but can’t move to another machine.
-
Service mesh requirements — traffic management, circuit breakers, distributed tracing across services. These are Kubernetes abstractions.
-
RBAC and multi-tenant isolation — you need to restrict who can deploy what and isolate workloads per customer. Kubernetes namespaces and policies handle this.
Until then, Compose on a single powerful machine with good backups, monitoring, and documented runbooks is a perfectly valid production architecture.
Key Takeaways
- Restart policies keep services up — use
on-failurewithmax_retriesto avoid thrashing - Healthchecks detect unresponsive containers — use them with
depends_onto coordinate startup - Resource limits prevent one service from crashing the host — set both
limitsandreservations - Encrypt secrets at rest — use Docker secrets or external stores, never hardcode in Compose
- Log rotation prevents disk fill — use
json-filedriver withmax-sizeandmax-fileoptions --no-depsupdates single services quickly — essential for zero-downtime deploys on one host- Backup databases regularly and test restores — more important than any code change
- Monitor and alert on container restarts — repeated restarts signal a deeper problem
- Compose scales vertically, not horizontally — one big machine instead of many small ones
- Move to Kubernetes when you need multiple machines or auto-scaling — until then, Compose is simpler