DEV Community

Cover image for I Stopped SSH-ing Into My Server to Deploy — Here’s the 30 Lines of YAML That Replaced It
Mushood
Mushood

Posted on

I Stopped SSH-ing Into My Server to Deploy — Here’s the 30 Lines of YAML That Replaced It

Automatic Node.js deployments on any VPS, using GitHub Actions, PM2, and a shell script that lives on the server.

Last month I merged a one-line fix at nine o'clock on a Friday night.

Then came the ritual. Open a terminal. SSH into the box. cd into the project. git pull. npm install. pm2 restart backend-api. Sit there for a few seconds waiting to find out whether anything caught fire.

Six commands for a single changed line.

I've run that sequence something like four hundred times, and every run carries a small tax. Wrong terminal tab. Wrong server. Skipping npm install and then burning twenty minutes hunting a missing dependency that was sitting in package.json the whole time. The commands aren't difficult. Remembering all of them, in order, while tired, is where it goes wrong.

In an earlier post I walked through getting a Node.js backend live on a VPS behind Nginx with PM2. That guide stopped at the manual deploy.

This one throws the manual deploy away.

By the time you finish reading, a merge into main will ship your backend without you. SSH becomes something you use when something's genuinely broken, not something you use on Fridays.

What the Pipeline Actually Does

The shape of it:

You merge a PR into main
  |
  v
GitHub Actions runner starts
  |
  v
Runner SSHs into your VPS with a deploy key
  |
  v
deploy.sh runs: git pull -> npm ci -> pm2 reload
  |
  v
App is live, no downtime
Enter fullscreen mode Exit fullscreen mode

The part worth internalizing: GitHub Actions never builds your app or ships files anywhere. It logs into your server and tells the server to go update itself. That's the whole trick.

For one VPS, this is almost always the right call. No container registry. No artifact uploads. No orchestration layer you'll spend a weekend learning. A key, a script, and a workflow file.

What You Need Before Any of This Works

I'm assuming you've already got:

  • A Node.js backend running on a VPS under PM2
  • The project cloned on that server from a Git repository
  • Nginx sitting in front of it

If any of those are missing, stop here and go set them up. Automating a deployment that doesn't work by hand doesn't give you a pipeline — it gives you two broken things instead of one, and no clear way to tell them apart.

Step 1: Generate a Deploy Key That Only Deploys

Never reuse your personal SSH key for this. Not once, not "just to test it."

The moment your personal private key lands in GitHub Secrets, every collaborator with admin rights on that repo is one workflow file away from everything that key touches. That's not a hypothetical — it's a two-minute exercise for anyone who wants to try.

Make a dedicated key on your local machine:

ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/deploy_key -N ""
Enter fullscreen mode Exit fullscreen mode

You get two files out of that:

  • ~/.ssh/deploy_key — the private half. This one goes into GitHub Secrets.
  • ~/.ssh/deploy_key.pub — the public half. This one goes on your server.

The -N "" flag creates the key without a passphrase. Yes, a passphrase would be stronger. No, GitHub Actions can't type one at three in the morning. So this key ships bare — which is precisely the argument for giving it its own narrow job and nothing else.

Step 2: Install the Public Key on Your Server

Push it up:

ssh-copy-id -i ~/.ssh/deploy_key.pub your-user@your-server-ip
Enter fullscreen mode Exit fullscreen mode

No ssh-copy-id on your machine? Do it the long way. Print the key:

cat ~/.ssh/deploy_key.pub
Enter fullscreen mode Exit fullscreen mode

Then on the server:

nano ~/.ssh/authorized_keys
Enter fullscreen mode Exit fullscreen mode

Drop the key onto its own line and save.

Before you go anywhere near GitHub, prove the key works:

ssh -i ~/.ssh/deploy_key your-user@your-server-ip
Enter fullscreen mode Exit fullscreen mode

If that doesn't log you in, nothing downstream will either. Sort it out now, at the layer where the error messages still make sense to a human.

Step 3: Store Four Secrets in GitHub

In your repository, head to:

