Hands-On: CI Pipeline for a Node App
What We Are Building
A two-stage pipeline for an Express API:
- CI job — on every push and pull request: install dependencies, run ESLint, run the test suite
- 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-versionruns 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~/.npmbetween runs — on my projects this cuts install time from about 40 seconds to under 10.npm ciinstead ofnpm install— it installs exactly whatpackage-lock.jsonsays and fails if the lockfile is out of sync.- The
if:condition ondeploymatters. Without it, the job would also attempt to deploy from pull requests, becauseneeds: cialone 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:
| Secret | Value |
|---|---|
SSH_HOST | The server's public IP or hostname |
SSH_USER | The Linux user that owns the app directory, e.g. deploy |
SSH_PRIVATE_KEY | The 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
cijob fails at the Test step - The
deployjob never runs (skipped, becauseneeds: cifailed 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)andci (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.