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
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 ""
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
No ssh-copy-id on your machine? Do it the long way. Print the key:
cat ~/.ssh/deploy_key.pub
Then on the server:
nano ~/.ssh/authorized_keys
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
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
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
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"
Make it executable:
chmod +x ~/deploy.sh
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
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
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
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
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
And use the full path inside deploy.sh:
/usr/bin/pm2 reload "$APP_NAME" --update-env
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
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
}
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)