Running applications directly on a VPS works until you need to move them, replicate them, or update dependencies without breaking everything. Docker Compose gives you a single YAML file that defines your entire application stack — containers, networks, volumes, environment variables, and restart policies. This guide covers the production-ready setup: installing Docker, writing a secure Compose file, managing persistent data, setting up health checks, and handling logging and resource limits.
Why Docker Compose on a VPS
Docker Compose is not just for development. On a single VPS, it replaces the need for configuration management tools for most small to medium deployments. A single docker compose up -d starts your entire stack. A docker compose pull && docker compose up -d updates all containers to the latest versions. The Compose file is self-documenting — anyone who inherits the server can read the YAML and understand exactly what is running and how it is configured.
Compared to Kubernetes, Docker Compose is dramatically simpler. It does not handle multi-node orchestration, but on a single VPS with 2–8 GB of RAM, it handles a dozen containers without issue.
Step 1: Install Docker and Docker Compose
Use the official Docker repository, not the distribution package. The distribution package is often outdated:
# Ubuntu/Debian
sudo apt update
sudo apt install ca-certificates curl -y
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
-o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) \
signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin -y
# Verify
sudo docker --version
sudo docker compose version
Add your user to the docker group to avoid typing sudo for every command. Be aware that this grants the user effective root access:
sudo usermod -aG docker $USER
newgrp docker
Step 2: A Production-Ready Compose File
Here is a complete Docker Compose file for a typical web application — Nginx reverse proxy, a Python/Node.js application, and PostgreSQL:
# docker-compose.yml
version: "3.8"
services:
reverse-proxy:
image: nginx:1.25-alpine
container_name: nginx-proxy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./certbot/www:/var/www/certbot:ro
- ./certbot/conf:/etc/letsencrypt:ro
- nginx_logs:/var/log/nginx
networks:
- frontend
depends_on:
app:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80/health"]
interval: 30s
timeout: 5s
retries: 3
deploy:
resources:
limits:
memory: 128M
cpus: "0.5"
app:
build:
context: ./app
dockerfile: Dockerfile
container_name: myapp
restart: unless-stopped
expose:
- "3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://app_user:${DB_PASSWORD}@db:5432/app_db
volumes:
- app_uploads:/app/uploads
networks:
- frontend
- backend
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
reservations:
memory: 256M
db:
image: postgres:16-alpine
container_name: postgres
restart: unless-stopped
environment:
- POSTGRES_USER=app_user
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=app_db
volumes:
- pgdata:/var/lib/postgresql/data
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
networks:
- backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app_user -d app_db"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true
volumes:
pgdata:
driver: local
app_uploads:
driver: local
nginx_logs:
driver: local
Step 3: Security Hardening in the Compose File
Each directive in the Compose file above serves a security purpose:
internal: truenetwork: The backend network is isolated — containers on it can communicate with each other but have no route to the internet. Only the reverse proxy needs external access.${DB_PASSWORD}via environment variable: Never hardcode secrets in docker-compose.yml. Use a.envfile (added to.gitignore) or Docker secrets.deploy.resources.limits: Without resource limits, a runaway container can consume all VPS memory and trigger the OOM killer. Set explicit memory and CPU limits for every service.read-onlyvolumes (:ro): Configuration files mounted from the host should be read-only. A compromised container cannot modify nginx.conf or init.sql.exposevsports:exposemakes the port available to other containers on the same network.portspublishes it to the host. Only the reverse proxy should useports.
Step 4: Persistent Data Management
Named volumes are the safest way to persist data. They are managed by Docker and stored in /var/lib/docker/volumes/. Bind mounts (host directories) are simpler but riskier — a misconfigured container can write anywhere on the host filesystem.
Back up named volumes with a simple script:
#!/bin/bash
# backup-volumes.sh
BACKUP_DIR="/backups/docker/$(date +%Y-%m-%d)"
mkdir -p "$BACKUP_DIR"
for volume in pgdata app_uploads; do
docker run --rm \
-v ${volume}:/data:ro \
-v "$BACKUP_DIR":/backup \
alpine tar czf /backup/${volume}.tar.gz -C /data .
done
# Keep only last 7 days
find /backups/docker -maxdepth 1 -type d -mtime +7 -exec rm -rf {} \;
Step 5: Logging Configuration
By default, Docker captures container stdout/stderr in JSON files. On a production VPS, this can fill the disk. Configure log rotation in /etc/docker/daemon.json:
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
sudo systemctl restart docker
For centralized logging, add a logging driver like fluentd or loki in the daemon configuration or per-container in the Compose file.
Step 6: Deployment and Update Workflow
# Pull latest images and recreate containers
cd /opt/myapp
docker compose pull
docker compose up -d --remove-orphans
# Check that all containers are healthy
docker compose ps
# View logs for the last 5 minutes
docker compose logs --since 5m
# Roll back to a specific image tag if needed
docker compose up -d app=myapp:1.2.3
Pin image tags to specific versions in production (nginx:1.25-alpine, not nginx:latest). The latest tag can introduce breaking changes without warning. Use docker compose pull to check for updates and then decide whether to apply them.
Common Pitfalls on a VPS
- Running out of disk space: Docker images, volumes, and logs accumulate quickly. Run
docker system prune -a --volumesweekly, but only after verifying you are not deleting active volumes. Set up a cron job for this. - Memory overcommit: If the sum of container memory limits exceeds the VPS RAM, the OOM killer will terminate containers unpredictably. Leave 500 MB–1 GB free for the host OS and Docker daemon overhead.
- Port conflicts: Only one container can bind to a host port. Use a reverse proxy (Nginx, Traefik, Caddy) to route traffic to multiple containers by hostname, not by port.
- iptables interference: Docker manipulates iptables rules. If you have custom firewall rules, test them carefully. Docker’s rules take precedence and can override your configuration.
- Timezone in containers: Most container images default to UTC. Mount
/etc/localtime:/etc/localtime:roif your application needs the host timezone.
When to Move Beyond Docker Compose
Docker Compose works well on a single VPS up to a point. You need a more advanced orchestrator when:
- You need zero-downtime rolling updates across multiple servers.
- You need automatic scaling based on CPU or request load.
- You need to schedule containers across a pool of VPS nodes.
- You need built-in service discovery and load balancing.
At that point, Docker Swarm (simple) or Kubernetes (powerful) becomes the right choice. But for the vast majority of single-VPS deployments, Docker Compose is the sweet spot between simplicity and power. For more on choosing a VPS with enough resources to run your containerized stack, see our VPS hosting comparison.




Leave a Reply
You must be logged in to post a comment.