Neural Tech Daily
dev-tutorials

GitHub Actions CI/CD: end-to-end pipeline for a Node.js app

Build a complete CI/CD pipeline with GitHub Actions for a Node.js app: lint, test, matrix builds, secrets, reusable workflows, and deploy.

Updated ~14 min read
Share

The bottom line

This tutorial walks through a complete GitHub Actions CI/CD pipeline for a Node.js app: install dependencies, run the linter, execute the test suite across a Node.js version matrix, build the production bundle, and deploy on push to main. The pipeline uses pinned action versions (actions/checkout@v6 and actions/setup-node@v6 are current as of May 2026) 1 , native repository secrets handled via the secrets: block, and a reusable workflow extracted so the build steps stay in one file.

Node.js 24 is the Active LTS line as of May 2026, with Node.js 22 in Maintenance LTS through 30 April 2027 per the Node.js release working group’s published schedule 2 . The matrix in this tutorial covers both, which is the right default for a library or app that wants to support the current LTS plus one back. Every YAML snippet below has been cross-checked against the official GitHub Actions docs cited in the Sources block.

actions/checkout GitHub repository banner, the canonical Action used in every workflow in this tutorial to check out the source code onto the runner.

Image: actions/checkout, the canonical action for checking out repository code into the runner. The v6 release line is current as of May 2026. The workflow syntax referenced throughout the tutorial is documented at docs.github.com/en/actions.

What you’ll need

  • A GitHub repository containing a Node.js app with package.json, an npm test script, and an npm run lint script.
  • Local Node.js 22 or 24 for running the same commands the workflow runs.
  • Write access to the repository (Actions are enabled by default on new repositories).
  • A target environment for the deploy job. The example uses a generic shell-based deploy step you can swap for Vercel, Cloudflare Pages, S3, or any other target.

A note on cost. GitHub Actions includes free-tier minutes for public repositories (unlimited) and private repositories on free plans (2,000 minutes per month at writer-time). The pipeline below uses about 4-6 runner-minutes per push, so a team pushing 20 times a day stays well inside the free allowance.

Step 1: workflow directory and file naming

GitHub Actions workflows live in .github/workflows/ at the repository root. Per the quickstart docs, “files must use .yml or .yaml file extensions” and “must be placed in the .github/workflows directory for GitHub to discover them” 3 .

mkdir -p .github/workflows
touch .github/workflows/ci.yml
touch .github/workflows/deploy.yml
touch .github/workflows/build-and-test.yml

Three files, by separation of concern:

  • ci.yml runs on every pull request and every push that is not to main. It installs, lints, tests, builds.
  • build-and-test.yml is a reusable workflow holding the actual install/lint/test/build steps. The other two workflows call into it via workflow_call.
  • deploy.yml runs on push to main only. It calls the reusable workflow first to gate the deploy, then runs the deploy step itself.

This shape mirrors what the GitHub docs call the “reusable workflow” pattern: extract repeated job sequences into their own file so the caller workflows stay small.

Step 2: the reusable build-and-test workflow

build-and-test.yml defines the actual work. It is triggered only by workflow_call, which marks it as a reusable workflow per the GitHub docs: “For a workflow to be reusable, the values for on must include workflow_call 4 .

# .github/workflows/build-and-test.yml
name: Build and test (reusable)

on:
  workflow_call:
    inputs:
      node-versions:
        description: 'JSON array of Node.js versions to test against'
        required: false
        type: string
        default: '["22", "24"]'

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        node-version: ${{ fromJSON(inputs.node-versions) }}

    steps:
      - name: Check out repository
        uses: actions/checkout@v6

      - name: Set up Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v6
        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

      - name: Build
        run: npm run build

Five points worth pausing on.

runs-on: ubuntu-latest. This is the canonical syntax in current GitHub docs 3 . The ubuntu-latest label tracks GitHub’s current default Ubuntu image; for production pipelines that need reproducibility, pin to a versioned image like ubuntu-24.04 instead, accepting the maintenance cost of bumping the pin when GitHub deprecates the image.