Settings → Secrets and variables → Actions → New repository secret

Add these four:

Secret name Value
VPS_HOST your server IP or domain
VPS_USER the SSH user, e.g. ubuntu
VPS_SSH_KEY the entire contents of ~/.ssh/deploy_key
VPS_PORT 22, or whatever custom SSH port you run

For that third one:

cat ~/.ssh/deploy_key
Enter fullscreen mode Exit fullscreen mode

Copy all of it. The -----BEGIN OPENSSH PRIVATE KEY----- line. The -----END OPENSSH PRIVATE KEY----- line. The trailing newline after it.

Roughly half the Permission denied (publickey) failures you'll ever stare at in a deploy log trace back to someone who grabbed only the middle chunk.

Step 4: Put the Deploy Logic in a Script on the Server

You could stuff every deploy command straight into the workflow YAML.

Don't.

Keep them in a script that lives on the server, because:

  • You can run it by hand whenever you need to
  • You can test changes without pushing a commit to trigger anything
  • Editing your deploy steps stops meaning editing your CI config

On the server:

nano ~/deploy.sh
Enter fullscreen mode Exit fullscreen mode

Here's the script:

#!/bin/bash
set -euo pipefail

APP_DIR="$HOME/your-backend-repo"
APP_NAME="backend-api"
BRANCH="main"

echo "==> Deploying $APP_NAME"

cd "$APP_DIR"

echo "==> Pulling latest code"
git fetch origin "$BRANCH"
git reset --hard "origin/$BRANCH"

echo "==> Installing dependencies"
npm ci --omit=dev

echo "==> Reloading PM2"
pm2 reload "$APP_NAME" --update-env

echo "==> Deploy complete"
Enter fullscreen mode Exit fullscreen mode

Make it executable:

chmod +x ~/deploy.sh
Enter fullscreen mode Exit fullscreen mode

Short script, but several of those lines are carrying real weight.

set -euo pipefail

Halts the script the instant something fails. Leave it out and npm ci can blow up while the script cheerfully reloads PM2 anyway — handing you a green checkmark on a deploy that just took production down.

git reset --hard rather than git pull

git pull chokes on merge conflicts, which is exactly what you get when someone edits a file directly on the server. reset --hard forces the working tree to match the remote branch and asks no questions. Your server shouldn't be holding local changes in the first place. If it is, that's the actual bug — not the thing the deploy script needs to negotiate with.

npm ci rather than npm install

ci installs precisely what's pinned in package-lock.json and fails loudly when the lockfile has drifted. install will quietly resolve to versions you never tested against, which is a fun problem to discover in production at 2am.

pm2 reload rather than pm2 restart

restart kills the process, then starts a fresh one — and requests arriving in that gap get refused. reload spins up the replacement before retiring the old process. On a single instance the window shrinks to almost nothing; in cluster mode it really is zero downtime.

Now run it yourself, once:

~/deploy.sh
Enter fullscreen mode Exit fullscreen mode

Get a clean run here before you automate anything.

Step 5: Write the Workflow

Back on your local machine, inside the repo:

mkdir -p .github/workflows
nano .github/workflows/deploy.yml
Enter fullscreen mode Exit fullscreen mode

The whole thing:

name: Deploy Backend

on:
  push:
    branches: [main]
  workflow_dispatch:

concurrency:
  group: production-deploy
  cancel-in-progress: false

