Logo

Docker Compose in production: best practices

Docker Compose in production: best practices

Deploy your applications with Docker Compose reliably in production. This guide covers networks, named volumes, healthchecks, restart policies, secrets and log management for a setup that survives reboots and failures.

Introduction

Docker Compose is perfect for small to medium infrastructures (up to ~10-20 containers on one VPS). But in production, you don't use a docker-compose like in dev. You must plan for:

  • Healthchecks for automatic crash recovery
  • Restart policies to restart after reboot
  • Named volumes (no blind bind mounts)
  • Isolated networks
  • Secret management
  • Limited logs

This guide gives you the production template you can reuse.

Prerequisites

Step 1: Verify Docker

docker --version
docker compose version

Docker Compose v2 (docker compose, not docker-compose) required.

For each app, organize:

/opt/myapp/
├── docker-compose.yml
├── .env                  # env variables (gitignore)
├── .env.example          # public template
├── secrets/              # sensitive secrets (gitignore)
│   ├── db_password.txt
│   └── api_key.txt
├── data/                 # bind mounts if needed
│   └── ...
└── config/               # custom configs
    └── nginx.conf

Permissions:

sudo mkdir -p /opt/myapp/{secrets,data,config}
sudo chmod 700 /opt/myapp/secrets

Step 3: Production docker-compose.yml template

# /opt/myapp/docker-compose.yml

services:
  app:
    image: myapp:1.2.3       # ⚠️ pin exact version, not "latest"
    container_name: myapp
    restart: unless-stopped
    ports:
      - "127.0.0.1:3000:3000" # Bind on localhost only, exposed via Nginx
    environment:
      - NODE_ENV=production
      - DATABASE_URL=postgresql://app:${DB_PASSWORD}@db:5432/myapp
    secrets:
      - db_password
    volumes:
      - app_data:/data
      - ./config/app.conf:/etc/app/app.conf:ro
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 30s
    networks:
      - frontend
      - backend
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"
    deploy:
      resources:
        limits:
          memory: 1G
          cpus: '1.0'

  db:
    image: postgres:16-alpine
    container_name: myapp-db
    restart: unless-stopped
    environment:
      - POSTGRES_DB=myapp
      - POSTGRES_USER=app
      - POSTGRES_PASSWORD_FILE=/run/secrets/db_password
    secrets:
      - db_password
    volumes:
      - db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - backend
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

  redis:
    image: redis:7-alpine
    container_name: myapp-redis
    restart: unless-stopped
    command: redis-server --requirepass ${REDIS_PASSWORD}
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5
    networks:
      - backend
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

volumes:
  app_data:
    driver: local
  db_data:
    driver: local
  redis_data:
    driver: local

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
    internal: true   # No Internet access for this network

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

Key points:

  • restart: unless-stopped: auto-restart except if manually stopped
  • Pinned images: no latest, always 1.2.3
  • Healthchecks on all critical services
  • Named volumes: portable, doesn't pollute the system
  • Separated networks: frontend (web visible) vs backend (DB isolated)
  • Limited logs: 10 MB × 3 files max per container
  • Resources: CPU/RAM caps so a runaway container doesn't eat everything
  • Secrets: passwords in files, not in environment

Step 4: .env file

sudo nano /opt/myapp/.env
# Database
DB_PASSWORD=will_be_overridden_by_secret_file

# Redis
REDIS_PASSWORD=ALongRedisPassword_42

# App
APP_PORT=3000

Reusable variables with ${VAR} in docker-compose.yml.

sudo chmod 600 /opt/myapp/.env

Step 5: File-based secrets

echo "ALongDBPassword_2024" | sudo tee /opt/myapp/secrets/db_password.txt
sudo chmod 600 /opt/myapp/secrets/db_password.txt

Container accesses it via /run/secrets/db_password and env via POSTGRES_PASSWORD_FILE=/run/secrets/db_password.

Advantage vs environment: POSTGRES_PASSWORD=...:

  • Not visible in docker inspect
  • Not exported in process logs/env

Step 6: Start the stack

cd /opt/myapp
sudo docker compose up -d

Verify:

sudo docker compose ps
sudo docker compose logs -f

Expected state:

NAME            STATUS              PORTS
myapp           Up (healthy)        127.0.0.1:3000->3000/tcp
myapp-db        Up (healthy)
myapp-redis     Up (healthy)

Step 7: Healthchecks explained

healthcheck lets Docker know if a container is healthy. Format:

healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
  interval: 30s    # Check frequency
  timeout: 5s      # Max response time
  retries: 3       # Attempts before marking "unhealthy"
  start_period: 30s # Grace period at startup

depends_on: condition: service_healthy waits for the check to pass before starting the dependent app (e.g. app waits for DB to be ready).

