Deploying to a VPS — Full Stack on a Single Server

Deploying to a VPS (Virtual Private Server) gives you full control over the production environment at a fraction of the cost of managed cloud services. A $6/month Hetzner or DigitalOcean droplet is sufficient for a blog application with moderate traffic. The deployment process involves: provisioning the server, installing Docker, cloning the repository, configuring environment variables, starting the stack, and pointing a domain at the server IP. Automating this with GitHub Actions means future deployments are a single git push.

Server Provisioning Checklist

# 1. Create server (DigitalOcean, Hetzner, Linode)
#    - Ubuntu 24.04 LTS
#    - 2 vCPU, 4GB RAM minimum for blog
#    - Enable SSH key authentication during creation

# 2. First login and basic hardening
ssh root@YOUR_SERVER_IP

# Create a non-root deploy user
adduser deploy
usermod -aG sudo deploy
# Copy your SSH key to deploy user
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy

# Configure UFW firewall
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable

# Disable password authentication (SSH key only)
sed -i 's/#PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config
systemctl restart sshd

# 3. Install Docker
curl -fsSL https://get.docker.com | sh
usermod -aG docker deploy

# 4. Clone repository and configure
su - deploy
git clone https://github.com/yourname/blog-app.git
cd blog-app
cp .env.example .env.production
nano .env.production   # fill in real values

# 5. Start the stack
docker compose -f docker-compose.yml up -d

# 6. Obtain SSL certificate
docker run --rm \
  -v ./certbot/conf:/etc/letsencrypt \
  -v ./certbot/www:/var/www/certbot \
  -p 80:80 \
  certbot/certbot certonly --standalone \
  -d blog.example.com --agree-tos --email you@example.com

# Restart Nginx to pick up the certificate
docker compose restart nginx
Note: The -d flag in docker compose up -d runs the stack in detached mode (background). Monitor the logs with docker compose logs -f (all services) or docker compose logs -f backend (specific service). If a container exits unexpectedly, docker compose ps shows its status and docker compose logs backend shows its output including any startup errors or crash output.
Tip: Set up automatic security updates on the VPS: apt install unattended-upgrades and enable them with dpkg-reconfigure unattended-upgrades. This automatically installs security patches for the operating system without manual intervention. For Docker images, set up a periodic docker compose pull && docker compose up -d to pick up patched base images (postgres, nginx, redis) — but only do this for patch versions, not minor/major updates that may require configuration changes.
Warning: Never store deployment secrets (private SSH keys, API tokens) in the repository. Use GitHub Actions encrypted secrets (Settings > Secrets and variables > Actions) for sensitive deployment values. The deploy job uses ${{ secrets.SSH_PRIVATE_KEY }} — the value is never visible in logs or in the repository. Rotate these secrets whenever a team member leaves or a secret is suspected compromised.

GitHub Actions Auto-Deploy

# .github/workflows/deploy.yml
name: Deploy to Production

on:
  push:
    branches: [main]   # auto-deploy on push to main

jobs:
  deploy:
    name: Deploy
    runs-on: ubuntu-latest
    needs: [backend, frontend]   # only deploy if tests pass (from ci.yml)
    environment: production       # requires manual approval if configured

    steps:
      - uses: actions/checkout@v4

      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host:        ${{ secrets.DEPLOY_HOST }}
          username:    deploy
          key:         ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /home/deploy/blog-app

            # Pull latest code
            git pull origin main

            # Build and restart containers
            docker compose pull
            docker compose build --no-cache backend frontend
            docker compose up -d --no-deps backend frontend nginx

            # Verify deployment
            sleep 5
            curl -f http://localhost/api/health || exit 1

            echo "Deployment successful"

Monitoring and Logging

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

# Check container health
docker compose ps

# Resource usage
docker stats

# Application-level health check
curl https://blog.example.com/api/health

# Database connection check
docker compose exec db pg_isready -U blog_user

# View Nginx access logs
docker compose exec nginx tail -f /var/log/nginx/access.log

Common Mistakes

Mistake 1 — Deploying directly to main branch without tests

❌ Wrong — push to main immediately deploys, even broken code.

✅ Correct — use needs: [backend, frontend] in the deploy job so deployment only proceeds if CI passes.

Mistake 2 — Not testing the health endpoint after deploy

❌ Wrong — deploy script exits after docker compose up -d regardless of whether the containers actually started successfully.

✅ Correct — curl the health endpoint after a brief pause; if it fails, the deployment is marked failed in GitHub Actions and the team is alerted.

🧠 Test Yourself

The GitHub Actions deploy job SSHes into the server and runs docker compose up -d backend. The new container starts but immediately exits due to a missing environment variable. The old container is gone. What is the production state?