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_requestinstead and manually approve first-time contributors
Prerequisites¶
- deploy_02_pm2.md - process management before automating deployment
next -> deploy_04_reverse_proxy.md