Building a GitHub Actions Pipeline for a Node.js App

I walk through building a GitHub Actions pipeline for a Node.js app — triggers, install steps, npm ci, caching node_modules, and environment secrets.

Building a GitHub Actions Pipeline for a Node.js App

You push a fix, it works on your machine, it ships. Two days later someone reports the change broke the API for authenticated users — and the unit tests that would have caught it weren’t wired to run anywhere except locally, on request.

The CI pipeline doesn’t write your tests. It does something narrower but critical: it runs them on every push, without anyone asking it to. By the time a pull request gets reviewed, the checks have already run. If something fails, the reviewer sees it before they merge.

This post builds a complete GitHub Actions workflow for a Node.js project: what triggers it, what each step does, how to cache installs, and how to wire environment secrets for a deploy step. If you’re already comfortable with branching — the kind of workflow covered in A Practical Git Branching Workflow for Small Teams — this is the natural next layer on top.

Quick answer: Create .github/workflows/ci.yml. Set on: [push, pull_request] for the triggers, use actions/checkout@v6 to get the code, actions/setup-node@v7 with cache: 'npm' to set up Node.js, then run npm ci, your test script, your lint script, and your build. Secrets stored in repository settings are referenced with ${{ secrets.SECRET_NAME }}.

On this page

What the finished workflow does

Before drilling into each part, here is the complete workflow file. It lives at .github/workflows/ci.yml in your repository:

name: CI

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

jobs:
  ci:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v6

      - name: Set up Node.js
        uses: actions/setup-node@v7
        with:
          node-version: "20"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

      - name: Run linter
        run: npm run lint

      - name: Build
        run: npm run build

Each part of this file has a reason for being exactly where it is. The sections below explain what each piece does and what happens when something goes wrong.

The trigger: when the workflow runs

The on: block tells GitHub when to start this workflow. The two events here cover the moments that matter most:

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

push runs the pipeline whenever a commit lands on main. pull_request runs it whenever a PR is opened, updated, or synchronised against main. The second one is what makes the pipeline useful during review — the checks appear directly on the PR before anyone merges.

If you want the pipeline to run on every branch, remove the branches filter:

on: [push, pull_request]

Running on all branches catches problems earlier but costs more CI minutes. Most projects run the full suite on PRs targeting main, and sometimes a lighter check on every push to any branch.

One detail worth knowing: if you push directly to main, both the push trigger and any open PRs targeting main each start a separate run. That’s expected — they’re different events and can give you slightly different results if the branch state differs.

The steps: checkout, install, test, lint, build

Each step in the steps: list runs in sequence. A failing step aborts the job, so the pipeline fails fast: if tests fail, lint and build don’t run. That keeps feedback time short.

Checkout is always first. Without it, the runner has no code to work with:

- name: Checkout code
  uses: actions/checkout@v6

Setup Node.js runs immediately after. Pinning node-version means the pipeline doesn’t depend on whatever version happens to be installed on the runner that day:

- name: Set up Node.js
  uses: actions/setup-node@v7
  with:
    node-version: "20"
    cache: "npm"

The cache: 'npm' line is doing something specific here — more on this in the next section.

Install with npm ci, not npm install. This is the one that surprises people setting up CI for the first time:

- name: Install dependencies
  run: npm ci

npm ci deletes node_modules, reads package-lock.json, and installs exactly what the lock file specifies. It fails if the lock file is missing or out of sync with package.json. npm install would update the lock file if it found a mismatch — meaning your CI run might be installing different versions than your local environment. npm ci prevents that entirely.

Test, lint, and build map directly to the scripts in your package.json:

- name: Run tests
  run: npm test

- name: Run linter
  run: npm run lint

- name: Build
  run: npm run build

Order matters. Tests validate logic, lint validates style, and the build validates the compiled output. Running in this order means you get the most actionable failure first — a failing test tells you more than a lint warning about variable naming.

If your project structure has grown beyond a single app.js, the structure described in Structuring a Production-Ready Express Project will have separate service layers that are easier to test independently. That separation pays off here.

Check this before moving on

  • Your package.json has test, lint, and build scripts defined
  • A package-lock.json exists and is committed — npm ci fails without it
  • The workflow file is at .github/workflows/ci.yml — GitHub won’t find it anywhere else

Caching installs to cut run time

Without caching, npm ci downloads every package from the registry on every single run. For a project with 50 dependencies that’s often 30–60 seconds of network time, every run.

