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
Chrome
Firefox
Safari
Edge
✓
✓
✓
✓
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.
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.
thecodeforge.io
Docker Compose Production
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.
thecodeforge.io
Docker Compose Production
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" # Keep3 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 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).
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 DockerCompose 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, DockerSwarm, 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=3for i in $(seq 1 $REPLICAS); do
echo "Updating replica $i..."
docker-compose up -d --no-deps --scale $SERVICE=$((REPLICAS - i + 1)) --no-recreate $SERVICE
# Waitfor 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.
Multi-Stage Builds with Compose — Smaller Images, Faster Deploys
Multi-stage builds let you use one Dockerfile with multiple FROM statements, each representing a build stage. In Compose, you reference a specific build target with the build.target key. The typical pattern: a builder stage installs all dev dependencies and compiles artifacts, then a runtime stage copies only the compiled output into a minimal base image. This produces production images that are 5-20x smaller than single-stage builds. In docker-compose.yml, set build.target: production to build only the runtime stage. The builder stage is never saved to the registry — it exists only during the build. Use this pattern to keep production images free of compilers, package managers, and dev dependencies, reducing the attack surface and deployment time. Compose resolves the target within the Dockerfile's multi-stage structure, so you can define different targets for development (with hot-reload tooling) and production (minimal runtime).
DockerfileDOCKERFILE
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
# Dockerfile with multi-stage build
# Stage1: BuilderFROM node:20-alpine AS builder
WORKDIR /app
COPYpackage*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
# Stage2: Production runtime
FROM node:20-alpine AS production
WORKDIR /app
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
USER appuser
EXPOSE3000CMD ["node", "dist/index.js"]
# Stage3: Development (optional, with dev deps)
FROM node:20-alpine AS development
WORKDIR /app
RUN npm ci && npm install -g nodemon
COPY . .
USER node
CMD ["nodemon", "src/index.js"]
💡Target-Specific Builds
Set build.target in docker-compose.yml to pick which stage to build. Compose builds only that stage and its ancestors. The builder stage's layers are cached locally but never pushed to production registries.
📊 Production Insight
A Go API dropped from 1.2GB (single-stage with golang:latest) to 16MB (multi-stage with scratch) using multi-stage builds. The deployment time went from 45 seconds to under 3 seconds because the image was small enough to pull instantly. The same pattern applies to any compiled language (Rust, Java, Python with compiled extensions).
🎯 Key Takeaway
Multi-stage builds produce lean production images by separating build-time dependencies from runtime artifacts. Reference targets with build.target in Compose. Combine with .dockerignore for maximum size reduction.
Image Pinning by Digest in Production — Preventing Tag Mutability Attacks
Tags are mutable — the same tag can point to different images over time. A docker-compose.yml with image: myapp/api:latest can pull a different image on every deploy, even if you think you're deploying the same version. Image digests (SHA256 hashes) are immutable: image@sha256:abc123 always refers to exactly the same image. In production, pin every image by digest to prevent tag mutability attacks, accidental overwrites, and cache poisoning. When you push to your registry, record the digest from the push output (or use docker images --digests). In docker-compose.yml, replace image: myapp/api:v1.2.3 with image: myapp/api@sha256:a1b2c3d4. This guarantees that every deployment of that compose file uses the exact same image content. For base images (postgres, redis), use a specific tag AND verify the digest from Docker Hub. Update pinned digests only when you explicitly intend to deploy a new version. The trade-off: you need a process to update digests in your compose files, usually via CI after a successful build and scan.
docker-compose.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
services:
api:
# Pinned by digest — this exact image, always
image: ghcr.io/myorg/api@sha256:a1b2c3d4e5f6789012345678901234567890123456789012345678901234567890
restart: unless-stopped
postgres:
# Base image pinned to a specific tag (not latest)
image: postgres:15-alpine
# In production, also pin base images by digest:
# image: postgres@sha256:official_postgres_15_alpine_digest
restart: unless-stopped
redis:
image: redis:7-alpine
restart: unless-stopped
Output
# Check digests of running images:
docker images --digests | grep myapp
ghcr.io/myorg/api latest sha256:a1b2c3d4... 2 weeks ago 142MB
# Verify current image digest on a running container:
# The pinned compose file always pulls this exact image:
docker compose pull app
Pulling app... a1b2c3d4: Pulling from myorg/api
Digest: sha256:a1b2c3d4...
Status: Image is up to date
⚠ The 'latest' Trap
Using image: postgres:latest in production is a deployment time bomb. A new major version release will break your app when Docker pulls the updated latest. Always pin to a specific version tag, and for maximum security, pin by digest. The extra characters are worth the guarantee.
📊 Production Insight
In 2024, a malicious actor overwrote tags on a popular base image repository, causing thousands of production deployments to pull compromised images. Digest pinning would have prevented every one of those incidents. The fix: in your CI/CD pipeline, after docker compose build, capture the digest with docker inspect and update the compose file automatically. Never deploy without a pinned digest for your application images.
🎯 Key Takeaway
Tags are mutable; digests are immutable. Pin all production images by digest (image@sha256:...) to prevent tag hijacking and ensure deterministic deployments. Automate digest updates in CI.
.dockerignore Best Practices — Shrinking Build Context, Speeding Builds
The .dockerignore file excludes files from the Docker build context, reducing the amount of data sent to the Docker daemon during docker compose build. A bloated build context (node_modules, .git, .env files) slows builds and can leak secrets. Every file in the build context is sent to the Docker daemon, even if the Dockerfile doesn't COPY it. A .git directory alone can be hundreds of megabytes. Critical exclusions: .env (contains secrets), .git (massive directory, not needed for build), node_modules (reinstalled inside the container), .pem, .key (private keys), __pycache__, .DS_Store, .vscode, .idea, *.log, .terraform, and any CI artifacts. The .dockerignore file follows the same glob patterns as .gitignore. Place it at the root of each build context directory. For monorepos, each service should have its own .dockerignore in its build context directory. A well-configured .dockerignore can reduce build context from 500MB to under 1MB, cutting build time by 80%.
.dockerignoreDOCKERIGNORE
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
# .dockerignore — always at the root of the build context
# Secrets
.env
*.pem
*.key
*.cert
# Version control
.git
.gitignore
.gitattributes
# Dependencies (reinstalled inside container)
node_modules
vendor
.python_packages
# IDE and editor files
.vscode
.idea
*.swp
*.swo
.DS_Store
# Logs
*.log
npm-debug.log*
# Build artifacts
dist
build
*.tsbuildinfo
# CI/CD
.ci
.github
.gitlab
# Terraform (if in same repo)
.terraform
terraform.tfstate*
# DockerDockerfile
docker-compose*.yml
du -sh --exclude=.git --exclude=node_modules . # 800KB
docker compose build # 8 seconds
# Check what's being sent:
docker build -t test -f- . <<< "FROM scratch
COPY . /" 2>&1 | head -20
🔥Context Size Matters in CI
In CI runners, the build context is compressed, transferred to the Docker daemon, and extracted. A 500MB context takes 30+ seconds just to transfer. .dockerignore eliminates this overhead. Run docker build -t test -f- . <<< 'FROM scratch\nCOPY . /' > /dev/null to see exactly what's being sent.
📊 Production Insight
A common failure: developer adds .env to .dockerignore but the CI pipeline generates a .env file before the build, causing the build to include secrets. Solution: generate secrets at deploy time, not build time. Never COPY .env in the Dockerfile — use docker compose environment: or secrets: instead. The .dockerignore is defense in depth, not the sole protection for secrets.
🎯 Key Takeaway
.dockerignore reduces build context size by 99% and prevents secret leakage. Exclude .git, node_modules, .env, *.pem, IDE files, and build artifacts. Place one per build context directory.
Hadolint Dockerfile Linting in CI — Automated Quality Gates
Hadolint is a Dockerfile linter that checks for best practices, security issues, and common mistakes. It parses the Dockerfile and applies rules from the hadolint rule set (DL3000-DL3099). Run hadolint in CI as a required PR check to enforce Dockerfile quality before merging. Configure typical rules: DL3006 (always tag image versions, never use latest), DL3008 (pin apt-get package versions), DL3009 (delete apt-get lists), DL3018 (pin pip package versions), DL4006 (set SHELL for readability), and DL3045 (use COPY instead of ADD unless you need tar extraction). Hadolint integrates with pre-commit hooks, GitHub Actions, GitLab CI, and any CI system that runs shell commands. It also supports a .hadolint.yaml config file to customize rules, ignore specific violations, and set trusted registries for base image validation. In production CI pipelines, run hadolint before docker compose build to fail fast — if the Dockerfile fails linting, don't waste time building it.
./api/Dockerfile:3 DL3006 warning: Always tag the version of an image explicitly
./api/Dockerfile:7 DL3008 error: Pin versions in apt-get install
./api/Dockerfile:12 DL3045 warning: Use COPY instead of ADD
# Exit code: 1 (will fail CI pipeline)
# Custom .hadolint.yaml overrides:
# trustedRegistries: ["docker.io", "ghcr.io"]
# override:
# DL3008: warning # Demote from error to warning
# ignore:
# - DL3007 # Ignore 'latest' tag rule for dev images
🔥Fail Fast Principle
Run hadolint before docker compose build. If the Dockerfile has lint violations, there's no point building it. This saves CI minutes and prevents insecure images from reaching the registry. Lint first, build second, push third.
📊 Production Insight
Hadolint caught a production issue before it shipped: a Dockerfile using ADD --chown instead of COPY --chown, which automatically untarred a malicious archive hidden in the build context. The rule DL3045 (use COPY instead of ADD) prevented the exploit. Hadolint also catches the common mistake of not pinning apt-get versions, which can cause builds to fail when repositories update package versions.
🎯 Key Takeaway
Add hadolint to CI as a required PR check. Run before docker compose build to fail fast. Pair with docker compose config --dry-run for compose-level validation. Lint plus config validation equals deployable confidence.
thecodeforge.io
Docker Compose Production
Reverse Proxy Integration (Traefik/Caddy) in Production Compose
In production, you should never expose application containers directly to the internet. Use a reverse proxy service (Traefik or Caddy) as the sole ingress point. The proxy handles TLS termination, automatic certificate management, rate limiting, and routing to backend services. For Traefik, use Compose labels for dynamic configuration — Traefik reads service labels and automatically configures routing. For Caddy, use Caddyfile for static configuration. Both support automatic HTTPS via Let's Encrypt. The reverse proxy runs on port 80/443 and routes traffic based on hostname or path to internal services on their container ports. This pattern eliminates the need for each service to manage TLS certificates, and it centralizes access control, logging, and rate limiting. In the compose file, the proxy service is the only service with host port mappings. All backend services use expose: (no host port) and are only reachable through the proxy's internal network. For Traefik, labels on each service declare routing rules; for Caddy, a bind-mounted Caddyfile defines the reverse proxy configuration.
# Traefik auto-detects services via Docker labels and routes traffic:
# https://example.com -> frontend (port 80)
# https://api.example.com -> api (port 3000)
# Automatic Let's Encrypt certificate issuance and renewal
# No port mapping on API or frontend services — they're internal only
# Caddy equivalent with Caddyfile:
# example.com {
# reverse_proxy frontend:80
# }
# api.example.com {
# reverse_proxy api:3000
# }
$ docker compose ps
NAME PORTS
traefik 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp
api (no host ports)
frontend (no host ports)
⚠ Never Expose Docker Socket in Production Without Restriction
The Traefik pattern mounts /var/run/docker.sock to enable dynamic service discovery. This gives Traefik root access to Docker. Mitigate by using a read-only mount (:ro) and running Traefik in a dedicated restricted network. For high-security environments, use the file provider instead of the Docker provider.
📊 Production Insight
A startup exposed their API directly on port 3000 to the internet because they skipped the reverse proxy. Within 24 hours, an automated scanner discovered the endpoint and launched a DDoS attack that maxed out their 1Gbps uplink, costing $2,000 in bandwidth overages. The fix: put Traefik in front, bind the API to 127.0.0.1, and enable rate limiting via Traefik middleware. The scanner couldn't reach the API anymore because the only exposed port was 443 (TLS-terminated Traefik).
🎯 Key Takeaway
Use a reverse proxy (Traefik/Caddy) as the sole ingress point. Only the proxy gets host port mappings. Backend services use expose: and are reachable only via the proxy. This centralizes TLS, rate limiting, and access control.
● 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.
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.
6
Pin all production images by digest (image@sha256:...) to prevent tag mutability attacks and ensure immutable, deterministic deployments. Automate digest updates in CI.
7
Use a reverse proxy (Traefik/Caddy) as the sole ingress point in production. Only the proxy gets host port mappings. Backend services use expose
with no host ports — they're reachable only through the proxy, which handles TLS, routing, and rate limiting.
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 ...
Q07SENIOR
You're deploying a Node.js API with Docker Compose in production. The im...
Q08SENIOR
How would you design a production Docker Compose setup to handle zero-do...
Q01 of 08SENIOR
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.
Q02 of 08SENIOR
When would you choose Docker Compose over Kubernetes for a production deployment?
ANSWER
Choose Compose when you have a single host, a small team, and need simplicity. It's great for small applications, CI/CD pipelines, or edge deployments. Choose Kubernetes when you need high availability, rolling updates, auto-scaling, or multi-host orchestration. The trade-off is operational complexity.
Q03 of 08SENIOR
What happens when a container exceeds its memory limit in Docker Compose, and how do you diagnose it?
ANSWER
The kernel OOM-kills the container with exit code 137. To diagnose, run docker inspect <container> | grep -i oom or check docker logs. Use docker stats to monitor memory usage. The fix is to increase the limit or fix the memory leak. Always set memory limits to prevent a single container from taking down the host.
Q04 of 08JUNIOR
What is the difference between restart policies 'always' and 'unless-stopped' in production?
ANSWER
'always' restarts the container even if it was manually stopped, which can cause unexpected restarts during maintenance. 'unless-stopped' only restarts if the container exits unexpectedly, not if it was stopped manually. Use 'unless-stopped' in production to avoid surprise restarts.
Q05 of 08SENIOR
You deploy a new version of a service with docker-compose up -d, and the service becomes unhealthy. How do you roll back?
ANSWER
First, check docker-compose ps and logs to confirm the issue. Then run docker-compose up -d with the previous image tag. If you don't have the tag, rebuild with the old code. For a faster rollback, use docker-compose scale to reduce replicas of the new version and increase replicas of the old version (if running side by side).
Q06 of 08SENIOR
How would you design a multi-service application with Docker Compose to handle zero-downtime deployments?
ANSWER
Compose doesn't support zero-downtime natively. You can hack it by running multiple replicas behind a reverse proxy (nginx, HAProxy). Use blue-green deployment: start new containers on different ports, then update the proxy config to switch traffic. Or use Docker Swarm with rolling updates. For true zero-downtime, move to Kubernetes.
Q07 of 08SENIOR
You're deploying a Node.js API with Docker Compose in production. The image is currently 1.2GB and takes 45 seconds to build. How do you reduce the image size and build time?
ANSWER
Three changes: (1) Multi-stage builds — create a builder stage that compiles and installs devDependencies, then a production stage that copies only the compiled output and production dependencies. This removes compilers, TypeScript source, and npm cache from the final image. Expect 50-100MB final size. (2) .dockerignore — exclude .git, node_modules, .env, and CI artifacts from the build context. This reduces build context from ~500MB to ~1MB, speeding up Docker daemon context transfer. (3) Pin base image to a specific version (node:20-alpine, not node:latest) to ensure cache hits. Combined, these reduce build time from 45 seconds to under 10 seconds and image size from 1.2GB to ~120MB.
Q08 of 08SENIOR
How would you design a production Docker Compose setup to handle zero-downtime deployments, TLS termination, and centralized logging?
ANSWER
Use three architectural patterns: (1) Reverse proxy (Traefik or Caddy) as the sole ingress point — handles TLS termination via Let's Encrypt, rate limiting, and routes traffic to backend services by hostname. Backend services use expose: and never have host port mappings. (2) Blue-green deployment pattern — run two sets of containers behind the proxy, update one set while the other serves traffic, then switch the proxy upstream. (3) Centralized logging — configure the json-file logging driver with max-size and max-file on each service, plus a log shipper (Vector, Fluent Bit) that reads Docker logs and forwards to Loki, Elasticsearch, or Datadog. Combine with health checks (condition: service_healthy) and resource limits on every service. This gives you TLS, zero-downtime deploys, and centralized log aggregation without Kubernetes complexity.
01
How does Docker Compose handle container startup order, and what are the pitfalls in production?
SENIOR
02
When would you choose Docker Compose over Kubernetes for a production deployment?
SENIOR
03
What happens when a container exceeds its memory limit in Docker Compose, and how do you diagnose it?
SENIOR
04
What is the difference between restart policies 'always' and 'unless-stopped' in production?
JUNIOR
05
You deploy a new version of a service with docker-compose up -d, and the service becomes unhealthy. How do you roll back?
SENIOR
06
How would you design a multi-service application with Docker Compose to handle zero-downtime deployments?
SENIOR
07
You're deploying a Node.js API with Docker Compose in production. The image is currently 1.2GB and takes 45 seconds to build. How do you reduce the image size and build time?
SENIOR
08
How would you design a production Docker Compose setup to handle zero-downtime deployments, TLS termination, and centralized logging?
SENIOR
FAQ · 7 QUESTIONS
Frequently Asked Questions
01
Is Docker Compose production-ready?
Yes, but only for single-host deployments or small clusters. You need to add resource limits, health checks, logging rotation, and secrets management. The default dev compose file will fail in production. For multi-host or zero-downtime, use Docker Swarm or Kubernetes.
Was this helpful?
02
What's the difference between Docker Compose and Docker Swarm?
Compose is for defining and running multi-container applications on a single host. Swarm is a container orchestration platform for multi-host clusters with built-in load balancing, rolling updates, and secrets management. Compose files can be used with Swarm via docker stack deploy.
Was this helpful?
03
How do I set memory limits in Docker Compose?
Use the deploy.resources.limits.memory key under a service. For example: deploy: resources: limits: memory: 512M. Also set reservations for guaranteed minimum. This prevents OOM kills.
Was this helpful?
04
How do I handle secrets in Docker Compose for production?
Use the secrets directive with file: or external: true. Mount secrets as files in /run/secrets/. Never use environment variables for secrets. For swarm mode, use docker secret create.
Was this helpful?
05
Can I do rolling updates with Docker Compose?
Not natively. You can hack it by scaling replicas and updating one by one, but it's fragile. For proper rolling updates, use Docker Swarm or Kubernetes.
Was this helpful?
06
What is image digest pinning and why should I use it in production?
Image digest pinning means referencing a container image by its SHA256 hash (image@sha256:abc123) instead of a tag (image:latest). Tags are mutable — the same tag can point to different images at different times, which is a security risk (tag hijacking, accidental overwrites) and a reproducibility problem. Digests are immutable — they always reference the exact same image content. Use digest pinning for all production images, especially your own application images. Automate digest updates in CI after successful builds and security scans. For base images like postgres or redis, pin to a specific version tag at minimum, and to a verified digest for maximum security.
Was this helpful?
07
How do multi-stage builds work with Docker Compose?
Multi-stage builds use multiple FROM statements in a Dockerfile, each representing a build stage. In docker-compose.yml, use build.target to specify which stage to build. For example, a Dockerfile with AS builder and AS production stages, combined with build.target: production in compose, builds only the production stage. The builder stage (with compilers, dev tools, and source code) is never saved as an image layer — only the final runtime artifacts are included in the output image. This produces dramatically smaller images (often 10-20x smaller). The same Dockerfile can have a development target used locally with docker compose up and a production target used in CI/CD pipelines.