Skip to content

CI/CD Pipelines for Node.js

Table of Contents


Manual deploys are how production goes down on Friday at 5PM
You SSH in , pull the latest code , npm install , restart the process , cross your fingers - and when something breaks you're debugging on a live server with angry users in your DMs Automate the whole pipeline or accept that you're the deployment procedure

GitHub Actions for Node.js CI

flowchart LR
    subgraph Trigger["Trigger"]
        Push["git push"]
        PR["Pull Request"]
    end

    subgraph CI["CI Pipeline"]
        Lint["Lint
        eslint + prettier"]
        Test["Test
        jest + coverage"]
        Build["Build
        tsc + webpack"]
        Audit["Security
        npm audit"]
    end

    subgraph CD["CD Pipeline"]
        Docker["Docker Build
        & Push to Registry"]
        Staging["Deploy to
        Staging"]
        Prod["Deploy to
        Production"]
    end

    Push --> CI
    PR --> CI
    Lint --> Test --> Build --> Audit
    Audit -->|"main branch"| CD
    Audit -->|"other branches"| Done["Build Complete"]
    Docker --> Staging --> Prod

    style Push fill:#3498db,color:#fff
    style PR fill:#3498db,color:#fff
    style Lint fill:#f1c40f,color:#000
    style Test fill:#f1c40f,color:#000
    style Build fill:#f1c40f,color:#000
    style Audit fill:#e74c3c,color:#fff
    style Docker fill:#2ecc71,color:#fff
    style Staging fill:#e67e22,color:#fff
    style Prod fill:#2ecc71,color:#fff

The CI pipeline catches what local testing misses - different OS , clean install , no dev dependencies hidden in node_modules

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

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

jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: [18.x, 20.x, 22.x]

    steps:
      - uses: actions/checkout@v4

      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Lint
        run: npm run lint

      - name: Type check
        run: npm run typecheck

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

      - name: Upload coverage
        uses: actions/upload-artifact@v4
        with:
          name: coverage-${{ matrix.node-version }}
          path: coverage/

npm ci is not npm install - npm ci uses package-lock.json exactly , fails if there's a mismatch , and is faster because it skips resolution Use it in CI , use npm install in development

Caching dependencies - shaves minutes off pipeline runs

- name: Cache node_modules
  uses: actions/cache@v3
  with:
    path: ~/.npm
    key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-node-

The cache key uses the lockfile hash - when dependencies change , the cache invalidates and npm ci re-installs from scratch Keeps pipelines fast without serving stale packages

Testing with Coverage Thresholds

Tests that don't enforce coverage are just suggestions

// jest.config.js
module.exports = {
  collectCoverage: true,
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80
    },
    './src/**/*.controller.js': {
      branches: 90,
      functions: 90
    },
    './src/**/*.service.js': {
      branches: 85
    }
  },
  collectCoverageFrom: [
    'src/**/*.js',
    '!src/**/*.test.js',
    '!src/index.js'  // bootstrap code
  ]
}
// package.json script
"test:coverage": "jest --coverage --forceExit --detectOpenHandles"
# CI step - fail if coverage drops below threshold
- name: Run tests with coverage
  run: npm run test:coverage
  env:
    CI: true

Coverage is a floor not a ceiling - 100% coverage doesn't mean bug-free but 20% coverage means you're deploying blind Set thresholds in CI and watch your team argue about what deserves 100%

Building Artifacts

For compiled Node.js apps (TypeScript , JSX , bundle steps) , build in CI and store the artifact

# .github/workflows/build.yml
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'

      - run: npm ci
      - run: npm run build

      - name: Upload build artifact
        uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/
          retention-days: 7

      - name: Upload coverage
        uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage/
          retention-days: 30
# .github/workflows/deploy.yml - use artifact from build job
jobs:
  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Download build artifact
        uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist/

      - name: Deploy to server
        run: |
          rsync -avz --delete dist/ user@server:/app/
          ssh user@server 'pm2 reload myapp'

Separating build and deploy into dependent jobs means you can retry a deploy without rebuilding - saves minutes when the deploy step fails due to a transient SSH issue

Deploy Strategies

Rolling deploy - replace instances one by one , no downtime

pm2 reload myapp-api
# Kills one worker , starts new one , moves to next
# Downside: version skew during rollout

Blue-green deploy - two identical environments , switch traffic atomically

# Blue - current production
# Green - new version

# Deploy to green
rsync -avz dist/ user@server:/app-green/
ssh user@server 'cd /app-green && pm2 start ecosystem.config.js --env production'

# Switch nginx to green
ssh user@server 'ln -sfn /app-green /app/current && pm2 reload myapp-api'

# Blue-green done , blue waits for next deploy
# Benefits: instant rollback (switch symlink back)

# Nginx config for blue-green
upstream myapp {
    server unix:/app/current/app.sock;
}

# Deploy script
ln -sfn /app/new-version /app/current
pm2 reload myapp
# If it fails:
# ln -sfn /app/old-version /app/current
# pm2 reload myapp

Canary deploy - route small traffic percentage to new version

# Docker Swarm - update with gradual roll
docker service update \
  --update-parallelism 1 \
  --update-delay 30s \
  --update-monitor 60s \
  --update-failure-action rollback \
  myapp_service

# Kubernetes - canary via traffic splitting
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    nginx.ingress.kubernetes.io/canary: "true"
    nginx.ingress.kubernetes.io/canary-weight: "10"
spec:
  # routes 10% traffic to canary deployment
  # if error rate spikes , remove canary annotation

Canary is for confidence - if your monitoring catches an error spike , the canary rolls back without affecting 90% of users Only useful if you have proper monitoring (see deploy_05)

Docker Build and Push in CI

# .github/workflows/docker-build.yml
name: Docker Build

on:
  push:
    tags: ['v*']

jobs:
  docker:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=sha

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Tagging with both semver (v1.2.3) and SHA (sha-a1b2c3d) gives you traceability - the SHA tag tells you exactly which commit built this image Works with Docker layer caching (type=gha) to reuse unchanged layers between builds

CI/CD Security

Every CI pipeline is a potential supply chain attack vector
Someone compromises your CI and they own your production secrets

  • GitHub Secrets - never hardcode secrets in workflow files

    # Bad: secret in workflow
    env:
      DATABASE_URL: postgres://prod:supersecret@db:5432/myapp
    
    # Good: secret from GitHub Secrets
    env:
      DATABASE_URL: ${{ secrets.DATABASE_URL }}
    

  • No secrets in build logs - mask secrets in output Add secret patterns to repo settings so Actions redacts them from logs automatically

  • Secret scanning - enable GitHub secret scanning to catch commits with credentials

    # .github/secret_scanning.yml
    name: Secret Scanning
    on: [push]
    jobs:
      scan:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: github/codeql-action/analyze@v3
    

  • Pin versions of third-party actions - supply chain attacks on GitHub Actions happen

    # Bad: unpinned action (can be compromised)
    - uses: actions/setup-node@v4
    
    # Good: pin to specific SHA
    - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af  # v4.0.0
    

  • Principle of least privilege - your CI token doesn't need write access to everything

    # Minimal permissions for CI job
    permissions:
      contents: read
      packages: write  # for ghcr.io push
    

  • Avoid running builds on pull_request_target - it runs in the base repo context with secret access Use pull_request instead and manually approve first-time contributors

Prerequisites

  • deploy_02_pm2 - process management before automating deployment

next -> deploy_04_reverse_proxy