jobs:
  deploy:
    name: Deploy to VPS
    runs-on: ubuntu-latest

    steps:
      - name: Deploy over SSH
        uses: appleboy/ssh-action@v1.2.0
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          port: ${{ secrets.VPS_PORT }}
          script_stop: true
          script: |
            export NVM_DIR="$HOME/.nvm"
            [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
            bash ~/deploy.sh
Enter fullscreen mode Exit fullscreen mode

Commit it and push.

Two lines in there deserve a closer look.

workflow_dispatch puts a "Run workflow" button in your Actions tab. The first time you need to redeploy without producing a new commit — and that day comes — you'll be glad it's sitting there.

concurrency keeps two deploys from stepping on each other. Merge two pull requests thirty seconds apart without it and you've got two git reset --hard calls racing in the same directory. Setting cancel-in-progress: false tells the second run to queue up and wait rather than get discarded.

Step 6: Merge Something and Watch

Push a change into main, then open the Actions tab.

You'll watch the job spin up, connect over SSH, and stream every line of deploy.sh straight into the log. Same output you'd have seen in your terminal, minus the terminal.

Green? You're finished. Go delete that text file of deploy commands you've been keeping.

The Error Nearly Everyone Hits First

bash: line 1: pm2: command not found
Enter fullscreen mode Exit fullscreen mode

This one catches almost everybody, and it isn't a mistake on your part.

When you SSH in as a person, bash reads ~/.bashrc and ~/.profile — and if you installed Node through nvm, that's where pm2 gets added to your PATH. When GitHub Actions runs a command over SSH, it opens a non-interactive shell. Those files never get sourced. Your PATH shows up nearly empty, and as far as that shell is concerned, pm2 doesn't exist.

The two NVM_DIR lines in the workflow above solve it by loading nvm by hand before the script runs.

Installed Node from NodeSource instead of nvm, like in my VPS post? Then there's no nvm to load, those lines are harmless no-ops, and pm2 should already be sitting at /usr/bin/pm2. Still not found? Track it down:

which pm2
Enter fullscreen mode Exit fullscreen mode

And use the full path inside deploy.sh:

/usr/bin/pm2 reload "$APP_NAME" --update-env
Enter fullscreen mode Exit fullscreen mode

Four Other Things That Will Trip You Up

Host key verification failed. Your server has never spoken to the Git remote before and doesn't trust its fingerprint. SSH in manually, run git fetch once, accept the fingerprint, and it never asks again.

A private repo the server can't clone. The server needs read access of its own. Generate a key on the server, then add the public half under Repository Settings → Deploy keys. Leave write access switched off — the server only ever needs to read.

Your .env isn't on the server. Good. It's gitignored and it should stay gitignored. The file belongs on the server and nowhere else, and nothing in this pipeline goes near it. That's the design working, not a gap in it.

npm ci failing with a lockfile error. Your package-lock.json has fallen out of sync. Run npm install locally, commit the refreshed lockfile, push again.

Three Upgrades Worth Fifteen Minutes

Once the basic pipeline is green, these are the additions that pay for themselves fastest.

Run your tests first

Add a job the deploy depends on:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm test

  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      # ... the SSH step from before
Enter fullscreen mode Exit fullscreen mode

needs: test means a red test suite blocks the deploy outright. No override, no judgment call at midnight.

Add a health check

A deploy that completed isn't the same thing as an app that's serving traffic. Tack this onto the end of deploy.sh:

sleep 5
curl -fsS https://your-domain.com/health || {
  echo "Health check failed"
  exit 1
}
Enter fullscreen mode Exit fullscreen mode

Now a broken release surfaces as a red X in GitHub within seconds, instead of as a support ticket six hours later.

Lock down the branch that triggers all this

Think about what you've just built: anyone who can push to main can push straight to production. That's a lot of authority to leave sitting out in the open. I wrote a separate piece on restricting pushes and routing everything through a staging workflow — worth reading before you forget this part.

What You Actually Gained

Manual deployment isn't risky because the commands are hard. It's risky because it runs on your memory — every step, in the right order, every single time, including at 11pm on a Friday when you just want to close the laptop.

A pipeline takes that memory and writes it down where it can't forget.

Here's what's now in place:

  • A dedicated key that can deploy and do nothing else
  • A script on the server you can still run by hand when you want to
  • A workflow that fires on merge, refuses to run twice at once, and streams its logs somewhere you can actually read them
  • A reload instead of a restart, so nobody hits a refused connection mid-deploy

Setup runs about twenty minutes. You'll make that back before the week is out — and the fourth or fifth time you merge a fix and simply close the tab, it stops feeling like automation and starts feeling like the way it should have worked all along.

Top comments (0)