Home DevOps Docker Compose for Production: The Hard Truths They Don't Tell You
Advanced 4 min · July 11, 2026

Docker Compose for Production: The Hard Truths They Don't Tell You

Docker Compose for production is not just docker-compose up -d.

N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.

Follow
Production
production tested
July 11, 2026
last updated
258
articles · all by Naren
Before you start⏱ 30 min
  • Solid Docker fundamentals (images, containers, networks, volumes)
  • Experience running Docker in development
  • Basic understanding of Linux process management and resource limits
  • Familiarity with YAML syntax
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer

Docker Compose for production requires explicit resource limits (memory, CPU), health checks, restart policies, logging drivers, and network isolation. You must also handle secrets, configs, and rolling updates without downtime. The default compose file from a dev environment will fail in production — often catastrophically.

✦ Definition~90s read
What is Docker Compose for Production?

Docker Compose for production means using docker-compose.yml as the single source of truth for multi-container applications in a live environment, with proper resource constraints, health checks, logging, and orchestration fallbacks. It's not just a dev tool — it's a deployment artifact.

Think of Docker Compose as the blueprint for a food truck kitchen.
Plain-English First

Think of Docker Compose as the blueprint for a food truck kitchen. In development, you just need the fryer and the fridge to work. In production, you need fire suppression, a backup generator, a grease trap, and a schedule for when each station starts up so the power doesn't trip. The blueprint is the same, but the details are completely different.

⚙ Browser compatibility
Latest versions — ✓ supported
ChromeFirefoxSafariEdge

You've been using Docker Compose for local dev. It's great. Now your boss says 'ship it to production with compose.' That's where the pain starts. I've seen a simple compose file bring down a payments service because someone forgot to set a memory limit — the container ate all the RAM, the kernel OOM-killed the database, and the entire platform went dark at 3 AM. Docker Compose for production is not docker-compose up -d. It's a completely different beast. By the end of this, you'll know exactly what to add, what to remove, and what to watch for when running compose in production. You'll be able to write a compose file that survives a traffic spike, a node failure, and a junior dev's 'quick fix'.

Why Your Dev Compose File Will Burn Production

The compose file you use for local development is optimized for convenience, not reliability. It probably has ports exposed to the world, no resource limits, and a restart policy of 'no'. In production, that's a disaster. I've seen a dev compose file with depends_on: - db and no health check — the app started before the database was ready, failed, and the restart policy wasn't set, so it stayed dead. The classic rookie mistake here costs you a full table lock on writes because the app connects before the DB is accepting queries. You need health checks, condition: service_healthy on depends_on, and proper restart policies. Don't assume order — enforce it.

production-compose-basics.ymlDEVOPS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// io.thecodeforge — DevOps tutorial

version: '3.8'

services:
  api:
    image: myapp/api:latest
    ports:
      - "127.0.0.1:8080:8080"  # Bind to localhost only — never 0.0.0.0 in production
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s  # Give app time to initialize
    depends_on:
      db:
        condition: service_healthy
    deploy:
      resources:
        limits:
          cpus: '0.5'
          memory: 512M
        reservations:
          cpus: '0.25'
          memory: 256M
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

  db:
    image: postgres:15
    restart: unless-stopped
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5
    deploy:
      resources:
        limits:
          memory: 1G

secrets:
  db_password:
    file: ./secrets/db_password.txt

volumes:
  pgdata:
Output
Services start in order: db first (health check passes), then api. Both have memory limits and logging rotation. Secrets are mounted as files, not env vars.
Production Trap: Exposing Ports to 0.0.0.0
In dev, you expose ports to 0.0.0.0 so you can access from your browser. In production, bind to 127.0.0.1 and use a reverse proxy (nginx, Traefik) to handle TLS and routing. Otherwise, you're exposing your app directly to the internet — a common vector for attacks.

Resource Limits: The One Thing Everyone Forgets

