CI/CD Pipelines for Node.js with GitHub Actions

CI/CD Pipelines for Node.js with GitHub Actions

I've set up CI/CD on Jenkins, CircleCI, Travis, and GitLab. GitHub Actions is the one I actually enjoy using. Here's how to build a pipeline that catches bugs, runs fast, and deploys without babysitting.

I've lost count of how many CI/CD pipelines I've set up over the years. Jenkins with its Groovy DSL that nobody actually understands. CircleCI with config files that somehow always have indentation issues. Travis CI before it became a ghost town. Each one got the job done, but none of them were enjoyable to work with.

GitHub Actions changed that. Not because it's fundamentally different -- it's still YAML files triggering shell commands. But because it lives right next to your code, the marketplace has actions for basically everything, and the matrix strategy feature alone is worth the switch. I went from "CI/CD is something I tolerate" to "CI/CD is something I actually tweak for fun on Friday afternoons."

Here's how to build a real pipeline for a Node.js application -- not a toy "run npm test" example, but a pipeline with linting, multi-version testing, caching, and deployment.

How GitHub Actions Works (The Mental Model)

GitHub Actions runs on the concept of workflows. A workflow is a YAML file in .github/workflows/ that responds to events in your repo -- pushes, pull requests, schedules, manual triggers. Each workflow has jobs that run on fresh VMs (called runners). Each job has steps that run commands or use pre-built actions.

The key insight is this: every job starts from scratch. Fresh VM, no state from previous runs. This is a feature, not a bug -- it means your builds are reproducible. But it also means you need to explicitly cache things (like node_modules) or they get reinstalled every time.

One thing that tripped me up early on: jobs within the same workflow share nothing by default. If your lint job installs dependencies and your test job also needs them, both jobs run npm ci separately. You can share data between jobs using artifacts, but honestly, the caching approach I cover below is simpler for most Node.js projects. Artifacts are better suited for passing build outputs -- like compiled TypeScript or bundled assets -- from one job to the next.

Workflow YAML Syntax and Triggers

Here's a real workflow skeleton:

# .github/workflows/ci.yml
name: Node.js CI Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]
  schedule:
    - cron: '0 6 * * 1'  # Every Monday at 6 AM UTC
  workflow_dispatch:       # Manual trigger button in GitHub UI

env:
  NODE_ENV: test

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm test

The on key defines triggers. You can filter by branches, tags, and file paths -- incredibly useful for monorepos:

on:
  push:
    branches: [main, 'release/**']
    paths:
      - 'src/**'
      - 'package.json'
    tags: ['v*']
  pull_request:
    types: [opened, synchronize, reopened]

That paths filter is something I use constantly. Changed only a README? Don't run the full test suite. Changed something in src/? Run everything.

The schedule trigger deserves special mention. I run scheduled builds every Monday morning on my main projects. Why? Dependencies get updated, upstream APIs change, and security vulnerabilities get discovered. A weekly build that runs against your main branch catches problems before they pile up. I once discovered a breaking change in a transitive dependency three days after it was published -- only because my scheduled pipeline caught it. Without that, it would have bitten me the next time I tried to deploy.

The workflow_dispatch trigger is underrated too. It adds a "Run workflow" button in the GitHub UI, and you can define custom inputs:

on:
  workflow_dispatch:
    inputs:
      environment:
        description: 'Target environment'
        required: true
        type: choice
        options: [staging, production]
      skip_tests:
        description: 'Skip test suite'
        type: boolean
        default: false

This turns your workflow into something closer to a deployment tool. I use this for manual production deploys on projects where I want a human to explicitly click "go."

Jobs, Matrix Strategy, and Dependencies

Jobs run in parallel by default. Use needs to create sequential dependencies:

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm ci
      - run: npm run lint

  test:
    needs: lint
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18, 20, 22]
      fail-fast: false
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm test

  build:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm ci
      - run: npm run build

The matrix strategy is the killer feature. That test job runs three times -- once for each Node version -- in parallel. fail-fast: false means all matrix combinations run to completion even if one fails, so you see all failures at once instead of fixing them one by one.

You can also use include and exclude to fine-tune the matrix. For example, if you want to run an additional integration test suite only on Node 20:

strategy:
  matrix:
    node-version: [18, 20, 22]
    include:
      - node-version: 20
        run-integration: true
    exclude:
      - node-version: 18
        os: windows-latest

Then reference matrix.run-integration in a conditional step. This keeps your pipeline lean -- no need for a separate job just to run integration tests on one version.

Caching: Because npm ci Is Slow

Without caching, every run reinstalls all dependencies from scratch. For a medium-sized project, that's 30-60 seconds of wasted time per run. The simplest approach:

steps:
  - uses: actions/checkout@v4
  - uses: actions/setup-node@v4
    with:
      node-version: '20'
      cache: 'npm'
  - run: npm ci

The cache: 'npm' option automatically caches the npm global cache based on your package-lock.json hash. For more control, use the cache action directly:

- uses: actions/cache@v4
  id: cache-deps
  with:
    path: node_modules
    key: deps-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
    restore-keys: deps-${{ runner.os }}-
- name: Install dependencies
  if: steps.cache-deps.outputs.cache-hit != 'true'
  run: npm ci

This caches the entire node_modules directory and skips the install step when the cache is valid. The restore-keys fallback restores the most recent partial match when no exact match exists.

A word of caution: caching node_modules directly is faster but slightly riskier than caching the npm global cache. If your package-lock.json changes in a way that requires a clean install (like switching from npm to yarn or changing the Node version), a stale node_modules cache can cause weird, hard-to-diagnose failures. I once spent two hours debugging a test failure that only happened in CI -- turned out a cached native addon was compiled for Node 18 but the job had switched to Node 20. Stick with cache: 'npm' on setup-node unless you have a specific reason to cache node_modules directly.

