Skip to main content

Hands-On: CI Pipeline for a Node App

What We Are Building

A two-stage pipeline for an Express API:

  1. CI job — on every push and pull request: install dependencies, run ESLint, run the test suite
  2. Deploy job — only on push to main, only after CI passes: SSH into the server and pull the new version

By the end, a merge to main goes live without anyone touching the server.

Step 1 — Prepare the App Scripts

The workflow will call npm scripts, so make sure package.json defines them:

{
"scripts": {
"lint": "eslint .",
"test": "jest --ci",
"start": "node server.js"
}
}

Verify both run locally before wiring up CI — a pipeline can only automate what already works:

npm run lint
npm test

Step 2 — Create the Workflow File

Create .github/workflows/ci-deploy.yml in the repo root:

name: CI and Deploy

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
ci:
runs-on: ubuntu-latest

strategy:
matrix:
node-version: [18, 20]

steps:
- uses: actions/checkout@v4

- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Lint
run: npm run lint

- name: Test
run: npm test
env:
NODE_ENV: test

deploy:
needs: ci
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest

steps:
- name: Deploy over SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /var/www/my-api
git pull origin main
npm ci --omit=dev
pm2 reload my-api

A few decisions worth explaining:

  • matrix.node-version runs the CI job twice, once on Node 18 and once on Node 20, in parallel. This catches version-specific breakage before an upgrade.
  • cache: 'npm' caches ~/.npm between runs — on my projects this cuts install time from about 40 seconds to under 10.
  • npm ci instead of npm install — it installs exactly what package-lock.json says and fails if the lockfile is out of sync.
  • The if: condition on deploy matters. Without it, the job would also attempt to deploy from pull requests, because needs: ci alone only controls ordering, not filtering.

Step 3 — Add the Secrets

The deploy job references three secrets. Add them in the repo under Settings → Secrets and variables → Actions → New repository secret:

SecretValue
SSH_HOSTThe server's public IP or hostname
SSH_USERThe Linux user that owns the app directory, e.g. deploy
SSH_PRIVATE_KEYThe full private key, including the BEGIN and END lines

Generate a dedicated key pair for deployments rather than reusing a personal key:

ssh-keygen -t ed25519 -C "github-actions-deploy" -f deploy_key -N ""
cat deploy_key.pub >> ~/.ssh/authorized_keys # run on the server
cat deploy_key # paste into SSH_PRIVATE_KEY

If the key ever leaks, revoking it affects only CI — not your own access.

Step 4 — Push and Watch It Run

git add .github/workflows/ci-deploy.yml
git commit -m "Add CI and deploy pipeline"
git push origin main

Open the Actions tab on GitHub. You should see the run with ci (18) and ci (20) executing in parallel, then deploy starting once both pass. Click any job to stream its logs live.

Step 5 — Verify the Failure Path

A pipeline you have never seen fail is a pipeline you cannot trust. Open a branch, break a test on purpose, and push a PR:

git checkout -b test-pipeline
# make any test assertion fail
git commit -am "Intentionally break a test"
git push origin test-pipeline

Expected behavior:

  • The PR shows a red cross — the ci job fails at the Test step
  • The deploy job never runs (skipped, because needs: ci failed and the event is a PR anyway)
  • The exact failing assertion is visible in the job log

Revert the change, push again, and the PR check turns green.

Step 6 — Protect the Main Branch

Automation is only enforced if merging requires it. Under Settings → Branches → Add branch protection rule for main:

  • Enable Require status checks to pass before merging
  • Select both ci (18) and ci (20) as required checks

Now a PR with failing lint or tests physically cannot be merged, no matter who authored it.

Common Problems

Deploy job is skipped on every run. Check the if: expression — a typo like refs/head/main (missing s) silently evaluates to false. The job shows as "skipped", not "failed", so it is easy to miss.

npm ci fails only in CI. Usually a lockfile drift: someone ran npm install with a different npm version locally. Run npm ci locally to reproduce, then regenerate the lockfile.

SSH step times out. The runner's IP must be able to reach port 22 on your server. If the server firewall allowlists IPs, either open port 22 to GitHub's runner ranges or switch to a self-hosted runner inside your network.

Secrets show as empty strings. Secrets are not passed to workflows triggered from forks. For public repos, the deploy job's if: guard on push events already avoids this trap.