CI/CD for Node.js with GitHub Actions
Continuous integration runs your linter, tests, and build on every change so broken code is caught before it merges; continuous deployment ships the merged result automatically so releases become routine instead of risky. GitHub Actions is the most direct way to wire this up for a Node.js project because the runners, the npm cache, and the Git context all live next to your code. This page builds a complete pipeline: caching dependencies, running lint/test/build on pull requests, matrix-testing across Node.js versions, and deploying only when changes land on main.
How a GitHub Actions workflow is structured
A workflow is a YAML file in .github/workflows/. Each file declares the events that trigger it (on), one or more jobs, and the ordered steps inside each job. Jobs run in parallel by default on fresh virtual machines; you chain them with needs so deployment waits for tests to pass. Steps either run a shell command or uses a reusable action like actions/checkout or actions/setup-node.
The two events that matter most are pull_request (validate proposed changes) and push to your default branch (deploy what merged). Scoping each job to the right event keeps you from deploying half-reviewed code.
Caching dependencies
A clean npm ci re-downloads every package, which dominates job time on a cold runner. The actions/setup-node action has built-in caching: point it at your lockfile and it restores ~/.npm (the package manager’s global cache) keyed on that lockfile’s hash. When dependencies are unchanged the cache is a near-instant hit; when package-lock.json changes the key misses and a fresh cache is saved.
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
Cache
~/.npm(viacache: npm), notnode_modulesitself. The global cache letsnpm cilink packages quickly while still resolving exactly what the lockfile pins, so you never ship a stale or platform-mismatched module tree.
Running lint, test, and build on pull requests
The CI job is the gate: it checks out the code, installs from the lockfile, then runs the same scripts you run locally. Use npm ci rather than npm install so the runner reproduces your lockfile exactly and fails fast on drift.
name: CI
on:
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run lint
- run: npm test
- run: npm run build
These steps assume conventional package.json scripts. Define them once and both your machine and the runner stay in sync:
{
"scripts": {
"lint": "eslint .",
"test": "node --test",
"build": "tsc -p tsconfig.json"
}
}
Node.js 20 and 22 ship a built-in test runner, so node --test needs no extra dependency. A failing lint rule, test, or type error exits non-zero, which marks the step red and blocks the merge.
Output:
> npm test
✔ creates a user (3.114ms)
✔ rejects a duplicate email (1.882ms)
ℹ tests 2
ℹ pass 2
ℹ fail 0
Matrix testing across Node.js versions
If your package must support more than one Node.js release, a build matrix runs the whole job once per version in parallel. Define the axis under strategy.matrix and reference it with ${{ matrix.node }}. Each combination becomes its own check on the pull request, so a regression on Node.js 20 is visible even while 22 stays green.
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: ["20", "22"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci
- run: npm test
Note the version values are quoted strings: "20" not 20. YAML would otherwise read 20 as a number and 20.10 could collapse to 20.1, silently testing the wrong release. Set fail-fast: false so one failing version does not cancel the others — you usually want the full picture.
Deploying on merge to main
Deployment belongs in a separate job triggered by push to main, gated behind the test job with needs. The if condition double-checks the ref so the deploy never fires on a branch push. Secrets live in the repository’s encrypted settings and are injected as environment variables — never commit them.
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node: ["20", "22"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci
- run: npm run lint
- run: npm test
- run: npm run build
deploy:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci --omit=dev
- run: npm run build
- name: Deploy
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: npm run deploy
Because deploy declares needs: test, it starts only after every matrix combination passes. The environment: production line ties the job to a GitHub environment, where you can require manual approval or restrict which branches may deploy.
| Field | Purpose |
|---|---|
on.pull_request | Runs the gate on proposed changes |
on.push | Triggers deployment after merge |
needs | Orders jobs so deploy waits for tests |
if | Restricts a job to a specific ref or condition |
strategy.matrix | Fans a job out across Node.js versions |
secrets.* | Injects encrypted credentials at runtime |
Pin actions to a major tag like
actions/checkout@v4for stability, or to a full commit SHA for supply-chain hardening. Floating tags such as@latestcan change behavior under you between runs.
Best Practices
- Run lint, test, and build as separate steps so a failure points at the exact stage.
- Use
npm cieverywhere in CI to install reproducibly from the lockfile, nevernpm install. - Enable
cache: npminactions/setup-nodeto cache~/.npmand cut cold-start time. - Quote matrix version values (
"20","22") so YAML never reinterprets them as numbers. - Gate deployment with
needs:and anif:ref check so only merged, tested code ships. - Store credentials in encrypted repository secrets and surface them through a protected
environment. - Pin actions by major tag or commit SHA to keep builds deterministic and resistant to tampering.