Deploy Docker Compose to a VPS — Production-Ready Setup

Why Deploy to a VPS?

A Virtual Private Server (VPS) gives you full control over your infrastructure. It is more flexible than PaaS (Heroku, Render), more affordable than managed Kubernetes, and gives you the same Linux environment as production cloud servers.

Why this matters for your career:

  • VPS deployment skills are essential for freelance developers
  • Understanding server setup helps you debug production issues
  • VPS is often the most cost-effective choice for early-stage projects
  • The skills transfer directly to cloud servers (EC2, DigitalOcean Droplets)

Step-by-Step VPS Deployment

Step 1: Initial Server Setup

# SSH into your VPS
ssh root@your-server-ip

# Update system
apt update && apt upgrade -y

# Create a non-root user
adduser deploy
usermod -aG sudo deploy

# Disable root SSH login (edit /etc/ssh/sshd_config)
# PermitRootLogin no
systemctl restart sshd

# Exit and reconnect as deploy user
exit
ssh deploy@your-server-ip

Step 2: Install Docker and Docker Compose

# Install Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER

# Log out and back in for group changes to take effect

# Install Docker Compose (standalone, latest)
sudo curl -SL "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose

# Verify
docker --version
docker-compose --version

Step 3: Set Up the Application

# Create application directory
mkdir -p ~/apps/myapp
cd ~/apps/myapp

# Copy your docker-compose files (from git or scp)
git clone https://github.com/yourorg/your-repo.git .

# Create production .env file
cat > .env << 'EOF'
NODE_ENV=production
DB_PASSWORD=your-strong-password-here
DB_USER=myapp
DB_NAME=myapp_production
EOF

chmod 600 .env  # Restrict permissions on .env file

# Start services
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d

# Check status
docker-compose ps
docker-compose logs --tail=50

Step 4: Nginx Reverse Proxy

# Install Nginx
sudo apt install nginx -y

# Create site config
sudo cat > /etc/nginx/sites-available/myapp << 'EOF'
server {
    listen 80;
    server_name myapp.example.com;

    # Redirect all HTTP to HTTPS
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name myapp.example.com;

    ssl_certificate /etc/letsencrypt/live/myapp.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/myapp.example.com/privkey.pem;

    # Security headers
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Content-Type-Options nosniff;
    add_header X-Frame-Options DENY;

    location / {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
    }

    location /static/ {
        alias /var/www/myapp/static/;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }
}
EOF

# Enable site
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t  # Test configuration
sudo systemctl reload nginx

Step 5: SSL Certificate (Let's Encrypt)

# Install Certbot
sudo apt install certbot python3-certbot-nginx -y

# Obtain certificate
sudo certbot --nginx -d myapp.example.com

# Auto-renewal (certbot creates a systemd timer automatically)
sudo certbot renew --dry-run

Step 6: Firewall Configuration

# Install ufw
sudo apt install ufw -y

# Configure rules
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow ssh
sudo ufw allow http
sudo ufw allow https

# Enable firewall
sudo ufw --force enable

# Check status
sudo ufw status

Complete Production docker-compose.yml

version: "3.9"
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile.prod
    restart: always
    env_file: .env
    expose:
      - "3000"  # Only expose to internal network
    networks:
      - app_net
    depends_on:
      db:
        condition: service_healthy
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

  db:
    image: postgres:16-alpine
    restart: always
    env_file: .env
    volumes:
      - pgdata:/var/lib/postgresql/data
    networks:
      - app_net
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-myapp}"]
      interval: 10s
      timeout: 5s
      retries: 5
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

networks:
  app_net:

volumes:
  pgdata:

Monitoring and Maintenance

# View logs
docker-compose logs --tail=100 -f

# Check resource usage
docker stats

# Update the application
git pull
docker-compose build
docker-compose up -d

# View old containers (for cleanup)
docker container ls -a | grep weeks ago

# Clean up unused images and containers
docker system prune -a --volumes

Automated Deployments with GitHub Actions

# .github/workflows/deploy.yml
name: Deploy to VPS
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4

    - name: Deploy via SSH
      uses: appleboy/ssh-action@v1
      with:
        host: ${{ secrets.VPS_HOST }}
        username: ${{ secrets.VPS_USER }}
        key: ${{ secrets.VPS_SSH_KEY }}
        script: |
          cd ~/apps/myapp
          git pull
          docker-compose build
          docker-compose up -d
          docker image prune -f  # Remove old images

Security Checklist

| Item | Status | |------|--------| | Non-root user for daily operations | ✅ | | Root SSH login disabled | ✅ | | Firewall (ufw) enabled | ✅ | | SSH key authentication only | ✅ | | SSL/TLS (Let's Encrypt) | ✅ | | .env file permissions (600) | ✅ | | Regular security updates | ✅ (unattended-upgrades) | | Docker not exposed on public port | ✅ | | Database not exposed externally | ✅ | | Log rotation configured | ✅ | | Backup strategy | ⚠️ (needs setup) | | Monitoring (uptime, resource) | ⚠️ (needs setup) |

Summary

Deploying Docker Compose to a VPS gives you full control over your infrastructure at a fraction of the cost of managed services. With Nginx as a reverse proxy, Let's Encrypt for SSL, UFW for firewall, and GitHub Actions for CI/CD, you have a production-ready setup that rivals cloud platforms.

Key takeaways:

  • Use a non-root user and disable root SSH login
  • Install Docker and Docker Compose on the VPS
  • Use Nginx as a reverse proxy with SSL termination
  • Obtain SSL certificates from Let's Encrypt (free, auto-renewing)
  • Configure UFW firewall to allow only SSH, HTTP, HTTPS
  • Use .env files for secrets with restricted permissions
  • Set up automated deployments via GitHub Actions
  • Configure log rotation and regular maintenance
  • Monitor with docker stats and logs

What's Next: DevOps — GitOps

The next course covers GitOps — using Git as the single source of truth for infrastructure and application deployment with ArgoCD.

Unlock Full Tutorial

This chapter is paid content. Join the project to unlock over 5000 words of deep analysis, including 10+ god-tier Prompts and real Source Code examples!