Last updated Nov 5, 2026

Automated Deployment with SSH on VPS using GitHub Actions

Stop SSH-ing into servers at 2 AM. Learn how to wire GitHub Actions to your VPS for push-to-deploy automation that just works — for Node.js, NestJS, and any SaaS stack.

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, npm install, rebuild, and pray PM2 restarts cleanly — while hoping you don't accidentally ctrl+C out of the wrong window.

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.

PRO TIP

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.

  

WARNING

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_IP

Update the System

sudo apt update && sudo apt upgrade -y

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

Install 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 save

Install Git

sudo apt install git -y
git --version  # → git version 2.x.x

Setup 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 status
DANGER

Danger

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/app

SSH 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

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_keys

Step 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 11

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

  1. Go to your GitHub repo → Settings → Deploy keys → Add deploy key
  2. Paste the contents of ~/.ssh/github_actions_deploy.pub
  3. 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 staging
SUCCESS

Success

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

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 save
SUCCESS

Success

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

PM2 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 save

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

Get 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 }}
INFO

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.

INFO

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.

Available for projectsLet’s build together

Need this built properly,
not just explained?

I take on Angular and NestJS work directly — auth systems, multi-tenant SaaS, API rewrites and the deploy pipeline underneath them. You talk to the person writing the code.

Projects delivered
20+ Projects delivered
Angular • NestJS
7+ years Angular • NestJS
Average reply time
1 day Average reply time

What I can help with

  • Auth and permissions systems
  • Multi-tenant SaaS foundations
  • API design and rewrites
  • Deploy pipelines and VPS setup
  • Bug fixing and performance optimization
Send me a message

Tell me about your project and I’ll get back with a realistic scope and price.

Turn
Your Ideas
Into Reality

Stay in the loop

Get the finished tutorials, projects and resources in your inbox.

Join 5,000+ developers and get new tutorials, source code, product updates and exclusive content — straight to your inbox.

  • No spam
  • Useful content only
  • Unsubscribe anytime

Subscribe to TutsCoder

Be the first to get new tutorials, projects and tips.

Join 5,000+ developers already learning with us.

Learn
Build
Grow

Discussion

Comments

Share your thoughts, ask questions or suggest improvements. Let’s learn and grow together!

Loading comments…

Join the discussion

Sign in to join the discussion. Your account also keeps your saved tutorials and path progress.