Running Docker Compose in production is a practical choice for single-server deployments. If you have a VPS or dedicated host, a handful of services, and no need for Kubernetes, a well-structured docker-compose.yml plus a reverse proxy gives you a reliable, easy-to-maintain stack. This guide covers everything you need for a production deployment: services, volumes, networks, environment variables, secrets, restart policies, logging, resource limits, backups, monitoring, and Nginx reverse proxy configuration.

Why Use Docker Compose in Production?

For a single VPS or dedicated host, Docker Compose keeps things simple. You get declarative service definitions, dependency ordering, and a single command to bring the stack up or down. No orchestrator overhead, no cluster to manage, and your docker-compose.yml doubles as living documentation of your entire infrastructure.

The trade-off is horizontal scale: once you need multiple nodes or advanced scheduling, you will outgrow Compose and look at Docker Swarm or Kubernetes. But for freelance projects, SaaS MVPs, internal tools, and portfolio sites — Compose is more than enough. I run my own production stack this way and it has been rock-solid.

If you are coming from a hardened Linux server setup, Compose fits naturally on top of that base: Docker is already installed, your firewall allows the ports you need, and SSH access is locked down.

Production-Ready docker-compose.yml Setup

Start with a clear separation of services, volumes, and networks. Here is a typical pattern for a web app with an API, a database, and a reverse proxy:

services:
  web:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./public:/var/www/html:ro
      - ./ssl:/etc/nginx/ssl:ro
    depends_on:
      - api
    networks:
      - frontend
    restart: unless-stopped

  api:
    build: ./api
    env_file: .env
    depends_on:
      db:
        condition: service_healthy
    networks:
      - frontend
      - backend
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    env_file: .env
    volumes:
      - db_data:/var/lib/postgresql/data
    networks:
      - backend
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 10s
      timeout: 5s
      retries: 3

volumes:
  db_data:

networks:
  frontend:
  backend:

Key points: restart: unless-stopped ensures containers come back after a reboot or crash. The depends_on with condition: service_healthy means the API only starts when Postgres is actually ready to accept connections — not just when the container is running. Named volumes persist data across container recreations.

Docker Compose Networks for Production

By default, Compose puts all services on a single shared network. In production, you should isolate services that do not need to talk to each other. In the example above:

This means even if someone exploits the web server, they cannot directly reach the database. It is a simple but effective layer of defense. Docker's built-in DNS resolves service names within each network, so proxy_pass http://api:3000; works inside the frontend network without publishing any ports.

Environment Variables and Secrets Handling

How do I handle secrets safely in Docker Compose?

Never hardcode credentials in your docker-compose.yml — they end up in version control and docker inspect output. Instead, use an .env file and reference it with env_file. The .env file stays on the server, never gets committed, and can be rotated without rebuilding images.

# .env (never commit this file)
NODE_ENV=production
DATABASE_URL=postgresql://appuser:s3cureP4ss@db:5432/myapp
POSTGRES_USER=appuser
POSTGRES_PASSWORD=s3cureP4ss
POSTGRES_DB=myapp
JWT_SECRET=your-random-secret-here
SMTP_HOST=smtp.provider.com
SMTP_PASS=email-password

Add .env to your .gitignore immediately. For deployment, either copy the .env file to the server manually, use a secure CI/CD pipeline variable, or use a secrets manager. The key rule: secrets never go into version control.

Inside your Compose file, reference variables with ${VARIABLE_NAME} syntax when you need them in the YAML itself (e.g. for Nginx config templating). For services that read them from the process environment, env_file: .env is cleaner and avoids leaking values into docker inspect output.

Restart Policies, Logging, and Resource Limits

What restart policy should I use for production containers?

unless-stopped is the right default for production Docker Compose setups. It restarts the container automatically after crashes and after the host reboots, but respects manual docker compose stop commands. Avoid always if you need to stop the stack cleanly for maintenance without it bouncing back immediately. Use no only for one-off tasks like migrations or seed scripts.

Logging Configuration

Without log rotation, a busy service can fill your disk in hours. Always cap log size:

services:
  api:
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

This keeps at most 30 MB of logs per service (3 files of 10 MB each). For a 4-service stack, that is 120 MB total — predictable and safe. If you need centralized logging later, switch the driver to syslog or fluentd and forward to Loki, Elasticsearch, or a cloud log sink.

Resource Limits