Without resource limits, a single container can consume all host memory and get OOM-killed, taking down other services. Worse, a CPU-hungry container can starve the rest. I've seen a log aggregator with a memory leak eat 8GB before the kernel killed it — and the database was on the same host. The fix: always set deploy.resources.limits.memory and deploy.resources.limits.cpus. Use reservations to guarantee minimum resources. For memory, also set --memory-swap to allow controlled swap usage. The rule of thumb: limit memory to 80% of what you expect the container to need under peak load. Monitor with docker stats and adjust.

resource-limits.ymlDEVOPS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// io.thecodeforge — DevOps tutorial

services:
  worker:
    image: myapp/worker:latest
    deploy:
      resources:
        limits:
          cpus: '1.0'        # One full core
          memory: 512M        # Hard limit
        reservations:
          cpus: '0.5'         # Guaranteed minimum
          memory: 256M
    # Optional: allow swap up to 1GB total (memory + swap)
    # memswap_limit: 1G
    # mem_swappiness: 0  # Disable swapping if possible
Output
Container is guaranteed 0.5 CPU and 256MB RAM, limited to 1 CPU and 512MB RAM. If it exceeds 512MB, it gets OOM-killed.
Senior Shortcut: Use docker stats to Tune Limits
Run docker stats <container> during a load test. Note the peak memory and CPU. Set limits to 1.5x the peak. For memory, add 20% headroom. Never guess — measure.

Health Checks: The Difference Between Self-Healing and Self-Destructing

A container is 'running' if its main process is alive. That doesn't mean your app is healthy. I've seen a Node.js app that was running but stuck in an infinite loop — the health endpoint never responded, but Docker thought it was fine. The load balancer kept sending traffic, users got timeouts, and the monitoring didn't catch it because the container was 'up'. Health checks fix this. They tell Docker (and orchestrators) when a container is truly ready. Use them in depends_on with condition: service_healthy to ensure startup order. Also use them in restart policies — if health check fails, Docker can restart the container. The key is setting a proper start_period: give your app time to initialize before health checks start.

healthcheck-example.ymlDEVOPS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// io.thecodeforge — DevOps tutorial

services:
  web:
    image: myapp/web:latest
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 60s  # App may take 60s to start
    restart: unless-stopped
    # If health check fails 3 times, container is marked unhealthy
    # Docker will restart it (unless-stopped policy)
    # But only if the main process exits — health check alone doesn't trigger restart
    # For auto-restart on unhealthy, use a process manager or orchestration
Output
Container starts. After 60s, health checks begin every 30s. If 3 consecutive checks fail, container is 'unhealthy' but still running. To auto-restart, you need a wrapper script that exits on health failure, or use Docker Swarm/Kubernetes.
The Classic Bug: Health Check Without start_period
If you don't set start_period, health checks start immediately. A slow-starting app will fail the first few checks, hit retries, and be marked unhealthy before it's even ready. Then the load balancer removes it. Always set start_period to at least the 95th percentile startup time.

Logging: Don't Let Logs Fill Your Disk

Default Docker logging uses json-file driver with no rotation. In production, a chatty app can fill a disk in hours. I've seen a misconfigured logger write 50GB of logs in a day, causing the host to run out of space, which killed all containers. The fix: configure logging driver with max-size and max-file. Better yet, use a centralized logging system (ELK, Loki, Datadog) with the syslog or fluentd driver. But even with centralized logging, always set local rotation as a safety net. Also consider log levels — in production, set to warn or error unless debugging.

logging-config.ymlDEVOPS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// io.thecodeforge — DevOps tutorial

services:
  app:
    image: myapp/app:latest
    logging:
      driver: "json-file"
      options:
        max-size: "10m"   # Rotate when log file reaches 10MB
        max-file: "3"      # Keep 3 rotated files (30MB total)
        # Alternative: use syslog to send to central server
        # driver: "syslog"
        # options:
        #   syslog-address: "tcp://logs.example.com:514"
        #   tag: "{{.Name}}/{{.ID}}"
Output
Each container's logs are capped at 30MB (3 files x 10MB). Older logs are deleted. No disk fill-up.
Production Trap: Logging Driver and Docker Compose Up
If you change the logging driver after containers are running, docker-compose up won't recreate them. You must run docker-compose down && docker-compose up -d. Always test logging changes in a staging environment first.

Secrets and Configs: Never Hardcode in Production

