Containerizing Applications on a VPS: Docker Compose for Production Use

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: true network: 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 .env file (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-only volumes (:ro): Configuration files mounted from the host should be read-only. A compromised container cannot modify nginx.conf or init.sql.
  • expose vs ports: expose makes the port available to other containers on the same network. ports publishes it to the host. Only the reverse proxy should use ports.

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 --volumes weekly, 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:ro if 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