strategy.matrix.node-version driven by an input. The fromJSON(inputs.node-versions) expression parses the string input into an actual array. Per the matrix docs, this is the canonical pattern for matrices whose values are not known at workflow-author time; a caller workflow can pass '["20", "22", "24"]' for a wider sweep.

fail-fast: false. Default matrix behaviour cancels every in-progress job the moment one fails. Setting this to false lets every Node version finish so you see whether the failure is version-specific or universal.

npm ci rather than npm install. npm ci requires a package-lock.json and installs exactly what the lockfile says, with no resolution. It’s faster and reproducible; npm install mutates the lockfile and is wrong for CI.

cache: 'npm' on setup-node. The action caches the npm download tree keyed on package-lock.json hash, which cuts the install step from 30-60 seconds to 5-10 seconds on cache hits.

actions/setup-node GitHub repository banner, the canonical Action used to install a specific Node.js version on the runner with built-in npm caching.

Image: actions/setup-node, the canonical action for installing a specific Node.js version on the runner with built-in npm/yarn/pnpm caching.

Step 3: the CI caller workflow

ci.yml is the workflow that runs on every pull request. It does almost nothing itself; it just calls the reusable workflow with the default matrix.

# .github/workflows/ci.yml
name: CI

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

jobs:
  build-and-test:
    uses: ./.github/workflows/build-and-test.yml

The uses: ./.github/workflows/build-and-test.yml line is the reusable-workflow call syntax for a workflow in the same repository. For a workflow in a different repository, the GitHub docs specify the syntax as uses: {owner}/{repo}/.github/workflows/{filename}@{ref}, where “{ref} can be a SHA, a release tag, or a branch name” 4 .

Note push: branches-ignore: [main]. Without it, CI would run twice on a merge to main: once for the push to main, and again for the deploy workflow that also includes the reusable build step. The branch-ignore filter splits the responsibility cleanly: pull-request and feature-branch pushes go through ci.yml; main goes through deploy.yml.

Step 4: secrets and the deploy workflow

deploy.yml runs on push to main. It calls the reusable build-and-test workflow first to gate the deploy on a green build, then runs the actual deploy step with access to a secret.

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  build-and-test:
    uses: ./.github/workflows/build-and-test.yml
    with:
      node-versions: '["24"]'

  deploy:
    needs: build-and-test
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Check out repository
        uses: actions/checkout@v6

      - name: Set up Node.js
        uses: actions/setup-node@v6
        with:
          node-version: '24'
          cache: 'npm'

      - name: Install and build
        run: |
          npm ci
          npm run build

      - name: Deploy
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
          DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
        run: ./scripts/deploy.sh

A handful of details worth surfacing.

needs: build-and-test. This makes deploy a dependent job that only starts if the reusable build-and-test workflow finished successfully. A red test blocks the deploy.

environment: production. GitHub Environments are the gate for secrets and approvals. Per the secrets docs, “a workflow job cannot access environment secrets until approval is granted by required approvers” 5 . Set the production environment up in repo Settings to require manual approval before any deploy lands.

Single Node version for deploy. No matrix here; the deploy job builds once on the version that goes to production. The reusable build-and-test workflow above is called with node-versions: '["24"]' rather than the default matrix, since this is the gating run, not the cross-version compatibility check.

Secrets injection via env. Secrets are not available implicitly in run steps; you must bind them into the step’s environment via the env block as shown. The GitHub secrets reference explains the encryption model: “Secrets use Libsodium sealed boxes, so that they are encrypted before reaching GitHub. Once the secret is uploaded, GitHub is then able to decrypt it so that it can be injected into the workflow runtime” 5 .

Never echo a secret. GitHub redacts known secrets from log output, but only the exact string. A base64-encoded version, a substring, or a transformation slips through the redactor. The safe pattern: pass the secret as an environment variable directly to the tool that consumes it.

To add the secret, go to repository Settings → Secrets and variables → Actions → New repository secret. For environment-scoped secrets (which gate on approvals), use Settings → Environments → production → Add secret.

