Shipping code manually is a liability. Every time a developer SSH-es into a server and runs git pull && npm install && pm2 restart app, there is a window for human error, a skipped test, a missing environment variable, or a deployment that quietly breaks production at 2 a.m. A well-built CI/CD pipeline eliminates that window entirely.
This guide walks through a production-grade GitHub Actions workflow for a Node.js application — not a toy example, but the kind of pipeline that handles secrets properly, enforces test gates, and deploys with zero downtime to a VPS or cloud provider.
Why GitHub Actions for Node.js
GitHub Actions is tightly integrated with your repository, requires no separate CI server to manage, and has a generous free tier. For Node.js projects specifically, the ecosystem of community actions — for caching node_modules, running Jest, publishing to npm, or SSHing into a server — is mature and well-maintained. You get speed, flexibility, and one fewer infrastructure concern.
Project Assumptions
- A Node.js app (Express, Fastify, NestJS — any framework) stored in a GitHub repository.
- A remote server (Ubuntu VPS, AWS EC2, DigitalOcean Droplet) running PM2 as the process manager.
- Tests written with Jest or a compatible runner.
- You want the pipeline to run on every push to
mainand on all pull requests.
Step 1 — Structuring the Workflow File
Create .github/workflows/deploy.yml at the root of your repository. The top-level structure defines when the pipeline triggers:
name: Node.js CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
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 -- --coverage --ci
deploy:
needs: test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to VPS
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
cd /var/www/myapp
git pull origin main
npm ci --omit=dev
pm2 reload myapp --update-env
Two critical design decisions here deserve attention.
needs: test creates a hard dependency. The deploy job will not start unless the test job exits with a zero status code. This is your test gate — it is not optional in a production setup.
if: github.ref == 'refs/heads/main' && github.event_name == 'push' ensures that pull requests run tests but never trigger a deploy. PRs from forks or feature branches should never reach production automatically.
Step 2 — Managing Secrets Correctly
Hardcoding credentials in workflow files is a common and dangerous mistake. GitHub provides encrypted repository secrets, accessible at Settings → Secrets and variables → Actions.
For this pipeline you need:
VPS_HOST— the IP address or domain of your server.VPS_USER— the SSH user (e.g.,deployorubuntu).VPS_SSH_KEY— the private SSH key whose public counterpart is in~/.ssh/authorized_keyson the server. Paste the entire private key including headers.
For application-level secrets (database URLs, API keys, JWT secrets), avoid injecting them through the workflow at all. Instead, store them in a .env file on the server itself — outside the repository — and let PM2's ecosystem file or dotenv pick them up at runtime. Your pipeline should never be the transport layer for application secrets.
Step 3 — Zero-Downtime Deploys With PM2 Reload
The difference between pm2 restart and pm2 reload is significant. restart kills the process and starts a new one, causing a brief outage. reload performs a rolling restart — new workers come online before old ones are killed — resulting in zero downtime for clustered apps.
To enable clustering, your PM2 ecosystem file should look like this:
// ecosystem.config.js
module.exports = {
apps: [{
name: 'myapp',
script: './src/index.js',
instances: 'max',
exec_mode: 'cluster',
env_file: '.env'
}]
};
With instances: 'max', PM2 spawns one worker per CPU core. When pm2 reload myapp runs in the pipeline, it cycles through workers without dropping connections.
Step 4 — Caching Dependencies for Speed
The cache: npm option in actions/setup-node caches the npm cache directory between runs. On a warm cache, npm ci on a typical Node.js project drops from 40–90 seconds to under 10. Over hundreds of pipeline runs, this compounds into meaningful time and cost savings.
If your project uses a monorepo or Yarn/pnpm, cache keys need to be scoped more carefully, but the principle remains the same: always cache the package manager's store, keyed to the lockfile hash.
Step 5 — Protecting the Main Branch
A CI/CD pipeline is only as strong as your branch protection rules. In GitHub, navigate to Settings → Branches → Add branch protection rule for main and enable:
- Require status checks to pass before merging — select the
testjob. - Require branches to be up to date before merging — prevents stale PRs from merging.
- Restrict who can push to matching branches — enforces that all changes go through pull requests.
With these rules active, no code reaches main — and therefore production — without passing the test gate.
Common Pitfalls to Avoid
- Deploying on every branch push. Always scope deploys to
main(or a dedicatedreleasebranch) and gate them behind theifcondition shown above. - Using
npm installinstead ofnpm ci.npm ciinstalls from the lockfile exactly, making builds deterministic.npm installcan silently resolve to different versions. - Ignoring exit codes in shell scripts. Multi-command
scriptblocks in the SSH action should useset -eor chain commands with&&so a failedgit pulldoesn't allow a stale deploy to proceed. - Over-privileged deploy users. The SSH user running your deploy should own only the app directory and have no sudo access. Principle of least privilege applies to automation as much as humans.
Why This Matters for Your Project
Whether you are a solo founder shipping a SaaS MVP or a team scaling a microservices platform, a broken manual deploy process will eventually cost you — in downtime, in bugs reaching users, or in the accumulated anxiety of every release. A pipeline like this one turns deployment into a boring, repeatable event rather than a high-stakes ceremony. The upfront investment of a few hours building it correctly pays back within the first week of active development.





