How to Build a CI/CD Pipeline With GitHub Actions for Node.js
Shipping code manually is a tax on your team's attention. Every time a developer SSHs into a server, runs git pull, and restarts a process, they are introducing human error into something a machine should own entirely. For SaaS teams — especially those operating on lean budgets with affordable cloud tiers — a well-structured CI/CD pipeline is not a luxury. It is the baseline.
This guide builds a production-grade pipeline using GitHub Actions for a Node.js application. Not a toy deployment. A real one: with environment secrets, staged rollouts, health checks, and a rollback path when things go wrong.
What "Production-Grade" Actually Means
Before writing a single YAML line, align on what the pipeline must do:
- Run tests automatically on every pull request before any merge
- Separate staging and production environments with different secrets
- Deploy only after tests pass on the correct branch
- Perform a health check post-deploy to confirm the service is live
- Support rollback to the previous release without manual intervention
Most tutorials cover the first point and stop. The rest of this guide covers all five.
Repository and Branch Strategy
Use a two-branch model:
main→ deploys to productiondevelop→ deploys to staging
Pull requests target develop. After QA on staging, develop merges into main via a reviewed PR. This gives you a controlled promotion path without needing a complex GitFlow setup.
Structuring the Workflow File
Create .github/workflows/deploy.yml. The workflow has three jobs that run in sequence: test, deploy-staging, and deploy-production.
name: Node.js CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [develop]
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-staging:
needs: test
if: github.ref == 'refs/heads/develop'
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- name: Deploy to Staging
env:
HOST: ${{ secrets.STAGING_HOST }}
SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
APP_ENV: ${{ secrets.STAGING_APP_ENV }}
run: |
echo "$SSH_KEY" > /tmp/deploy_key && chmod 600 /tmp/deploy_key
ssh -i /tmp/deploy_key -o StrictHostKeyChecking=no $HOST \
"cd /srv/app-staging && git pull origin develop && npm ci --omit=dev && pm2 reload app-staging --update-env"
- name: Health Check – Staging
run: |
sleep 10
curl --fail https://staging.yourapp.com/health || exit 1
deploy-production:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Deploy to Production
env:
HOST: ${{ secrets.PROD_HOST }}
SSH_KEY: ${{ secrets.PROD_SSH_KEY }}
run: |
echo "$SSH_KEY" > /tmp/deploy_key && chmod 600 /tmp/deploy_key
ssh -i /tmp/deploy_key -o StrictHostKeyChecking=no $HOST \
"cd /srv/app-prod && git pull origin main && npm ci --omit=dev && pm2 reload app-prod --update-env"
- name: Health Check – Production
run: |
sleep 15
curl --fail https://yourapp.com/health || exit 1
A few things worth noting here:
npm ciis used instead ofnpm install— it respectspackage-lock.jsonexactly, making builds reproducible.--omit=devstrips devDependencies on the server, keeping the runtime footprint small.pm2 reloadperforms a zero-downtime restart by cycling workers one at a time.- The
environmentkey in each job enables GitHub's Environment Protection Rules, where you can require manual approvals before production deploys trigger.
Managing Secrets Properly
Never hardcode credentials. GitHub Actions supports environment-scoped secrets, which means PROD_SSH_KEY in your production environment is completely isolated from the staging environment.
Go to Settings → Environments in your repository. Create staging and production environments. Add the relevant secrets to each. For the production environment, enable Required reviewers — this forces a human approval gate before the deploy job runs, even after tests pass.
For applications with .env files, store the entire env file contents as a single secret (APP_ENV), then write it to disk during the deploy step:
echo "$APP_ENV" > /srv/app-staging/.env
This is simpler than managing individual secrets per variable, especially as your config grows.
The Rollback Strategy
No pipeline is complete without a rollback path. The fastest approach at the server level is to keep the last two releases on disk and symlink the active one.
Structure your server directory like this:
/srv/app-prod/
releases/
20240601-1430/
20240608-0910/ ← current
current → releases/20240608-0910
Your deploy step clones into a timestamped directory, installs dependencies, then atomically updates the symlink. PM2 points to /srv/app-prod/current. Rolling back means updating the symlink and reloading PM2 — two commands, under five seconds.
You can add a manual rollback workflow triggered via workflow_dispatch that takes a release directory name as input and performs exactly those two steps.
Health Checks as a Safety Net
The curl --fail health check after each deploy is your last line of defence. If the app fails to start — a missing env variable, a port conflict, a broken migration — the job exits with a non-zero code, GitHub marks the deployment as failed, and your team gets notified immediately.
Keep your /health endpoint lightweight: check that the server is responding, optionally ping the database, and return a 200. Avoid making it so thorough that it becomes slow or flaky.
Keeping Costs Low on African Cloud Tiers
GitHub Actions gives every public repository unlimited free minutes, and private repositories 2,000 free minutes per month on the Free plan. For most small SaaS teams, that is sufficient. To stay within limits:
- Cache
node_moduleswithactions/cacheor use the built-incacheoption insetup-node - Keep test suites fast — parallelise with
--shardif using Jest - Use self-hosted runners on your existing VPS for heavier workloads at zero Actions-minute cost
Providers like Hetzner, DigitalOcean, and local options like Rack Centre offer affordable VPS tiers that pair well with this SSH-based deployment model — no proprietary deployment platform required.
Why This Matters for Your Project
A pipeline like this compresses your release cycle from hours to minutes, eliminates deploy-day anxiety, and gives every team member — not just the lead engineer — the confidence to merge and ship. For SaaS founders building on constrained budgets, it also removes the need for expensive deployment platforms: a VPS, a GitHub account, and this workflow are all you need to ship software with the same rigour as a well-funded engineering team.





