Docker Compose Production Environment Guide - Production-Ready Configuration

advanced | 90 min read | 2025.12.02

Docker Compose can be effectively used not only for development environments but also for small to medium-scale production environments. This article builds production-ready configurations considering security, performance, and operability while clarifying the differences from development environments.

What You’ll Learn in This Article

  1. Separating development and production configurations
  2. Optimization through multi-stage builds
  3. Secret management and security
  4. Health checks and automatic recovery
  5. Log management and monitoring
  6. Backup and recovery strategies

Project Structure

project/
├── docker/
│   ├── app/
│   │   └── Dockerfile
│   ├── nginx/
│   │   ├── Dockerfile
│   │   └── nginx.conf
│   └── postgres/
│       └── init.sql
├── docker-compose.yml           # Base configuration
├── docker-compose.override.yml  # Development (auto-loaded)
├── docker-compose.prod.yml      # Production
├── docker-compose.staging.yml   # Staging
├── .env.example                 # Environment variable template
└── src/
    └── ...

Separating Development and Production Environments

Base Configuration (docker-compose.yml)

# docker-compose.yml - Shared configuration
services:
  app:
    build:
      context: .
      dockerfile: docker/app/Dockerfile
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    networks:
      - backend

  db:
    image: postgres:16-alpine
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - backend
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    networks:
      - backend

networks:
  backend:
    driver: bridge

volumes:
  postgres_data:

Development Configuration (docker-compose.override.yml)

# docker-compose.override.yml - Development environment
# Automatically loaded with docker compose up
services:
  app:
    build:
      target: development
    volumes:
      - .:/app
      - /app/node_modules
    environment:
      - NODE_ENV=development
      - DEBUG=app:*
    ports:
      - "3000:3000"
      - "9229:9229"  # Debugger
    command: npm run dev

  db:
    environment:
      - POSTGRES_USER=dev
      - POSTGRES_PASSWORD=dev
      - POSTGRES_DB=app_dev
    ports:
      - "5432:5432"

  redis:
    ports:
      - "6379:6379"

Production Configuration (docker-compose.prod.yml)

# docker-compose.prod.yml - Production environment
services:
  app:
    build:
      target: production
    environment:
      - NODE_ENV=production
    deploy:
      resources:
        limits:
          cpus: '2'
          memory: 2G
        reservations:
          cpus: '1'
          memory: 1G
      restart_policy:
        condition: on-failure
        delay: 5s
        max_attempts: 3
    logging:
      driver: "json-file"
      options:
        max-size: "100m"
        max-file: "5"
    secrets:
      - db_password
      - app_secret

  nginx:
    image: nginx:1.25-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./docker/nginx/ssl:/etc/nginx/ssl:ro
    depends_on:
      - app
    networks:
      - backend
    deploy:
      resources:
        limits:
          cpus: '0.5'
          memory: 256M

  db:
    environment:
      - POSTGRES_USER_FILE=/run/secrets/db_user
      - POSTGRES_PASSWORD_FILE=/run/secrets/db_password
      - POSTGRES_DB=app_production
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
    deploy:
      resources:
        limits:
          cpus: '2'
          memory: 4G
    secrets:
      - db_user
      - db_password

  redis:
    command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
    volumes:
      - redis_data:/data
    deploy:
      resources:
        limits:
          cpus: '0.5'
          memory: 512M

secrets:
  db_user:
    file: ./secrets/db_user.txt
  db_password:
    file: ./secrets/db_password.txt
  app_secret:
    file: ./secrets/app_secret.txt

volumes:
  postgres_data:
  redis_data:

Multi-Stage Build

Node.js Application

# docker/app/Dockerfile
# ========== Base Stage ==========
FROM node:20-alpine AS base
WORKDIR /app
RUN apk add --no-cache libc6-compat

# ========== Dependencies Stage ==========
FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci --only=production && \
    cp -R node_modules /tmp/prod_modules && \
    npm ci

# ========== Development Stage ==========
FROM base AS development
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NODE_ENV=development
EXPOSE 3000 9229
CMD ["npm", "run", "dev"]

