GitHub Actions CI/CD Pipeline to a VPS: Complete 2026 Guide
Manual deployment — SSH into server, git pull, restart application — doesn't scale and is error-prone. GitHub Actions automates this: push code, tests run, deployment happens automatically if tests pass.
The Basic Pipeline
# .github/workflows/deploy.yml
name: Test and Deploy
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm test
deploy:
needs: test # Only runs if test job passes
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to VPS
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.VPS_IP }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
cd /var/www/my-app
git pull origin main
npm ci --production
pm2 reload my-app
Setting Up SSH Key Authentication for CI/CD
# Generate a dedicated deploy key (on your local machine):
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/deploy_key
# Add public key to your VPS:
ssh-copy-id -i ~/.ssh/deploy_key.pub user@your-vps-ip
# Add PRIVATE key to GitHub Secrets:
# Repository → Settings → Secrets and variables → Actions → New repository secret
# Name: VPS_SSH_KEY
# Value: contents of ~/.ssh/deploy_key (private key)
Docker Compose Deployment Pattern
For containerised applications:
- name: Deploy Docker Stack
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.VPS_IP }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
cd /opt/my-app
git pull origin main
docker compose pull
docker compose up -d --remove-orphans
docker image prune -f
Zero-Downtime for Node.js with PM2
# On your VPS app directory:
# pm2 reload performs graceful restart (processes existing requests before shutdown)
pm2 reload ecosystem.config.js --update-env
Use pm2 reload (not pm2 restart) for zero-downtime deployments — it gracefully cycles processes rather than killing them.
Notifications on Deployment
Add a Slack or Discord notification on deployment completion:
- name: Notify Discord
if: always()
run: |
curl -X POST \
-H "Content-Type: application/json" \
-d "{"content": "Deploy ${{ job.status }}: ${{ github.event.head_commit.message }}"}" \
${{ secrets.DISCORD_WEBHOOK }}