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:
frontendnetwork: Nginx and the API can communicate. Nginx proxies requests to the API.backendnetwork: The API and the database can communicate. The database is not reachable from Nginx or the outside.
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:
- Let's Encrypt via Certbot: Run Certbot on the host, mount
/etc/letsencrypt/live/into the Nginx container. Set up a cron job or systemd timer for auto-renewal. - Cloudflare Origin Certificates: Generate origin certs in the Cloudflare dashboard, place them in
./ssl/. Cloudflare handles client-facing SSL; your origin cert secures the connection between Cloudflare and your server.
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:
- Simple uptime check: A cron job that curls your site and alerts on failure (email, webhook, Discord).
- Prometheus + Grafana: Add both as services in your Compose file. Prometheus scrapes
/metricsendpoints; Grafana visualizes the data. This gives you CPU, memory, request rates, and error rates per service. - Custom monitoring: Build a lightweight monitoring endpoint into your API that checks database connectivity, disk space, and memory — then expose it on an internal-only path.
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:
- You need multiple servers (horizontal scaling).
- You need rolling deployments with zero downtime across nodes.
- You need auto-scaling based on load.
- Your team size requires RBAC and audit trails for deployments.
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.