CI/CD Patterns
Best practices for building reliable, secure, and fast CI/CD pipelines with GitHub Actions.
Pipeline Stages
A well-structured pipeline follows this progression. Each stage gates the next.
lint --> test --> build --> security-scan --> deploy-staging --> deploy-production
| Stage |
Purpose |
Failure Means |
| Lint |
Code style, formatting |
Code doesn't meet standards |
| Test |
Unit + integration tests |
Broken functionality |
| Build |
Compile, bundle |
Code won't package |
| Security Scan |
Dependency + code analysis |
Vulnerabilities detected |
| Deploy Staging |
Pre-production verification |
Environment issue |
| Deploy Production |
Live release |
Requires approval gate |
GitHub Actions: Complete Workflow Templates
Standard CI Workflow
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
# Cancel in-progress runs for the same branch/PR
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
lint:
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 format:check
test:
runs-on: ubuntu-latest
needs: lint
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-report
path: coverage/
retention-days: 7
build:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
retention-days: 7
Deployment Workflow with Approval Gate
name: Deploy
on:
push:
branches: [main]
workflow_dispatch:
inputs:
environment:
description: Target environment
required: true
default: staging
type: choice
options:
- staging
- production
permissions:
contents: read
deployments: write
jobs:
build:
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 build
- uses: actions/upload-artifact@v4
with:
name: deploy-artifact
path: dist/
deploy-staging:
needs: build
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/download-artifact@v4
with:
name: deploy-artifact
path: dist/
- name: Deploy to staging
env:
DEPLOY_TOKEN: ${{ secrets.STAGING_DEPLOY_TOKEN }}
run: |
# Deploy script here -- uses secret, never echo it
echo "Deploying to staging..."
deploy-production:
needs: deploy-staging
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
# CRITICAL: Production requires manual approval via GitHub Environments
environment: production
steps:
- uses: actions/download-artifact@v4
with:
name: deploy-artifact
path: dist/
- name: Deploy to production
env:
DEPLOY_TOKEN: ${{ secrets.PRODUCTION_DEPLOY_TOKEN }}
run: |
echo "Deploying to production..."
Docker Build and Push
name: Docker
on:
push:
tags: ['v*']
permissions:
contents: read
packages: write
jobs:
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository }}:${{ github.ref_name }}
ghcr.io/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
Secret Management
Rules
| Rule |
Rationale |
| Never echo secrets in logs |
CI logs are often accessible to contributors |
| Use GitHub Environment secrets for deploy tokens |
Scoped to specific environments |
| Rotate secrets on schedule |
Reduces blast radius of leaks |
| Use OIDC where possible |
No long-lived credentials |
| Minimal secret scope |
Each secret should access only what it needs |
Masking Secrets
steps:
- name: Use secret safely
env:
# Secret is automatically masked in logs
API_KEY: ${{ secrets.API_KEY }}
run: |
# NEVER do this:
# echo "Key is $API_KEY"
# SAFE: Use secret in commands without printing
curl -s -H "Authorization: Bearer $API_KEY" https://api.example.com/health
- name: Mask dynamic values
run: |
TOKEN=$(generate-token)
echo "::add-mask::$TOKEN"
# Now $TOKEN is masked in all subsequent log output
echo "Token generated successfully"
OIDC for Cloud Providers (No Stored Secrets)
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-deploy
aws-region: us-east-1
# No AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY needed
Caching Strategies
Dependency Caching
# Node.js -- built into setup-node
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
# Python
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
# Go
- uses: actions/setup-go@v5
with:
go-version: '1.22'
cache: true
# Rust
- uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
target/
key: rust-${{ hashFiles('**/Cargo.lock') }}
restore-keys: rust-
Custom Cache
- uses: actions/cache@v4
with:
path: .cache/expensive-operation
key: expensive-${{ hashFiles('src/**') }}
restore-keys: |
expensive-
Cache Sizing
| What to Cache |
Impact |
Size Concern |
node_modules (via npm ci) |
HIGH |
Use setup-node cache instead |
| Build output |
MEDIUM |
Only if build is slow (>2 min) |
| Docker layers |
HIGH |
Use cache-from: type=gha |
| Test fixtures |
LOW |
Usually not worth caching |
Deployment Patterns
Blue-Green Deployment
Two identical environments. Switch traffic atomically.
Current traffic --> Blue (v1.0)
Green (v1.1) <-- Deploy here, test, then switch
After switch:
Current traffic --> Green (v1.1)
Blue (v1.0) <-- Rollback target
| Pros |
Cons |
| Instant rollback |
Requires 2x infrastructure |
| Zero downtime |
Database migrations need care |
| Full environment testing |
Higher cost |
Canary Deployment
Route a small percentage of traffic to the new version.
95% traffic --> v1.0 (stable)
5% traffic --> v1.1 (canary)
Monitor metrics. If healthy:
50% --> v1.0, 50% --> v1.1
Then: 100% --> v1.1
| Pros |
Cons |
| Low risk |
Slower rollout |
| Real traffic testing |
Complex routing setup |
| Gradual confidence |
Stateful apps need care |
Rolling Deployment
Replace instances one at a time.
Instance 1: v1.0 --> v1.1 (update, health check, continue)
Instance 2: v1.0 --> v1.1
Instance 3: v1.0 --> v1.1
| Pros |
Cons |
| No extra infrastructure |
Mixed versions during rollout |
| Simple to implement |
Slower rollback (re-deploy) |
| Works with most platforms |
Must be backward compatible |
Matrix Builds
Test across multiple versions and platforms efficiently.
strategy:
fail-fast: false # Don't cancel other jobs if one fails
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node-version: [18, 20, 22]
exclude:
# Skip combinations that don't matter
- os: macos-latest
node-version: 18
include:
# Add specific extra combinations
- os: ubuntu-latest
node-version: 20
coverage: true
steps:
- run: npm test
- if: matrix.coverage
run: npm run test:coverage
Pipeline Optimization
Speed Improvements
| Technique |
Savings |
Complexity |
| Dependency caching |
30-60s |
Low |
| Parallel jobs |
40-70% |
Low |
cancel-in-progress |
Avoid wasted runs |
Low |
| Docker layer caching |
1-5 min |
Medium |
| Selective test running |
Variable |
Medium |
| Self-hosted runners |
Variable |
High |
Conditional Execution
# Only run when relevant files change
on:
push:
paths:
- 'src/**'
- 'tests/**'
- 'package.json'
- 'package-lock.json'
paths-ignore:
- '**.md'
- 'docs/**'
# Skip CI for documentation-only changes
jobs:
test:
if: |
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[docs only]')
Reusable Workflows
# .github/workflows/reusable-test.yml
on:
workflow_call:
inputs:
node-version:
required: false
type: string
default: '20'
secrets:
NPM_TOKEN:
required: false
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: npm
- run: npm ci
- run: npm test
# .github/workflows/ci.yml -- caller
jobs:
test:
uses: ./.github/workflows/reusable-test.yml
with:
node-version: '20'
secrets: inherit
Anti-Patterns
| Anti-Pattern |
Problem |
Fix |
| Force-push in deploy scripts |
Can overwrite production state |
Use atomic deploys, never git push --force in CI |
| Secrets in workflow files |
Exposed in repo history |
Use GitHub Secrets or OIDC |
echo $SECRET in logs |
Leaked credentials |
Never echo; use ::add-mask:: for dynamic values |
| No approval gate for production |
Accidental deploys |
Use GitHub Environments with required reviewers |
npm install instead of npm ci |
Non-deterministic builds |
Always npm ci in CI (uses lockfile) |
No concurrency control |
Wasted compute, race conditions |
Add cancel-in-progress for PR builds |
| Hardcoded versions in actions |
Breaks without notice |
Pin to major version (@v4) or SHA |
| Running tests only on main |
Broken PRs get merged |
Run on pull_request trigger |
| Single monolithic job |
Slow, no parallelism |
Split into lint/test/build/deploy jobs |
| No timeout on jobs |
Hung builds waste minutes |
Set timeout-minutes on every job |
permissions: write-all |
Excessive permissions |
Use minimal permissions per job |
Workflow Security Checklist