The Problem with Manual Deployments
Picture this: you've just pushed the last bug fix for a client's SaaS app at 11 PM on a Friday. Now you have to open your terminal, SSH into the server, , git pull, rebuild, and pray PM2 restarts cleanly — while hoping you don't accidentally npm install out of the wrong window.ctrl+C
Manual deployments are brittle, slow, and entirely dependent on you being available. For a freelancer juggling three projects or a small team shipping fast, this becomes a real bottleneck. Worse, it's just unnecessary toil in 2026.
The answer is wiring up SSH deployment with GitHub Actions on your VPS. Every time you push to a branch, your server pulls the latest code, installs dependencies, builds, and restarts — all without you touching anything. You push code. Coffee gets poured. App is live.
What you'll achieve by the end
A fully automated CI/CD pipeline where pushing to staging or main on GitHub triggers a deployment to your VPS within seconds — zero manual SSH required.
Quick note on hosting: for this guide I'm using a Hostinger KVM VPS. I've been on it for a couple of projects now — the pricing is genuinely hard to beat for the specs you get, and their hPanel makes initial setup much less painful than raw DigitalOcean droplets (especially if a client is going to manage it after handoff). If you don't have a VPS yet, it's worth a look — grab one here before we start. Any Ubuntu 22.04 VPS will work fine though.
Architecture Overview
Before diving into commands, let's establish the mental model. The whole thing is really just a chain of secure handoffs:
Each arrow in that diagram is a trust relationship. GitHub Actions holds your private SSH key (as a secret), uses it to connect to your VPS, and then runs shell commands on your behalf. The VPS trusts the key because its public counterpart lives in ~/.ssh/authorized_keys.
Understanding SSH (Simply)
SSH can feel like magic until you understand the two-key dance. Think of it like a padlock system:
Private Key (id_rsa)
Lives on your machine (and in GitHub Secrets). Never share this. It's the key that unlocks the padlock. If someone gets this, they have access to everything you've unlocked with it.
Public Key (id_rsa.pub)
Lives on your VPS in ~/.ssh/authorized_keys. This is the padlock itself — you can share it freely. Anyone with the matching private key can connect.
The Most Common Mistakes
Mistake #1 — Wrong key in wrong place: People paste the public key into GitHub Secrets instead of the private key. GitHub Actions needs the private key to authenticate. The VPS needs the public key in authorized_keys.
Mistake #2 — Wrong permissions on .ssh: SSH is paranoid about file permissions by design. If ~/.ssh is world-readable, SSH refuses to use your keys. Always: chmod 700 ~/.ssh and chmod 600 ~/.ssh/authorized_keys.
Heads up:
The key format matters. Modern systems use Ed25519 by default. The older appleboy/ssh-action versions may need RSA. We'll use RSA in this guide for maximum compatibility.
VPS Setup — Hostinger Ubuntu (2026)
This guide uses Hostinger VPS running Ubuntu 22.04 LTS, but the steps apply to any Ubuntu-based VPS — DigitalOcean, Vultr, Hetzner. First, connect via root and get your server production-ready.
Connect to Your VPS
ssh root@YOUR_VPS_IPUpdate the System
sudo apt update && sudo apt upgrade -yInstall Node.js (LTS via NVM — Recommended)
NVM lets you switch Node versions without breaking anything. Far better than apt install nodejs.
# Install NVM
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh | bash
# Reload shell config
source ~/.bashrc
# Install latest LTS (Node 22 as of 2026)
nvm install --lts
nvm use --lts
# Verify
node -v # → v22.x.x
npm -v # → 10.x.xInstall PM2 Globally
PM2 is the process manager that keeps your Node app alive across crashes and reboots.
npm install -g pm2
# Make PM2 auto-start on server reboot
pm2 startup
# (run the command it outputs, then:)
pm2 saveInstall Git
sudo apt install git -y
git --version # → git version 2.x.xSetup UFW Firewall
# Allow SSH (critical — do this FIRST)
sudo ufw allow OpenSSH
# Allow your app port (e.g., 3000 or 443)
sudo ufw allow 3000
sudo ufw allow 80
sudo ufw allow 443
# Enable firewall
sudo ufw enable
sudo ufw statusDanger
Never enable UFW without allowing SSH first. You'll lock yourself out of the server permanently.
Create App Directory
sudo mkdir -p /var/www/app
sudo chown -R $USER:$USER /var/www/appSSH Key Setup (The Critical Part)
This is where most tutorials fall apart. Let's be precise. You're creating a dedicated deploy key — not using your personal SSH keys. This is the right way to do it.
Step 1 — Generate the Key Pair (on your local machine)
# Generate RSA 4096-bit key (no passphrase for CI/CD)
ssh-keygen -t rsa -b 4096 -C "github-actions-deploy" -f ~/.ssh/github_actions_deploy
# This creates two files:
# ~/.ssh/github_actions_deploy ← PRIVATE KEY (goes to GitHub Secrets)
# ~/.ssh/github_actions_deploy.pub ← PUBLIC KEY (goes to VPS)Warning
When prompted for a passphrase, press Enter to leave it empty. GitHub Actions can't enter a passphrase interactively. A passphrase-protected key will silently fail.
Step 2 — Copy Public Key to VPS
# Method A — ssh-copy-id (easiest)
ssh-copy-id -i ~/.ssh/github_actions_deploy.pub user@YOUR_VPS_IP
# Method B — manual paste
cat ~/.ssh/github_actions_deploy.pub
# → Copy output, then on VPS:
echo "PASTE_PUBLIC_KEY_HERE" >> ~/.ssh/authorized_keys
# Fix permissions on VPS
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keysStep 3 — Test the Connection
ssh -i ~/.ssh/github_actions_deploy user@YOUR_VPS_IP
# If it connects without asking for a password → ✅ you're good
# If you see "Permission denied (publickey)" → see Section 116. Connect GitHub Repo via SSH
Your VPS needs to clone your repo using SSH (not HTTPS), so it doesn't need to type a password on every pull. Add your public key as a GitHub Deploy Key first:
- Go to your GitHub repo → Settings → Deploy keys → Add deploy key
- Paste the contents of
~/.ssh/github_actions_deploy.pub - Check "Allow write access" only if you need to push from the server (usually not needed)
Then on your VPS, clone or update the remote URL:
# Initial clone (on VPS)
cd /var/www
git clone [email protected]:username/your-repo.git app
# Or update existing remote URL
cd /var/www/app
git remote set-url origin [email protected]:username/your-repo.git
# Test it
git fetch origin
git pull origin stagingSuccess
If you get "Host key verification failed" on the first clone, run ssh -T [email protected] on the VPS once to accept GitHub's host fingerprint.
7. GitHub Secrets Setup
GitHub Secrets are encrypted key-value pairs that get injected into your Actions workflow at runtime. Never hardcode your server IP or SSH key in YAML — always use secrets.
Go to: GitHub Repo → Settings → Secrets and variables → Actions → New repository secret
Info
When copying the private key, copy the entire file content — including -----BEGIN RSA PRIVATE KEY----- and -----END RSA PRIVATE KEY-----. Missing these lines breaks authentication.
8. GitHub Actions Workflow (2026)
Create .github/workflows/deploy.yml in your repository root. This is the file GitHub Actions watches. Here's a production-ready workflow using the appleboy/ssh-action — the most battle-tested SSH action available:
name: Deploy to VPS
on:
push:
branches:
- staging # triggers on push to staging
- main # triggers on push to main (production)
jobs:
deploy:
name: 🚀 SSH Deploy
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: 🔍 Checkout Code
uses: actions/checkout@v4
- name: 🚀 Deploy via SSH to VPS
uses: appleboy/[email protected]
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: ${{ secrets.VPS_PORT }}
script: |
# Navigate to app directory
cd /var/www/app
# Pull latest code from current branch
git pull origin ${{ github.ref_name }}
# Install dependencies (ci is faster + deterministic)
npm ci
# Build the application (Angular / NestJS / etc.)
npm run build
# Restart with PM2 (zero downtime)
pm2 reload ecosystem.config.js --env production
# Save PM2 state
pm2 saveSuccess
Use npm ci instead of npm install in CI environments. It's faster, uses package-lock.json exactly, and errors on any deviation from the lockfile. Your builds become reproducible.
9. Real Node.js App Deployment
Whether you're running Express, NestJS, or a Fastify API, the deployment pattern is the same. Here's a NestJS example — one of the most common production setups in 2026.
Project Structure Assumption
your-repo/
├── .github/
│ └── workflows/
│ └── deploy.yml
├── src/
├── dist/ ← built output
├── ecosystem.config.js
├── package.json
└── package-lock.jsonPM2 Ecosystem Config
Create ecosystem.config.js in your project root. This tells PM2 exactly how to run your app across environments.
module.exports = {
apps: [
{
name: 'my-saas-app',
script: 'dist/main.js', // NestJS build output
instances: 'max', // use all CPU cores
exec_mode: 'cluster', // cluster mode for load balancing
watch: false, // don't watch files in prod
env_staging: {
NODE_ENV: 'staging',
PORT: 3001,
},
env_production: {
NODE_ENV: 'production',
PORT: 3000,
},
error_file: 'logs/err.log',
out_file: 'logs/out.log',
log_date_format: 'YYYY-MM-DD HH:mm:ss',
},
],
};Initial App Start (One-time on VPS)
cd /var/www/app
npm ci
npm run build
pm2 start ecosystem.config.js --env production
pm2 save
After the first manual start, every subsequent deploy via GitHub Actions will use pm2 reload — which does a graceful restart with zero downtime.
10. Zero Downtime Deployment (Advanced)
The difference between pm2 restart and pm2 reload is everything in production. Here's what each does:
pm2 restart
Stops all processes, then starts them again. There's a gap between stop and start where your app is down. Users hit 502 errors. Never use this in production during a deploy.
pm2 reload
In cluster mode, PM2 restarts one process at a time. Old processes handle traffic while new ones boot. Once a new process is ready, traffic shifts over. Zero downtime.
Your deploy script should look like this for zero-downtime:
cd /var/www/app
git pull origin main
npm ci --only=production
npm run build
pm2 reload ecosystem.config.js --env production # ← graceful rolling reload
pm2 save11. Common Errors & Fixes
12. Production Best Practices
Separate Staging & Production Branches
Use a staging branch that deploys to a staging server, and main for production. This way every bug gets caught on staging before it ever touches production traffic. Use github.ref_name in your script to pull the right branch dynamically.
Use Deploy Keys, Not Personal Keys
Never add your personal SSH key to GitHub Secrets. Generate a dedicated deploy key pair. If it gets compromised, you revoke just that key — not your entire SSH identity. Rotate keys every 6-12 months.
Log Monitoring with PM2
pm2 logs my-saas-app # live logs
pm2 logs --lines 200 # last 200 lines
pm2 monit # real-time CPU/memory dashboardGet Notified on Deploy Failure
Add a notification step so you know when a deployment fails:
- name: Notify Slack on failure
if: failure()
uses: slackapi/[email protected]
with:
payload: '{"text":"🚨 Deploy failed on ${{ github.ref_name }}"}'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}On server reliability
For production deployments, stable infrastructure matters. In my experience, Hostinger's KVM VPS tier handles Node.js + PM2 cluster mode without unexpected reboots or throttling — which is more than I can say for some budget providers I've tried. If you're setting up a client's production environment, starting with a reliable VPS saves a lot of midnight debugging.
13. Pro Tips (Senior Dev Insights)
These are the things you only learn after shipping to production a dozen times:
Rotate Deploy Keys
Treat SSH keys like passwords. Set a calendar reminder to rotate your deploy keys every 6 months. The effort is minimal; the security gain is real.
Branch Protection Rules
Require PR reviews before merging to main. GitHub's branch protection prevents anyone from pushing directly to production — including yourself at 1 AM.
Run Tests Before Deploy
Add a test job before the deploy job. Use needs: test to gate the deploy. Never ship code that hasn't passed your test suite.
Monitor With PM2 Plus
PM2's cloud dashboard shows real-time metrics, memory usage, and crash alerts. The free tier is enough for most indie projects.
Consider Docker Next
This SSH approach is great for getting started. When you outgrow it, containerize with Docker and use GitHub Actions to push images to a registry. Same principles, better isolation.
Keep .env Out of Git
Never commit .env files. Store environment variables in PM2's ecosystem config or use a secrets manager. CI/CD is no excuse for shipping credentials.
Workflow caching tip
Add a cache step for node_modules on the GitHub Actions runner side if you're running tests there too. It won't speed up the VPS deploy, but it'll cut your CI test time significantly.