Step 8: Automatic volume backup

Docker volumes are stored in /var/lib/docker/volumes/. To back them up:

sudo nano /usr/local/bin/backup-docker-volumes.sh
#!/bin/bash
set -e
BACKUP_DIR=/backup/docker
DATE=$(date +%F_%H%M)
mkdir -p $BACKUP_DIR

cd /opt/myapp

# Briefly stop DB for consistent snapshot (10s max)
docker compose stop db redis

# Backup volumes
for vol in $(docker volume ls -q --filter name=myapp_); do
    docker run --rm \
        -v ${vol}:/source:ro \
        -v ${BACKUP_DIR}:/backup \
        alpine tar -czf /backup/${vol}_${DATE}.tar.gz -C /source .
done

# Restart
docker compose start db redis

# Rotation: keep 7 days
find $BACKUP_DIR -type f -mtime +7 -delete

echo "Backup OK: $DATE"
sudo chmod +x /usr/local/bin/backup-docker-volumes.sh
sudo crontab -e
0 3 * * * /usr/local/bin/backup-docker-volumes.sh

For something cleaner, see the Restic + Backblaze B2 guide.

Step 9: Clean update

cd /opt/myapp

# Pull new image versions
sudo docker compose pull

# Recreate with new images
sudo docker compose up -d --remove-orphans

# Clean old images
sudo docker image prune -f

To automate, use Watchtower:

  watchtower:
    image: containrrr/watchtower
    restart: unless-stopped
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    environment:
      - WATCHTOWER_CLEANUP=true
      - WATCHTOWER_SCHEDULE=0 0 4 * * *  # 4 AM
      - WATCHTOWER_NOTIFICATIONS=email

⚠️ Watchtower updates to the latest tag. If you pin to 1.2.3, it won't update. For auto 1.x, use tag 1 or 1.2.

Step 10: Centralized logs

By default Docker logs are stored as JSON in /var/lib/docker/containers/. To centralize:

sudo docker compose logs --tail=100 -f

For multi-service aggregated, see the Loki + Promtail guide.

Step 11: Resource limits (very important)

Without limits, a leaking container can eat all RAM/CPU:

services:
  app:
    deploy:
      resources:
        limits:
          memory: 1G
          cpus: '1.0'
        reservations:
          memory: 512M
          cpus: '0.5'

⚠️ In "compose" mode (not swarm), deploy: is partially supported. Prefer:

services:
  app:
    mem_limit: 1g
    cpus: 1.0

Step 12: Front reverse proxy

Port 3000 is bound to 127.0.0.1 only. To expose in HTTPS, add Nginx or Traefik in front:

# /etc/nginx/sites-available/myapp
server {
    listen 443 ssl http2;
    server_name app.your-domain.com;
    
    ssl_certificate /etc/letsencrypt/live/app.your-domain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app.your-domain.com/privkey.pem;
    
    location / {
        proxy_pass http://127.0.0.1:3000;
        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;
    }
}

Troubleshooting

Container restarts in loop

sudo docker compose logs app

Common causes:

  • Missing env variable
  • Volume with wrong permissions
  • Healthcheck failing at startup (increase start_period)

"no space left on device"

Clean up:

sudo docker system prune -af --volumes

⚠️ Removes everything unused. Be sure.

Huge logs filling disk

Check logging: is properly configured in each service. Otherwise, Docker logs can grow indefinitely.

Container can't see another

They must be on the same network. Check:

sudo docker compose config
sudo docker network inspect myapp_backend

Useful commands

# Stack state
sudo docker compose ps

# Logs
sudo docker compose logs -f service_name

# Restart a service
sudo docker compose restart app

# Rebuild and restart
sudo docker compose up -d --force-recreate

# Stop without removing
sudo docker compose stop

# Stop + remove containers (keeps volumes)
sudo docker compose down

# Stop + remove EVERYTHING (including volumes ⚠️)
sudo docker compose down -v

# Exec in a container
sudo docker compose exec app sh

# Inspect a service
sudo docker compose config

# View resources
sudo docker stats

# Networks
sudo docker network ls

# Volumes
sudo docker volume ls
sudo docker volume inspect myapp_db_data

Conclusion

With this template, your app is:

  • Resilient: auto-restarts on crash
  • Secure: isolated secrets, non-exposed backend
  • Maintainable: named volumes, separated networks
  • Observed: healthchecks, limited logs

Going further:

  • Migrate to Docker Swarm or Kubernetes when you have 10+ services to orchestrate
  • Use Portainer or Komodo to manage multiple stacks via UI
  • Set up Watchtower for controlled auto-updates

Resources

Join our Discord community server

For any questions, suggestions, or just to chat with the community, join us on Discord!

900+Members