Home DevOps Docker Compose for Production: The Hard Truths They Don't Tell You
Advanced 7 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 18, 2026
last updated
2,466
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.
docker-compose-production THECODEFORGE.IO Production Docker Compose Stack Layered architecture for resilient container orchestration Orchestration Docker Compose | Docker Swarm Networking Overlay Network | Service Discovery Security Secrets Management | Config Files Data Management Named Volumes | Bind Mounts Observability Health Checks | Logging Driver | Metrics Resource Control CPU Limits | Memory Limits THECODEFORGE.IO
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.
docker-compose-production THECODEFORGE.IO Production Docker Compose Stack Layered architecture for resilient container orchestration Application Services Web Server | API Gateway | Background Workers Data Layer PostgreSQL | Redis | Elasticsearch Infrastructure Docker Engine | Compose CLI | Swarm Mode Observability Prometheus | Grafana | Logstash Security Docker Secrets | TLS Certificates | Firewall Rules THECODEFORGE.IO
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"      # 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.

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

# Stage 1: Builder
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build

# Stage 2: 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
EXPOSE 3000
CMD ["node", "dist/index.js"]

# Stage 3: 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:
docker inspect --format='{{.Image}}' <container>
sha256:a1b2c3d4e5f6789012345678901234567890123456789012345678901234567890
# 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*

# Docker
Dockerfile
docker-compose*.yml
Output
# Before .dockerignore:
du -sh . # 520MB (mostly .git and node_modules)
docker compose build # 45 seconds (network transfer + daemon context)
# After .dockerignore:
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.

ci-lint.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Install hadolint (CI runner setup)
# Linux x86_64:
curl -sL https://github.com/hadolint/hadolint/releases/latest/download/hadolint-Linux-x86_64 -o /usr/local/bin/hadolint
chmod +x /usr/local/bin/hadolint

# macOS:
# brew install hadolint

# Lint all Dockerfiles in the project
hadolint ./**/Dockerfile

# With custom config file
hadolint -c .hadolint.yaml ./api/Dockerfile

# GitHub Actions step:
# - name: Lint Dockerfiles
#   run: hadolint ./**/Dockerfile

# Pre-commit setup (.pre-commit-config.yaml):
# repos:
#   - repo: https://github.com/hadolint/hadolint
#     rev: v2.12.0
#     hooks:
#       - id: hadolint
Output
# Clean lint:
$ hadolint ./api/Dockerfile
# No output = no violations
# Failed lint:
$ hadolint ./api/Dockerfile
./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.
Dev vs Production Docker Compose Key differences for reliable production deployments Dev Compose Production Compose Resource Limits No limits set CPU and memory constraints defined Health Checks Often omitted Mandatory for self-healing Logging Console output only Centralized with rotation Secrets Management Hardcoded in files Docker secrets or vault Network Isolation Single default network Multiple isolated networks Volume Persistence Bind mounts for code Named volumes for data THECODEFORGE.IO
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.

docker-compose.ymlYAML
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
# Traefik integration pattern
services:
  traefik:
    image: traefik:v3.0
    command:
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--entrypoints.websecure.address=:443"
      - "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
      - "--certificatesresolvers.letsencrypt.acme.email=admin@example.com"
      - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
    ports:
      - "443:443"
      - "80:80"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - letsencrypt:/letsencrypt
    labels:
      - "traefik.enable=true"

  api:
    image: myapp/api:latest
    expose:
      - "3000"
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.api.rule=Host(`api.example.com`)"
      - "traefik.http.routers.api.tls=true"
      - "traefik.http.routers.api.tls.certresolver=letsencrypt"
      - "traefik.http.services.api.loadbalancer.server.port=3000"

  frontend:
    image: myapp/frontend:latest
    expose:
      - "80"
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.frontend.rule=Host(`example.com`)"
      - "traefik.http.routers.frontend.tls=true"
      - "traefik.http.routers.frontend.tls.certresolver=letsencrypt"

volumes:
  letsencrypt:
Output
# 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.
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
12 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
DockerfileFROM node:20-alpine AS builderMulti-Stage Builds with Compose
docker-compose.ymlservices:Image Pinning by Digest in Production
.dockerignore.env.dockerignore Best Practices
ci-lint.shcurl -sL https://github.com/hadolint/hadolint/releases/latest/download/hadolint-...Hadolint Dockerfile Linting in CI

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.
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.
FAQ · 7 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?
06
What is image digest pinning and why should I use it in production?
07
How do multi-stage builds work 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 18, 2026
last updated
2,466
articles · all by Naren
🔥

That's Docker. Mark it forged?

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

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