On a small server (4 GB RAM, 2 CPUs), cap each service so one cannot starve the others:

  api:
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
        reservations:
          cpus: "0.25"
          memory: 128M

Without limits, a memory leak in your API can trigger the Linux OOM killer and take down the database with it. With limits, Docker kills only the offending container, and restart: unless-stopped brings it back. Set reservations to guarantee minimum resources for critical services like the database.

Nginx Reverse Proxy and SSL with Docker Compose

In a production Docker Compose setup, Nginx typically sits in front of your application: it terminates TLS, handles rate limiting, serves static files, and proxies dynamic requests to your API. Only Nginx is exposed to the internet.

Nginx Configuration

Mount your config and certificates into the Nginx container. The API does not publish any ports to the host:

  web:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./ssl:/etc/nginx/ssl:ro
      - ./public:/var/www/html:ro
    restart: unless-stopped

Inside nginx.conf, proxy to the API using the Docker service name:

upstream api_backend {
    server api:3000;
}

server {
    listen 443 ssl;
    server_name example.com;

    ssl_certificate /etc/nginx/ssl/origin.crt;
    ssl_certificate_key /etc/nginx/ssl/origin.key;

    location / {
        root /var/www/html;
        try_files $uri $uri/ =404;
    }

    location /api/ {
        proxy_pass http://api_backend;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

This is a real, working configuration — not pseudocode. The upstream block enables future load balancing if you scale to multiple API instances. For more Nginx patterns, see the Nginx wiki reference.

SSL Certificates

Two common approaches:

Either way, mount certificates read-only (:ro) and never commit private keys to your repository.

Backup Strategy for Docker Compose

How do I back up Docker volumes and databases?

Volumes are your persistent state. If they are gone, your data is gone. For databases, always use a logical dump (pg_dump, mysqldump) rather than copying volume files directly — raw file copies while the database is running risk corruption. For config files and static data, a simple tar archive works. Set up automated backups:

#!/bin/bash
# backup.sh — run via cron daily
BACKUP_DIR="/backups/$(date +%Y-%m-%d)"
mkdir -p "$BACKUP_DIR"

# Database dump (consistent, point-in-time)
docker compose exec -T db pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" \
  | gzip > "$BACKUP_DIR/db.sql.gz"

# Config files
tar czf "$BACKUP_DIR/config.tar.gz" nginx/ ssl/ .env

# Retention: keep last 14 days
find /backups -maxdepth 1 -type d -mtime +14 -exec rm -rf {} +

Run this via crontab -e as a daily job: 0 3 * * * /path/to/backup.sh. For critical data, sync backups off-server (rsync to a second VPS, or upload to S3/B2). A backup that only exists on the same disk as your data is not a real backup.

Health Checks and Monitoring

The healthcheck in the Compose file is your first line of defense. Docker marks unhealthy containers and (with restart: unless-stopped) restarts them automatically. Add health checks to every service:

  api:
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s

For monitoring beyond health checks, you have several options without leaving Docker Compose:

At minimum, monitor: uptime (is the site reachable?), disk space (will backups or logs fill it?), and error rates (is the API throwing 500s?). Everything else is a bonus.

Deploying and Maintaining Your Production Stack

For the initial deployment, clone your repo to the server, create the .env file, and bring the stack up:

git clone [email protected]:you/project.git
cd project
cp .env.example .env   # edit with real values
docker compose up -d

For subsequent updates, pull changes and rebuild only what changed:

git pull origin main
docker compose pull        # update base images
docker compose up -d --build  # rebuild custom images, recreate changed containers

Compose only recreates containers whose configuration or image has changed. Unchanged services keep running. For zero-downtime deployments of stateless services, you can scale temporarily: docker compose up -d --scale api=2, then scale back down after the new version is healthy.

Check container status regularly:

docker compose ps           # running state + health
docker compose logs --tail 50 api   # recent logs
docker system df            # disk usage by images/containers/volumes

When to Move Beyond Docker Compose

Is Docker Compose enough for production or do I need Kubernetes?

For a single server with a handful of services, Docker Compose is more than enough — and significantly simpler to operate. You don't need Kubernetes until you actually hit its use cases. Docker Compose in production works well until:

At that point, consider Docker Swarm (if you want to keep the Compose file format) or Kubernetes (if you need the full ecosystem). For most single-server setups, you will not hit these limits for a long time.

Need help designing or hardening your production Docker Compose stack? Get in touch for a free consultation.