In dev, you might set environment variables directly in compose. In production, that's a security risk. Anyone with access to the compose file or the host can see secrets. Use Docker secrets (swarm mode) or bind-mount secret files. For compose without swarm, use the secrets: directive with file: or external: true. Never use environment: for passwords, API keys, or tokens. Also, use configs for non-sensitive configuration files. The pattern: write secrets to files outside the compose directory, set permissions to 600, and mount them. The app reads them at startup.

secrets-config.ymlDEVOPS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// io.thecodeforge — DevOps tutorial

services:
  api:
    image: myapp/api:latest
    secrets:
      - db_password
      - api_key
    configs:
      - source: app_config
        target: /etc/app/config.yml

secrets:
  db_password:
    file: ./secrets/db_password.txt  # Permissions 600
  api_key:
    external: true  # Created outside compose: docker secret create api_key -

configs:
  app_config:
    file: ./config/production.yml
Output
Secrets are mounted as files in /run/secrets/<name>. Configs are mounted at the specified target. Environment variables are not used for sensitive data.
Never Do This: environment: POSTGRES_PASSWORD=secret123
Hardcoding secrets in compose files is a common security hole. Anyone with access to the repo or the host can read them. Use secrets or external vaults. If you must use env vars, set them in a .env file that's .gitignored and never committed.

Networking: Isolation and Service Discovery

In production, you don't want all containers on the same network. Isolate tiers: a frontend network, a backend network, and a database network. Only expose necessary ports. Use internal networks for databases. Docker Compose creates a default network, but you should define custom networks with driver: overlay for multi-host (swarm) or bridge for single host. Also, use container_name for stable DNS resolution. I've seen a service discovery fail because the container name changed after a restart — use container_name or service name (which is stable).

network-isolation.ymlDEVOPS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// io.thecodeforge — DevOps tutorial

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
    internal: true  # No external access
  database:
    driver: bridge
    internal: true

services:
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    networks:
      - frontend
      - backend

  api:
    image: myapp/api:latest
    networks:
      - backend
      - database
    # No frontend network — API not directly exposed

  db:
    image: postgres:15
    networks:
      - database
    # Only accessible from backend network
Output
nginx is exposed on port 80, connected to frontend and backend. API is only on backend and database. DB is only on database. No direct external access to API or DB.
Senior Shortcut: Use network aliases for service discovery
If you have multiple replicas of a service, use network aliases so other services can reach them by a stable name. For example, api-replica-1 and api-replica-2 both have alias 'api'.

Volumes: Persistent Data and Performance

In production, database data must survive container restarts. Use named volumes, not bind mounts, for database data. Bind mounts are fine for configs but not for data — they have permission issues and are harder to back up. For performance, consider volume drivers like local (default) or use a network filesystem (NFS, EFS) for multi-host setups. Also, set volume labels for backup automation. I've seen a production database lose data because someone used a bind mount and the host directory got deleted. Named volumes are managed by Docker and safer.

volumes-production.ymlDEVOPS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// io.thecodeforge — DevOps tutorial

services:
  db:
    image: postgres:15
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro  # Bind mount for init script
    # Named volume for data

volumes:
  pgdata:
    # Use a driver for backups or replication
    # driver: local
    # driver_opts:
    #   type: none
    #   device: /path/to/backup
    #   o: bind
Output
Database data is stored in a named volume 'pgdata'. It persists across container restarts and can be backed up independently. Init script is a bind mount (read-only).
Production Trap: Bind Mounts for Database Data
Bind mounts are fine for configs, but for database data, use named volumes. Bind mounts can cause permission mismatches (container user vs host user) and are harder to move or back up. Named volumes are managed by Docker and have consistent permissions.

When Not to Use Docker Compose in Production

Docker Compose is great for single-host deployments or small clusters. But if you need multi-host orchestration, rolling updates, auto-scaling, or service discovery across nodes, you need Kubernetes, Docker Swarm, or Nomad. Compose doesn't handle node failures — if the host goes down, everything goes down. Also, Compose doesn't have built-in secrets management (without swarm), and its health check auto-recovery is limited. Use Compose when: you have a single server, you need simplicity, and you can tolerate downtime during updates. Use Kubernetes when: you need high availability, scaling, or multi-host deployments. The rule of thumb: if you have more than 3 services or need zero-downtime deploys, move to an orchestrator.

