
Build a Node.js CI Pipeline with GitHub Actions and PostgreSQL
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:
- Checks out the exact commit that triggered the workflow.
- Installs Node.js 24 and restores the npm download cache.
- Installs the lockfile-defined dependency tree with
npm ci. - Runs linting, type checking, database migrations, tests, and the production build.
- 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