nodejs/node GitHub repository banner, the canonical Node.js source repository whose LTS release lines this tutorial's matrix targets.

Image: nodejs/node, the canonical Node.js source repository. The release schedule referenced in this tutorial is maintained by the Node.js Release Working Group.

Step 5: a working deploy script

The deploy step above shells out to ./scripts/deploy.sh, which is intentional separation: workflow YAML stays small and version-control-friendly, and the actual deploy logic lives in a script you can run locally for debugging.

A minimal deploy script that uploads a built dist/ directory to a remote host over SSH:

#!/usr/bin/env bash
# scripts/deploy.sh
set -euo pipefail

if [ -z "${DEPLOY_TOKEN:-}" ] \mid \mid  [ -z "${DEPLOY_HOST:-}" ]; then
  echo "DEPLOY_TOKEN and DEPLOY_HOST must be set" >&2
  exit 1
fi

echo "Deploying to $DEPLOY_HOST..."

# Example: rsync the build output. Replace with your target's actual
# deploy mechanism (Vercel CLI, wrangler, aws s3 sync, etc.).
echo "$DEPLOY_TOKEN" | ssh-add -
rsync -avz --delete dist/ "deploy@${DEPLOY_HOST}:/var/www/app/"

echo "Deploy complete."

For real targets, swap the rsync for the platform’s deploy command: vercel deploy --prod, npx wrangler pages deploy ./dist, aws s3 sync ./dist s3://your-bucket/ --delete. Each platform also ships a first-party GitHub Action you can use instead of a shell script, which often handles atomic deploys and cache invalidation better than a hand-rolled rsync.

Step 6: the application code

Two minimum supporting files for the workflows above. package.json defines the scripts the workflow runs:

{
  "name": "my-node-app",
  "version": "1.0.0",
  "scripts": {
    "lint": "eslint .",
    "test": "node --test",
    "build": "node build.js"
  },
  "devDependencies": {
    "eslint": "^9.0.0"
  }
}

A trivial passing test, using Node’s built-in test runner (no Jest, no Vitest, no extra dependencies):

// test/app.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { add } from '../src/app.js';

test('add returns the sum of its arguments', () => {
  assert.equal(add(2, 3), 5);
});

And the unit being tested:

// src/app.js
export function add(a, b) {
  return a + b;
}

With this in place, npm test, npm run lint, and npm run build (or whatever build step the app uses) all run locally with the same commands the workflow uses. That symmetry is the single biggest debug-time saver in CI work: anything that fails in the workflow should fail the same way locally.

Step 7: triggering and observing the pipeline

Push to a feature branch and open a pull request. The CI workflow runs the matrix across Node 22 and Node 24; both jobs appear under the PR’s Checks tab. Per the matrix docs, you can drill into any one job to see the per-step logs.

Merge to main. The deploy workflow runs: the reusable build-and-test job runs first on Node 24 only, then (if green) the deploy job runs after manual approval through the production environment gate.

A common confusion at this point: where does the workflow YAML get sourced from when a workflow_call is invoked? The answer is the SHA of the calling workflow’s commit. So if you push a change to build-and-test.yml and ci.yml in the same commit, the new build-and-test runs immediately. There is no cache-bust step needed.

GitHub Marketplace for Actions, the discovery surface for community and first-party actions referenced in this tutorial's Where to go next section.

Image: GitHub Marketplace for Actions, the discovery surface for community and first-party Actions that extend the patterns shown in this tutorial.

Common pitfalls

Five seams that catch first-time GitHub Actions work for Node.js apps. All are surfaced in the official docs but easy to miss on the first read.

Pinned action versions vs floating tags. actions/checkout@v6 is a floating major-version tag. GitHub maintainers move it forward as patch releases land within v6. For supply-chain security, pin to a specific SHA: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 (or any frozen commit). Floating tags are the right default for most repos; SHA pinning is the right default for repos with sensitive deploy paths.