# ========== Builder Stage ==========
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# ========== Production Stage ==========
FROM base AS production

# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nextjs -u 1001

# Production dependencies only
COPY --from=deps /tmp/prod_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./

# Change ownership
RUN chown -R nextjs:nodejs /app
USER nextjs

ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000

# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

CMD ["node", "dist/main.js"]

Image Size Comparison

Multi-stage Build Effects

StageSizeReduction
Development (all deps)1.2 GB-
Build stage1.5 GB-
Production (final)180 MB85%

Secret Management

# Create secret files
mkdir -p secrets
echo "app_user" > secrets/db_user.txt
echo "$(openssl rand -base64 32)" > secrets/db_password.txt
echo "$(openssl rand -base64 64)" > secrets/app_secret.txt

# Set file permissions
chmod 600 secrets/*
# docker-compose.prod.yml
services:
  app:
    secrets:
      - db_password
      - app_secret
    environment:
      # Read secrets from files
      - DATABASE_PASSWORD_FILE=/run/secrets/db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt
  app_secret:
    file: ./secrets/app_secret.txt
// Reading secrets in application
import { readFileSync } from 'fs';

function getSecret(name: string): string {
  const filePath = `/run/secrets/${name}`;
  try {
    return readFileSync(filePath, 'utf8').trim();
  } catch {
    // Get from environment variables in development
    return process.env[name.toUpperCase()] || '';
  }
}

const dbPassword = getSecret('db_password');

Secure Environment Variable Management

# .env.example (template)
NODE_ENV=production
DB_HOST=db
DB_PORT=5432
DB_NAME=app_production
# Don't include secrets as they're read from files

# .env.prod (production - add to .gitignore)
NODE_ENV=production
DB_HOST=db
DB_PORT=5432
DB_NAME=app_production
# docker-compose.prod.yml
services:
  app:
    env_file:
      - .env.prod

Security Configuration

Nginx Reverse Proxy

# docker/nginx/nginx.conf
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;

events {
    worker_connections 1024;
    use epoll;
    multi_accept on;
}

http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    # Log format
    log_format main_json escape=json '{'
        '"time": "$time_iso8601",'
        '"remote_addr": "$remote_addr",'
        '"method": "$request_method",'
        '"uri": "$request_uri",'
        '"status": $status,'
        '"body_bytes_sent": $body_bytes_sent,'
        '"request_time": $request_time,'
        '"upstream_response_time": "$upstream_response_time",'
        '"user_agent": "$http_user_agent"'
    '}';

    access_log /var/log/nginx/access.log main_json;

    # Performance settings
    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;
    keepalive_timeout 65;
    types_hash_max_size 2048;

    # Security headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';" always;

    # Hide server info
    server_tokens off;

    # Gzip compression
    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_types text/plain text/css text/xml application/json application/javascript application/xml;

    # Rate limiting
    limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
    limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s;

    upstream app {
        server app:3000;
        keepalive 32;
    }

    server {
        listen 80;
        server_name _;
        return 301 https://$host$request_uri;
    }

    server {
        listen 443 ssl http2;
        server_name example.com;

        ssl_certificate /etc/nginx/ssl/cert.pem;
        ssl_certificate_key /etc/nginx/ssl/key.pem;
        ssl_protocols TLSv1.2 TLSv1.3;
        ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
        ssl_prefer_server_ciphers off;
        ssl_session_cache shared:SSL:10m;
        ssl_session_timeout 1d;

        # HSTS
        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

        location / {
            proxy_pass http://app;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection 'upgrade';
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_cache_bypass $http_upgrade;
        }

        location /api/ {
            limit_req zone=api burst=20 nodelay;
            proxy_pass http://app;
            # ... other settings
        }

        location /api/auth/login {
            limit_req zone=login burst=5 nodelay;
            proxy_pass http://app;
        }

        # Static files
        location /static/ {
            alias /app/static/;
            expires 30d;
            add_header Cache-Control "public, immutable";
        }

        # Health check
        location /health {
            access_log off;
            proxy_pass http://app;
        }
    }
}

Container Security Hardening

# docker-compose.prod.yml
services:
  app:
    # Read-only filesystem
    read_only: true
    tmpfs:
      - /tmp
      - /app/tmp

    # Limit capabilities
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE  # Only add necessary capabilities

    # Security options
    security_opt:
      - no-new-privileges:true

    # Non-root user (configured in Dockerfile)
    user: "1001:1001"

Health Checks and Automatic Recovery

Health Check Configuration

# docker-compose.prod.yml
services:
  app:
    healthcheck:
      test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s

  db:
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d app_production"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

  redis:
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3

Application Health Check Endpoint

// src/health.ts
import { Router } from 'express';
import { Pool } from 'pg';
import Redis from 'ioredis';

const router = Router();
const pool = new Pool();
const redis = new Redis();

interface HealthStatus {
  status: 'healthy' | 'unhealthy';
  timestamp: string;
  checks: {
    database: { status: string; latency?: number };
    redis: { status: string; latency?: number };
    memory: { used: number; total: number };
  };
}

router.get('/health', async (req, res) => {
  const health: HealthStatus = {
    status: 'healthy',
    timestamp: new Date().toISOString(),
    checks: {
      database: { status: 'unknown' },
      redis: { status: 'unknown' },
      memory: {
        used: process.memoryUsage().heapUsed,
        total: process.memoryUsage().heapTotal,
      },
    },
  };

  try {
    // Database check
    const dbStart = Date.now();
    await pool.query('SELECT 1');
    health.checks.database = {
      status: 'healthy',
      latency: Date.now() - dbStart,
    };
  } catch {
    health.checks.database = { status: 'unhealthy' };
    health.status = 'unhealthy';
  }

  try {
    // Redis check
    const redisStart = Date.now();
    await redis.ping();
    health.checks.redis = {
      status: 'healthy',
      latency: Date.now() - redisStart,
    };
  } catch {
    health.checks.redis = { status: 'unhealthy' };
    health.status = 'unhealthy';
  }

  const statusCode = health.status === 'healthy' ? 200 : 503;
  res.status(statusCode).json(health);
});

// Liveness probe
router.get('/health/live', (req, res) => {
  res.status(200).json({ status: 'alive' });
});

// Readiness probe
router.get('/health/ready', async (req, res) => {
  try {
    await pool.query('SELECT 1');
    await redis.ping();
    res.status(200).json({ status: 'ready' });
  } catch {
    res.status(503).json({ status: 'not ready' });
  }
});

export default router;

Log Management

Structured Logging

// src/logger.ts
import pino from 'pino';

const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  formatters: {
    level: (label) => ({ level: label }),
  },
  timestamp: () => `,"timestamp":"${new Date().toISOString()}"`,
  base: {
    service: 'app',
    version: process.env.APP_VERSION || '1.0.0',
  },
});

export default logger;

Integration with Fluentd/Loki

# docker-compose.prod.yml
services:
  app:
    logging:
      driver: "fluentd"
      options:
        fluentd-address: "localhost:24224"
        tag: "app.{{.Name}}"
        fluentd-async-connect: "true"

  fluentd:
    image: fluent/fluentd:v1.16-debian
    volumes:
      - ./docker/fluentd/fluent.conf:/fluentd/etc/fluent.conf:ro
      - fluentd_logs:/fluentd/log
    ports:
      - "24224:24224"
    networks:
      - backend

volumes:
  fluentd_logs:
# docker/fluentd/fluent.conf
<source>
  @type forward
  port 24224
  bind 0.0.0.0
</source>

<filter app.**>
  @type parser
  key_name log
  reserve_data true
  <parse>
    @type json
  </parse>
</filter>

<match app.**>
  @type elasticsearch
  host elasticsearch
  port 9200
  logstash_format true
  logstash_prefix app
  <buffer>
    @type file
    path /fluentd/log/buffer
    flush_interval 5s
  </buffer>
</match>

Backup and Recovery

Database Backup Script

#!/bin/bash
# scripts/backup-db.sh

set -euo pipefail

BACKUP_DIR="/backups/postgres"
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="${BACKUP_DIR}/backup_${DATE}.sql.gz"
RETENTION_DAYS=7

# Create backup directory
mkdir -p "${BACKUP_DIR}"

# Execute backup
docker compose -f docker-compose.prod.yml exec -T db \
  pg_dump -U postgres -d app_production | gzip > "${BACKUP_FILE}"

# Delete old backups
find "${BACKUP_DIR}" -name "backup_*.sql.gz" -mtime +${RETENTION_DAYS} -delete

# Verify backup
if gzip -t "${BACKUP_FILE}"; then
  echo "Backup successful: ${BACKUP_FILE}"
  echo "Size: $(du -h ${BACKUP_FILE} | cut -f1)"
else
  echo "Backup verification failed!"
  exit 1
fi

Restore Script

#!/bin/bash
# scripts/restore-db.sh

set -euo pipefail

BACKUP_FILE=$1

if [ -z "${BACKUP_FILE}" ]; then
  echo "Usage: $0 <backup_file>"
  exit 1
fi

echo "Restoring from: ${BACKUP_FILE}"
echo "WARNING: This will overwrite the current database!"
read -p "Continue? (y/N): " confirm

if [ "${confirm}" != "y" ]; then
  echo "Aborted."
  exit 0
fi

# Execute restore
gunzip -c "${BACKUP_FILE}" | docker compose -f docker-compose.prod.yml exec -T db \
  psql -U postgres -d app_production

echo "Restore completed."

Deployment Commands

Deployment Script

#!/bin/bash
# scripts/deploy.sh

set -euo pipefail

echo "=== Starting deployment ==="

# Validate configuration files
docker compose -f docker-compose.yml -f docker-compose.prod.yml config --quiet

# Build latest images
echo "Building images..."
docker compose -f docker-compose.yml -f docker-compose.prod.yml build --pull

# Database backup
echo "Creating database backup..."
./scripts/backup-db.sh

# Rolling update
echo "Starting rolling update..."
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --no-deps app

# Wait for health check
echo "Waiting for health check..."
sleep 10

# Verify health check
if curl -sf http://localhost/health > /dev/null; then
  echo "=== Deployment successful ==="
else
  echo "=== Health check failed! Rolling back... ==="
  docker compose -f docker-compose.yml -f docker-compose.prod.yml rollback
  exit 1
fi

# Remove unused images
docker image prune -f

echo "=== Deployment completed ==="

Standardizing Operations with Makefile

# Makefile
.PHONY: dev prod build deploy logs backup restore clean

# Development environment
dev:
	docker compose up -d

dev-logs:
	docker compose logs -f

# Production environment
prod:
	docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

prod-logs:
	docker compose -f docker-compose.yml -f docker-compose.prod.yml logs -f

# Build
build:
	docker compose -f docker-compose.yml -f docker-compose.prod.yml build --no-cache

# Deploy
deploy:
	./scripts/deploy.sh

# Backup
backup:
	./scripts/backup-db.sh

restore:
	./scripts/restore-db.sh $(FILE)

# Cleanup
clean:
	docker compose down -v --remove-orphans
	docker image prune -af
	docker volume prune -f

Operations Command Reference

# Start
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

# Stop
docker compose -f docker-compose.yml -f docker-compose.prod.yml down

# Check logs
docker compose -f docker-compose.yml -f docker-compose.prod.yml logs -f app

# Scale out
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --scale app=3

# Enter container
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec app sh

# Resource usage
docker stats

# Validate configuration
docker compose -f docker-compose.yml -f docker-compose.prod.yml config

Summary

Here are the key points for using Docker Compose in production environments.

Security

  1. Secret management: Use Docker Secrets
  2. Non-root user: No privileges in containers
  3. Minimal permissions: Only grant necessary permissions
  4. Image optimization: Reduce attack surface with multi-stage builds

Reliability

  1. Health checks: Configure for all services
  2. Restart policy: Automatic recovery on failure
  3. Resource limits: OOM killer prevention
  4. Backups: Regular automated backups

Operability

  1. Log management: Structured logging and aggregation
  2. Monitoring: Metrics collection
  3. Deployment automation: Scripted deployments
  4. Configuration separation: Per-environment configuration files

Docker Compose can be effectively used in production when properly configured.

← Back to list