Skip to content
Node.js nd deployment 5 min read

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 (via cache: npm), not node_modules itself. The global cache lets npm ci link 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.

FieldPurpose
on.pull_requestRuns the gate on proposed changes
on.pushTriggers deployment after merge
needsOrders jobs so deploy waits for tests
ifRestricts a job to a specific ref or condition
strategy.matrixFans a job out across Node.js versions
secrets.*Injects encrypted credentials at runtime

Pin actions to a major tag like actions/checkout@v4 for stability, or to a full commit SHA for supply-chain hardening. Floating tags such as @latest can 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 ci everywhere in CI to install reproducibly from the lockfile, never npm install.
  • Enable cache: npm in actions/setup-node to cache ~/.npm and cut cold-start time.
  • Quote matrix version values ("20", "22") so YAML never reinterprets them as numbers.
  • Gate deployment with needs: and an if: 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.
Last updated June 14, 2026
Was this helpful?