when-not-to-use-compose.txtDEVOPS
1
2
3
4
5
6
7
8
9
10
11
// io.thecodeforge — DevOps tutorial

# Signs you should NOT use Docker Compose in production:
# 1. You need rolling updates without downtime
# 2. You have multiple hosts and need service discovery
# 3. You need auto-scaling based on load
# 4. You need secrets management without swarm mode
# 5. You can't tolerate a single host failure
# 6. You need per-container resource metrics and alerts

# In these cases, use Kubernetes, Docker Swarm, or Nomad.
Output
Plain text guidance.
Interview Gold: When would you choose Compose over Kubernetes?
Answer: When you have a small team, a single host, and need to ship fast. Compose is simpler, has less overhead, and is easier to debug. But you trade off resilience and scalability. Know your trade-offs.

Rolling Updates with Compose? Not Really — But Here's the Hack

Docker Compose doesn't support rolling updates natively. docker-compose up -d recreates all containers at once, causing downtime. The hack: use docker-compose scale to run multiple replicas, then update one by one. But this is fragile. Better: use Docker Swarm with docker stack deploy, which supports rolling updates. Or use a reverse proxy (nginx, HAProxy) with blue-green deployment: start new containers on different ports, then switch traffic. I've used a simple script that updates containers in sequence with health checks between each. It's not perfect, but it works for low-traffic services.

rolling-update-hack.shDEVOPS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// io.thecodeforge — DevOps tutorial

#!/bin/bash
# Simple rolling update for a service with 3 replicas

SERVICE="api"
REPLICAS=3

for i in $(seq 1 $REPLICAS); do
  echo "Updating replica $i..."
  docker-compose up -d --no-deps --scale $SERVICE=$((REPLICAS - i + 1)) --no-recreate $SERVICE
  # Wait for health check
  sleep 30
  # Check health
  if ! docker-compose ps | grep "$SERVICE.*Up"; then
    echo "Replica $i failed, rolling back..."
    docker-compose up -d --no-deps --scale $SERVICE=$REPLICAS
    exit 1
  fi
done

echo "All replicas updated successfully."
Output
Script updates one replica at a time, waiting for health checks. If a replica fails, it rolls back by scaling back to original count.
The Classic Bug: Scaling Without --no-recreate
If you use docker-compose up -d --scale api=3 without --no-recreate, it will recreate all containers, causing downtime. Always use --no-recreate when scaling existing containers.
● Production incidentPOST-MORTEMseverity: high

The 4GB Container That Kept Dying

Symptom
A Node.js API container would run for about 6 hours, then exit with code 137. No error logs. Just dead.
Assumption
Memory leak in the application code. Spent two days profiling heap dumps.
Root cause
No memory limit set in compose. The container could use all host memory. When other services started, the kernel OOM-killed the biggest consumer — the API. The default memory limit for a container is unlimited.
Fix
Added deploy.resources.limits.memory: 512M and deploy.resources.reservations.memory: 256M. Also set --memory-swap to 1G to allow some swap. Container never died again.
Key lesson
  • Always set memory limits on every container.
  • Unlimited memory is a production accident waiting to happen.
