Skip to content

CI/CD Pipelines for Node.js

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

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


next -> deploy_04_reverse_proxy.md