Running Tests, Linting, and Quality Gates

A proper CI pipeline enforces code quality at every stage:

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci

      - name: ESLint
        run: npx eslint . --format=json --output-file=eslint-report.json
        continue-on-error: true

      - name: Prettier check
        run: npx prettier --check "src/**/*.{js,ts,json}"

      - name: TypeScript check
        run: npx tsc --noEmit

      - name: Unit tests with coverage
        run: npm test -- --coverage

      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          files: ./coverage/lcov.info
          token: ${{ secrets.CODECOV_TOKEN }}

continue-on-error: true on ESLint lets subsequent steps run even if linting fails -- useful for collecting all issues in one run.

I also recommend setting a coverage threshold in your test config (Jest, Vitest, or whatever you use) and failing the build if it drops below that threshold. Tracking coverage in CI is meaningless if nobody acts on it. A hard gate -- say 80% line coverage -- forces the conversation early instead of letting coverage erode over months.

For integration tests that need databases, use service containers:

services:
  postgres:
    image: postgres:16
    env:
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: testdb
    ports: ['5432:5432']
    options: >-
      --health-cmd pg_isready
      --health-interval 10s
      --health-timeout 5s
      --health-retries 5
  redis:
    image: redis:7
    ports: ['6379:6379']

Service containers run alongside your job's runner. They're accessible via localhost on the mapped ports. One gotcha: the health check options on the service definition matter. Without them, your tests might start running before Postgres is ready to accept connections, leading to intermittent failures that make you question your sanity.

Deployment Configurations

The deployment job should only run on pushes to main, never on PRs:

deploy:
  needs: [quality]
  runs-on: ubuntu-latest
  if: github.ref == 'refs/heads/main' && github.event_name == 'push'
  environment: production
  steps:
    - uses: actions/checkout@v4
    - name: Deploy to Railway
      run: npx railway up --service my-app
      env:
        RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}

That if condition is critical. Without it, PRs trigger deployments. I've seen this happen. It's not fun.

For AWS, use OIDC instead of static credentials when possible -- it avoids storing long-lived secrets entirely.

Secrets Management

GitHub Actions provides encrypted secrets at repository, environment, and organization levels. They're never exposed in logs and are masked automatically.

Use environments for staged deployments with protection rules:

deploy-production:
  runs-on: ubuntu-latest
  environment:
    name: production
    url: https://myapp.com
  steps:
    - uses: actions/checkout@v4
    - run: ./deploy.sh
      env:
        API_KEY: ${{ secrets.API_KEY }}
        DATABASE_URL: ${{ secrets.DATABASE_URL }}

The production environment can require manual approval from a team lead. This human gate is invaluable for critical applications.

Best practices: never hardcode secrets, use environment-level secrets for different stages, rotate regularly, and use OIDC for cloud providers to avoid long-lived credentials.

One mistake I see often: people store secrets at the repository level and use the same values across staging and production. This defeats the purpose of separate environments. If your staging database URL is the same secret name as production, you're one if-condition typo away from running migrations against prod during a test run. Use environment-scoped secrets so that the staging deployment job literally cannot access production credentials.

Status Badges and Notifications

Add a badge to your README:

![CI](https://github.com/username/repo/actions/workflows/ci.yml/badge.svg)

Set up Slack notifications for failures:

notify:
  needs: [deploy]
  if: failure()
  runs-on: ubuntu-latest
  steps:
    - uses: slackapi/[email protected]
      with:
        payload: |
          {
            "text": "Pipeline failed on `${{ github.ref_name }}` by ${{ github.actor }}"
          }
      env:
        SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

I only send notifications on failure, not on success. A passing pipeline is the normal state -- you don't need a Slack message every time things work. If you start notifying on every success, people stop reading the messages within a week.

Debugging Failed Workflows

When a workflow fails, the first instinct is to re-run it and hope for the best. Sometimes that works (flaky tests, transient network errors), but when it doesn't, you need a systematic approach.

Enable debug logging by setting the ACTIONS_STEP_DEBUG secret to true in your repository. This adds verbose output to every step without changing your workflow files. You can also re-run a failed job with debug logging enabled directly from the GitHub UI.

For failures you can't reproduce locally, the tmate action gives you SSH access to the runner mid-workflow:

- name: Debug via SSH
  if: failure()
  uses: mxschmitt/action-tmate@v3
  with:
    limit-access-to-actor: true

This pauses the workflow and prints an SSH connection string. You get dropped into the exact environment where your build failed, with all the same environment variables and file system state. I've solved more CI mysteries in five minutes of SSH poking around than in hours of staring at log output.

The Full Production Workflow

Here's the complete workflow I use as a starting point for every Node.js project:

name: Production CI/CD

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

jobs:
  lint-and-typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck

  test:
    needs: lint-and-typecheck
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18, 20, 22]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'
      - run: npm ci
      - run: npm test -- --coverage
      - uses: actions/upload-artifact@v4
        if: matrix.node-version == 20
        with:
          name: coverage
          path: coverage/

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci && npm run build
      - run: npx railway up
        env:
          RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}

Four stages: lint, test, build, deploy. Each depends on the previous. Matrix testing across Node versions. Coverage uploaded once. Deployment only on main pushes. This is the pipeline I recommend building first and iterating on from there.

Written by Anurag Kumar

Full-stack developer passionate about Node.js and building fast, scalable web applications. Writing about what I learn every day.

Comments (0)

No comments yet. Be the first to share your thoughts!