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
-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.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.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.