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.