Lockfile drift between local and CI. npm ci fails if package.json and package-lock.json are inconsistent. If your CI is failing with “npm ERR! npm ci can only install packages when your package.json and package-lock.json are in sync”, run npm install locally to update the lockfile and commit the result.

Cache-key invalidation on setup-node. The npm cache built into setup-node keys on the OS, the Node version, and the contents of package-lock.json. A change to the lockfile invalidates the cache; the next run is a cold install. This is correct behaviour; if your CI feels slow after a dependency bump, the cache rebuild is the most common cause.

Environment secrets do not flow to pull requests from forks. A fork PR runs in an untrusted-runner context. Secrets are deliberately not available, which means a deploy-style workflow triggered from a fork PR cannot read the deploy token. The standard pattern: split the workflow so the build-and-test reusable runs on pull_request (no secrets, safe for forks), and the deploy runs only on push to a protected branch (secrets available, no untrusted code).

Concurrency: cancel-in-progress vs the deploy queue. For CI workflows on pull requests, add a concurrency block at the top of ci.yml to cancel superseded runs:

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

For deploys, you usually want the opposite: queue them in order, never cancel, so a fast-merge sequence doesn’t lose a deploy mid-flight. The deploy workflow gets a concurrency group with cancel-in-progress: false.

Where to go next

Three concrete extensions once the basic pipeline is green and shipping.

The first is to add a release workflow. The standard pattern is a separate release.yml triggered on a Git tag push (on: push: tags: ['v*']) that builds the artifact, signs it, and publishes to npm or a binary release page. The actions/create-release (or its current maintained successors on the GitHub Marketplace) handles the GitHub Releases side.

A second path is to add status checks and required-branch protections. In repo Settings → Branches → branch protection rule for main, mark the CI workflow as a required status check. With that on, no PR merges to main unless CI passes, which is the protective layer that makes the whole pipeline meaningful.

The third is to extract the workflow into a published reusable workflow in its own repository. The org-level pattern: one your-org/actions repo holds reusable workflows for every project; every project’s ci.yml calls them with uses: your-org/actions/.github/workflows/node-ci.yml@v1. Per the docs, that’s the canonical form for a workflow in a different repository 4 . Pinned to @v1 and bumped deliberately, this is the right shape for enforcing a single CI standard across many repos.

How this article was made: an autonomous AI pipeline researched, drafted, fact-checked, and reviewed this piece, aggregating publicly-available information from the sources consulted below. AI (artificial intelligence) can make mistakes, so please cross-check the consulted sources before acting on anything here. Neural Tech Daily is not liable for decisions or outcomes based on this article.

Sources consulted

Cited Sources

  1. 1. actions/checkout GitHub repository — v6 is the current release line as of May 2026. v5 and v6 are both actively maintained per the repo's README. (accessed )
  2. 2. Node.js Release Working Group — Node.js 24 in Active LTS, Node.js 22 in Maintenance LTS through 30 April 2027. (accessed )
  3. 3. GitHub Actions quickstart — canonical minimum-example workflow and `runs-on: ubuntu-latest` syntax. Quote: "Files must use `.yml` or `.yaml` file extensions" and "must be placed in the `.github/workflows` directory for GitHub to discover them". (accessed )
  4. 4. GitHub Docs — reusable workflows reference. Quote: "For a workflow to be reusable, the values for `on` must include `workflow_call`". Cross-repo call syntax: `uses: {owner}/{repo}/.github/workflows/{filename}@{ref}` where `{ref}` "can be a SHA, a release tag, or a branch name". (accessed )
  5. 5. GitHub Docs — using secrets in GitHub Actions. Quotes: "Secrets use Libsodium sealed boxes, so that they are encrypted before reaching GitHub" and "A workflow job cannot access environment secrets until approval is granted by required approvers". (accessed )

Further Reading

Anonymous · no cookies set

Report a problem with this article

Articles are produced by an autonomous AI pipeline; mistakes do happen. Tell us what's wrong and the editorial review will revisit the claim.

Category

Found this useful? Share it.