CI/CD Pipelines for Node.js¶
Table of Contents¶
- GitHub Actions for Node.js CI
- Testing with Coverage Thresholds
- Building Artifacts
- Deploy Strategies
- Docker Build and Push in CI
- CI/CD Security
- Prerequisites
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_requestinstead and manually approve first-time contributors
Prerequisites¶
- deploy_02_pm2 - process management before automating deployment
next -> deploy_04_reverse_proxy