A pull request should answer one question before a human reviews it: does this change still install, lint, test, and build correctly? A small GitHub Actions workflow can answer that question on every push without requiring a separate CI server.

This tutorial creates a practical continuous integration pipeline for a Node.js application. It uses current major releases of GitHub's official checkout and Node setup actions, installs dependencies reproducibly, caches package-manager downloads, runs quality checks, and starts PostgreSQL for integration tests. It also applies least-privilege permissions and includes the failure cases that usually make CI feel unreliable.

What the Pipeline Will Check

The finished workflow runs on pull requests and pushes to main. Each run:

  1. Checks out the exact commit that triggered the workflow.
  2. Installs Node.js 24 and restores the npm download cache.
  3. Installs the lockfile-defined dependency tree with npm ci.
  4. Runs linting, type checking, database migrations, tests, and the production build.
  5. Uses an isolated PostgreSQL service that disappears after the job.

The pipeline does not deploy anything. Keeping CI separate from deployment makes failures easier to diagnose and prevents unreviewed pull requests from reaching production credentials.

Prepare the Node.js Project

CI should call the same commands developers run locally. Define stable scripts in package.json instead of placing tool-specific commands throughout the workflow:

{
  "engines": {
    "node": ">=24"
  },
  "scripts": {
    "lint": "eslint .",
    "typecheck": "tsc --noEmit",
    "test": "vitest run --coverage",
    "build": "tsc -p tsconfig.build.json",
    "db:migrate:test": "prisma migrate deploy"
  }
}

Adapt the commands to your framework. A Nuxt application may use nuxt typecheck and nuxt build; an Express JavaScript project may not need a TypeScript step. The important part is that each command exits with a non-zero status when it finds a problem.

Commit package-lock.json. The lockfile lets npm ci install the exact dependency graph and makes a mismatch between package.json and the lockfile fail immediately instead of silently rewriting it.

Create the GitHub Actions Workflow

Create .github/workflows/ci.yml:

name: Node.js CI

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

permissions: contents: read

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

jobs: test: runs-on: ubuntu-latest timeout-minutes: 15

services:
  postgres:
    image: postgres:18
    env:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: app_test
    ports:
      - 5432:5432
    options: >-
      --health-cmd "pg_isready -U postgres -d app_test"
      --health-interval 10s
      --health-timeout 5s
      --health-retries 5

env:
  NODE_ENV: test
  DATABASE_URL: postgresql://postgres:postgres@localhost:5432/app_test

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

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

  - name: Install dependencies
    run: npm ci

  - name: Lint
    run: npm run lint

  - name: Type-check
    run: npm run typecheck

  - name: Apply test database migrations
    run: npm run db:migrate:test

  - name: Run tests
    run: npm test

  - name: Build production output
    run: npm run build</code></pre>

As of August 2026, the official GitHub Marketplace listings show major version 7 for both actions/checkout and actions/setup-node. The setup action accepts Node.js major versions such as 24. If your project supports another runtime, use the same version locally and in CI.

Understand the Important Settings

Run on pull requests and the protected branch

The pull_request trigger gives reviewers a status check before merging. The push trigger verifies the merged result on main. Configure branch protection or repository rules to require the job before a pull request can merge.

Cancel obsolete runs

When a developer pushes three fixes to the same branch, only the newest result matters. The concurrency group identifies runs for that workflow and ref, while cancel-in-progress stops older work. This shortens feedback and avoids spending runner time on obsolete commits.

Use least-privilege permissions

A test workflow only needs to read repository contents. Setting permissions: contents: read avoids relying on broader repository defaults. Do not add write permissions or production secrets merely to make a failing step pass.

Set a timeout

A hanging test should fail predictably. The job-level timeout caps the complete job; test-runner timeouts should also catch individual tests that never finish.

How npm Caching Works Here

The cache: npm option uses the dependency file to cache npm's package data. It does not treat node_modules as a portable build artifact. The workflow still runs npm ci, but repeated runs can download fewer packages.

For a monorepo or a lockfile outside the repository root, specify the dependency path:

- uses: actions/setup-node@v7
  with:
    node-version: 24
    cache: npm
    cache-dependency-path: apps/api/package-lock.json

  • run: npm ci working-directory: apps/api