The cache: 'npm' input on setup-node tells the action to cache npm’s local package store (the ~/.npm directory on the runner). The next run restores that cache before npm ci executes. Since npm ci reads packages from the local store when available, most installs happen from disk instead of the network.

This is worth understanding clearly: it doesn’t cache node_modules between runs. It caches the underlying package store. npm ci still deletes and rebuilds node_modules from that store on every run — which is what keeps installs reproducible. The speed gain comes from skipping the network fetch, not from skipping the install itself.

The cache key is derived automatically from the hash of package-lock.json. When the lock file changes (you added a dependency), the cache is invalidated and a fresh download runs. When it hasn’t changed, the cached store is used and the install is significantly faster.

If your project uses Yarn, change cache: 'npm' to cache: 'yarn'. For pnpm, use cache: 'pnpm'.

Environment secrets for a deploy step

Once CI passes, you often want to trigger a deploy. Deploy steps need credentials — API keys, SSH keys, server addresses — and hardcoding those in the workflow file would expose them to anyone with read access to the repository.

GitHub provides repository secrets for this. In your repository settings under Settings > Secrets and variables > Actions, you create a secret — for example DEPLOY_API_KEY — and GitHub encrypts it at rest. The value is never visible in the UI after you save it.

In the workflow, you reference it through the secrets context:

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

The secret value is masked in the run logs. If the script prints it accidentally, GitHub replaces it with ***.

For a real production deploy, you’ll want to separate the deploy job and gate it on the CI job passing:

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      # ... checkout, install, test, lint, build

  deploy:
    needs: ci
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Checkout code
        uses: actions/checkout@v6

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

The needs: ci line means the deploy job only starts after the ci job succeeds. The environment: production line ties the job to a named deployment environment, which lets you add protection rules — such as requiring a manual approval before the job runs — directly in repository settings.

Repository secrets and environment secrets are separate things. Repository secrets are available to all workflows. Environment secrets are scoped to a specific named environment and can require a reviewer to approve each deployment.

What breaks and why

Missing package-lock.json — npm ci exits immediately if the lock file isn’t in the repository. A lot of projects have package-lock.json in .gitignore because someone added it early and never removed it. Check your ignore file and commit the lock file.

Wrong Node.js version — If your code uses a feature from Node 20 and the workflow pins node-version: '18', the build fails in a way that doesn’t happen locally. Pin the same major version your production environment uses.

No test script defined — npm test exits with a non-zero code if package.json has no test script. GitHub Actions treats any non-zero exit code as a step failure. A project with no tests yet should either define a script that exits successfully or skip the test step explicitly.

Lint config not committed — Linters pick up different config files depending on where they run. If your configuration extends a local file that isn’t committed to the repository, lint will fail on the runner but pass locally. All config files the linter needs must be tracked in git.

Secrets unavailable on forked PRs — For security reasons, repository secrets are not available to workflows triggered by pull requests from forks. If you accept external contributions, your deploy step needs to either skip gracefully or use a separate workflow triggered after merge.

Failure symptom Likely cause First thing to check
npm ci exits immediately No package-lock.json Check .gitignore, commit the lock file
Tests pass locally, fail in CI Node version mismatch Match node-version to your local version
Lint fails only in CI Config file not committed Check all lint config files are in git
Deploy runs before tests pass No needs on deploy job Add needs: ci to the deploy job

The pipeline only catches what you test

The workflow YAML is the easy part. What determines whether CI is actually useful is the quality of the test suite it runs.

A pipeline that runs npm test and finds zero tests passes on every push, flags no regressions, and gives the team a false sense that things are checked. The green checkmark means “the workflow ran” — not “the code works.”

Think of it this way: the pipeline is the mechanism, and the tests are the policy. The mechanism takes an afternoon to set up. The policy — deciding what to test, writing those tests, maintaining them as the code changes — is the ongoing work.

Start with what you have. Wire up the pipeline now so the infrastructure exists, even if the test suite is thin. Then add tests incrementally, starting with the behaviour that has broken before. If you’re building on an Express API like the one described in Building a REST API with Node.js and Express, route handlers are a practical first target for integration tests.

The pipeline doesn’t make your code reliable. The tests do. The pipeline just makes sure they run.

A pipeline diagram showing five connected stages — checkout, install, test-and-lint, build, and deploy — with a cache cylinder below the install step and a lock icon representing secrets near the deploy stage.

The full pipeline: each push triggers the sequence from checkout through to build, with the cache cutting install time and secrets gating the deploy step.

Sources