Docker Compose in Production: 12 Things You're Probably Missing

services:
app:
restart: unless-stopped
healthcheck:
deploy.resources:
secrets: ❌ missing
logging: ❌ missing

Most tutorials show you how to get Docker Compose running. Almost none of them show you how to run it safely in production. Here are 12 things the tutorials skip — copy-paste ready.

1. Always Set restart: unless-stopped

Without a restart policy, containers that crash (or the host that reboots) stay down forever.

services:
  app:
    image: myapp:latest
    restart: unless-stopped   # Also: always, on-failure

Use unless-stopped for most services. Use on-failure for workers that should restart on crashes but not on a clean docker compose stop.

2. Add Healthchecks to Every Service

Without healthchecks, depends_on only waits for the container to start — not for it to be ready to accept traffic.

services:
  postgres:
    image: postgres:16-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER"]
      interval: 5s
      timeout: 5s
      retries: 5
      start_period: 10s

  app:
    image: myapp:latest
    depends_on:
      postgres:
        condition: service_healthy   # Wait for DB to be ready

3. Set Resource Limits

Containers without limits can eat all host memory, crashing every other service on the instance.

services:
  worker:
    image: myworker:latest
    deploy:
      resources:
        limits:
          cpus: "1.5"
          memory: 1G
        reservations:
          cpus: "0.25"
          memory: 256M
💡

Start with limits.memory = 2x your expected usage. Monitor for a week, then tighten. VeloxaHost's live resource graphs make this easy.

4. Use Docker Secrets (Not Environment Variables) for Passwords

Environment variables are visible in docker inspect, process lists, and crash reports. Docker secrets mount as files — much safer.

# Define secrets at the top level
secrets:
  db_password:
    file: ./secrets/db_password.txt   # chmod 600

services:
  app:
    image: myapp:latest
    secrets:
      - db_password
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password   # App reads the file

5. Configure Log Rotation

The default Docker logging driver writes unbounded JSON files. On a busy server, this fills your disk in days.

services:
  app:
    image: myapp:latest
    logging:
      driver: json-file
      options:
        max-size: "50m"     # Rotate when file hits 50MB
        max-file: "5"       # Keep 5 rotated files max

Or set it globally in /etc/docker/daemon.json for all containers:

{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "50m",
    "max-file": "5"
  }
}

6. Use Named Networks (Don't Use the Default)

The default Compose network exposes all containers to each other. Use separate networks to segment services — your DB shouldn't be reachable by your frontend container directly.

services:
  nginx:
    networks: [frontend]
  app:
    networks: [frontend, backend]
  postgres:
    networks: [backend]   # Not reachable from nginx

networks:
  frontend:
  backend:
    internal: true   # No outbound internet access from backend

7. Separate .env Files per Environment

├── .env              # Local dev defaults (commit to git)
├── .env.production   # Production overrides (NEVER commit)
├── .env.staging      # Staging overrides
# Use a specific .env file
docker compose --env-file .env.production up -d

Add .env.production and .env.staging to .gitignore.

8. Name Your Volumes Explicitly

Anonymous volumes (just specifying a path) get deleted on docker compose down -v. Named volumes persist unless explicitly removed.

services:
  postgres:
    volumes:
      - postgres_data:/var/lib/postgresql/data   # Named volume — safe

volumes:
  postgres_data:   # Declared explicitly

9. Set stop_grace_period

By default, Docker gives a container 10 seconds to shut down gracefully, then sends SIGKILL. For databases or message queues, this is often too short.

services:
  worker:
    image: myworker:latest
    stop_grace_period: 60s   # Give workers 60s to finish current jobs
  app:
    stop_grace_period: 30s   # Give the app 30s to drain connections

10. Use Profiles for Optional Services

Not every service needs to run all the time. Put infrequently used services (backups, migrations, debug tools) behind profiles.

services:
  migrate:
    image: myapp:latest
    profiles: ["migrate"]
    command: python manage.py migrate

  backup:
    image: postgres:16-alpine
    profiles: ["backup"]
    command: /backup.sh
# Run only when needed
docker compose --profile migrate up migrate
docker compose --profile backup up backup

11. Use YAML Anchors for DRY Configs

Stop copy-pasting the same environment block across services:

x-common-env: &common-env
  DATABASE_URL: postgresql://...
  REDIS_URL: redis://...
  APP_ENV: production

services:
  app:
    environment: *common-env   # Inherits all of x-common-env

  worker:
    environment:
      <<: *common-env          # Merge and add extras
      WORKER_CONCURRENCY: "4"

12. Automate Image Updates (With Care)

For non-critical services, Watchtower can automatically pull and restart containers when new images are available:

services:
  watchtower:
    image: containrrr/watchtower
    restart: unless-stopped
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    command: --schedule "0 0 4 * * *" --cleanup   # Daily at 4am
⚠️

Don't use Watchtower for databases or stateful services — auto-updating those is risky. Pin their image tags explicitly (postgres:16.2-alpine not postgres:latest).


Deploy your production Docker stacks on VeloxaHost — reliable networking, persistent volumes, and monitoring included. Start free →