Do not cache a directory just because it is large. A good cache has a reliable invalidation key and is cheaper to restore than to regenerate. If caching causes confusing results, temporarily disable it and compare the run.

Run PostgreSQL Integration Tests

GitHub Actions service containers create a fresh dependency for each job. Because the Node.js steps run directly on an Ubuntu runner, the workflow maps PostgreSQL's port and connects through localhost:5432.

The health check matters. Container startup does not mean PostgreSQL is ready to accept connections. GitHub waits for the service to become healthy before starting the job steps, preventing intermittent “connection refused” failures.

The password in this workflow is intentionally a throwaway value for an isolated test database. Do not reuse a production password. Tests should create their own data, avoid depending on execution order, and clean up state between cases.

Prisma migration example

For Prisma, prisma migrate deploy applies committed migrations without creating new ones:

{
  "scripts": {
    "db:migrate:test": "prisma migrate deploy",
    "test": "vitest run --coverage"
  }
}

If tests need seed data, create a separate deterministic test seed command. Never point CI at a shared development or production database.

Add a Node.js Version Matrix When It Provides Value

An application normally tests the one Node.js version it deploys. A reusable library should often test every supported major:

jobs:
  test:
    strategy:
      fail-fast: false
      matrix:
        node-version: [22, 24]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: ${{ matrix.node-version }}
          cache: npm
      - run: npm ci
      - run: npm test

A matrix multiplies runner usage, so add versions only when compatibility is part of your product contract. Keep database integration in one representative job if repeating it provides little extra signal.

Keep Pull Request Workflows Safe

Pull request code is untrusted input. A test can print environment variables, modify files, or make network requests. Use the pull_request event for ordinary CI and do not expose deployment keys to it.

  • Keep the workflow token read-only unless a specific step requires more.
  • Store real credentials in GitHub secrets, but do not pass them to untrusted pull request jobs.
  • Prefer GitHub-maintained actions; review third-party actions and pin sensitive workflows to a full commit SHA.
  • Do not use pull_request_target to execute code from an untrusted pull request with base-repository privileges.
  • Separate CI from release and deployment workflows.

Make the Check Required

After the workflow completes once, open the repository's rules settings and require the test job for main. Then a failing or missing run blocks merging. Use a stable job name; renaming it requires updating the repository rule.

Keep required checks focused. Linting, tests, and builds belong in CI. Optional browser suites or performance tests can run separately if their duration would make every small pull request slow.

Troubleshooting Common Failures

npm ci reports that the lockfile is out of sync

Run npm install locally with the supported npm version, review the lockfile change, and commit it. Do not replace npm ci with npm install in CI to hide the mismatch.

The application cannot connect to PostgreSQL

For a job running directly on the runner, use localhost and map port 5432. Confirm that the database name and credentials match both the service environment and DATABASE_URL. Keep the pg_isready health check.

A script works locally but is missing in CI

Check filename case, committed files, ignored environment files, and undeclared dependencies. Linux filesystems are case-sensitive. A package available globally on a laptop is not automatically present on a clean runner.

The cache never restores

Confirm that the lockfile exists at the path seen by setup-node. In a monorepo, set cache-dependency-path. A changed lockfile should produce a new cache key, which is correct behavior.

Tests pass locally but fail because of dates

Make the timezone explicit, freeze time in tests, and avoid locale-dependent string assertions. CI runners expose assumptions that a developer machine may hide.

Production CI Checklist

  • The Node.js version matches production and local development.
  • package-lock.json is committed and dependencies use npm ci.
  • Lint, type-check, test, migration, and build commands also work locally.
  • The workflow token has only contents: read.
  • Obsolete branch runs are canceled and the job has a timeout.
  • PostgreSQL is isolated, health-checked, and contains no production credentials.
  • Untrusted pull request code cannot access deployment secrets.
  • The main branch requires the CI job before merging.
  • Action versions and Node.js versions are reviewed periodically.

Final Takeaway

A useful CI pipeline is small, reproducible, and difficult to bypass. Start with the commands that already define a healthy build, run them on a clean Node.js environment, add PostgreSQL only where integration tests need it, and make the resulting check a merge requirement. Once this foundation is reliable, deployment can become a separate workflow that trusts the tested commit.

Official Sources