Production debug guideSystematic recovery paths for the failure modes engineers actually hit.4 entries
Symptom · 01
Container exits with code 137 (OOMKilled)
Fix
1. Run docker stats to see memory usage. 2. Check deploy.resources.limits.memory in compose. 3. Increase limit or fix memory leak. 4. Add swap limit with --memory-swap.
Symptom · 02
Service unreachable after deploy
Fix
1. Check docker-compose ps for status. 2. Check docker logs <service> for errors. 3. Verify health check endpoint responds. 4. Check port binding with docker port <container>. 5. Ensure firewall allows traffic.
Symptom · 03
Container restarting in a loop
Fix
1. Check docker logs <container> for crash reason. 2. Check restart policy (should be unless-stopped). 3. Check health check exit codes. 4. Increase start_period if app is slow to start.
Symptom · 04
Logs filling disk
Fix
1. Check docker system df for disk usage. 2. Check logging driver config. 3. Add max-size and max-file to logging options. 4. Run docker system prune -a to clean old containers and images.
★ Docker Compose for Production Triage Cheat SheetFirst-response commands for when things go wrong — copy-paste ready.
Container exits with `exit code 137`
Immediate action
Check if OOM-killed
Commands
docker inspect <container> | grep -i oom
docker stats --no-stream
Fix now
Add deploy.resources.limits.memory: 512M to compose and redeploy.
Service not starting, `depends_on` not waiting+
Immediate action
Check if DB is healthy
Commands
docker-compose ps db
docker logs db
Fix now
Add healthcheck to DB and condition: service_healthy to depends_on.
Port not accessible externally+
Immediate action
Check port binding
Commands
docker port <container>
netstat -tlnp | grep <port>
Fix now
Ensure ports section binds to 0.0.0.0:<host_port>:<container_port> or use reverse proxy.
Disk space critical, logs growing+
Immediate action
Check log sizes
Commands
docker system df
du -sh /var/lib/docker/containers/*/*-json.log
Fix now
Add logging: driver: json-file options: max-size: 10m max-file: 3 to compose and run docker-compose down && up.
FeatureDocker Compose (Production)Docker SwarmKubernetes
Setup complexityLowMediumHigh
Rolling updatesManual hackBuilt-inBuilt-in
Multi-hostNoYesYes
Secrets managementFile-based or swarmBuilt-inBuilt-in
Auto-scalingNoNoYes
Health check auto-recoveryPartial (restart policy)YesYes
Resource limitsYesYesYes
Learning curveLowMediumHigh
⚙ Quick Reference
8 commands from this guide
FileCommand / CodePurpose
production-compose-basics.ymlversion: '3.8'Why Your Dev Compose File Will Burn Production
resource-limits.ymlservices:Resource Limits
healthcheck-example.ymlservices:Health Checks
logging-config.ymlservices:Logging
secrets-config.ymlservices:Secrets and Configs
network-isolation.ymlnetworks:Networking
volumes-production.ymlservices:Volumes
rolling-update-hack.shSERVICE="api"Rolling Updates with Compose? Not Really

Key takeaways

1
Always set memory and CPU limits on every container
unlimited resources are a production accident waiting to happen.
2
Health checks with start_period and condition
service_healthy are mandatory for reliable startup ordering.
3
Never hardcode secrets in compose files
use Docker secrets or external files with proper permissions.
4
Log rotation is not optional
configure max-size and max-file to avoid disk-full disasters.
5
Docker Compose is not a replacement for Kubernetes
know when to graduate to an orchestrator.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
How does Docker Compose handle container startup order, and what are the...
Q02SENIOR
When would you choose Docker Compose over Kubernetes for a production de...
Q03SENIOR
What happens when a container exceeds its memory limit in Docker Compose...
Q04JUNIOR
What is the difference between restart policies 'always' and 'unless-sto...
Q05SENIOR
You deploy a new version of a service with docker-compose up -d, and the...
Q06SENIOR
How would you design a multi-service application with Docker Compose to ...
Q01 of 06SENIOR

How does Docker Compose handle container startup order, and what are the pitfalls in production?

ANSWER
Compose uses depends_on, but by default it only waits for the container to start, not for the service to be healthy. In production, you must add health checks and condition: service_healthy. Otherwise, your app may start before the database is ready, causing connection failures and retry storms.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Is Docker Compose production-ready?
02
What's the difference between Docker Compose and Docker Swarm?
03
How do I set memory limits in Docker Compose?
04
How do I handle secrets in Docker Compose for production?
05
Can I do rolling updates with Docker Compose?
N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.

Follow
Verified
production tested
July 11, 2026
last updated
258
articles · all by Naren
🔥

That's Docker. Mark it forged?

4 min read · try the examples if you haven't

Previous
Docker Storage and Volumes Deep Dive
24 / 32 · Docker
Next
Docker Healthchecks and Restart Policies