The Anatomy of a Production Pipeline
A CI/CD pipeline is a series of automated stages that code passes through from the moment a developer pushes a commit to the moment it's running in production. Each stage acts as a quality gate — if any stage fails, the pipeline stops and the team is notified.
Developer pushes code
↓
┌──────────────────────────────────────────────────────┐
│ STAGE 1: SOURCE (0 sec) Trigger pipeline │
│ STAGE 2: LINT (15 sec) Code quality │
│ STAGE 3: BUILD (60 sec) Compile & package │
│ STAGE 4: UNIT TEST (45 sec) Function-level │
│ STAGE 5: INTEGRATION (3 min) Service-level │
│ STAGE 6: SECURITY (2 min) Vulnerability scan │
│ STAGE 7: DEPLOY STAGING (1 min) Pre-prod env │
│ STAGE 8: DEPLOY PROD (1 min) Live release │
└──────────────────────────────────────────────────────┘
↓
Live in production ✅
Total: ~8 minutes
Key principle: Fail fast. The cheapest, fastest checks go first so broken code is caught in seconds, not minutes.
Stage 1: Source (Trigger)
The pipeline begins when an event occurs in your version control system.
Common Triggers
| Trigger | When It Fires | Use Case |
|---|---|---|
push | Code pushed to a branch | Run CI on every commit |
pull_request | PR opened/updated | Validate before merge |
tag | Git tag created | Trigger a release build |
schedule | Cron schedule | Nightly security scans |
manual | Human clicks button | Production deployments |
webhook | External event | Trigger from Slack or API |
GitHub Actions Example
on:
push:
branches: [main, develop]
paths:
- 'src/**' # Only trigger if source code changed
- 'package.json' # Or dependencies changed
- '!**.md' # Ignore documentation changes
pull_request:
branches: [main]
schedule:
- cron: '0 2 * * MON-FRI' # Weekdays at 2 AM UTC
workflow_dispatch: # Manual trigger button
inputs:
environment:
description: 'Deploy to which environment?'
required: true
default: 'staging'
type: choice
options:
- staging
- productionGitLab CI Example
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "main"'
- if: '$CI_COMMIT_BRANCH == "develop"'
- if: '$CI_PIPELINE_SOURCE == "schedule"'Jenkins Example
pipeline {
triggers {
pollSCM('H/5 * * * *') // Check for changes every 5 minutes
cron('0 2 * * *') // Nightly build at 2 AM
}
}Why this matters: Smart triggers prevent wasted compute. Don't run the entire pipeline when only a README file changed.
Stage 2: Lint & Static Analysis
The first quality gate. Linting catches code style issues and potential bugs without running the code.
What Linting Catches
✅ Syntax errors (missing semicolons, brackets)
✅ Style violations (inconsistent indentation, naming)
✅ Dead code (unused variables, unreachable code)
✅ Potential bugs (type mismatches, null references)
✅ Code complexity (functions too long, too many parameters)
✅ Import/dependency issues (circular imports, missing modules)
Real-World Lint Stage
# GitHub Actions
lint:
name: Code Quality
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
# JavaScript/TypeScript linting
- name: ESLint
run: npm run lint -- --max-warnings=0
# Type checking
- name: TypeScript
run: npx tsc --noEmit
# Formatting check
- name: Prettier
run: npx prettier --check "src/**/*.{ts,tsx,js,jsx}"
# Commit message linting
- name: Commitlint
run: |
npm install @commitlint/cli @commitlint/config-conventional
echo "${{ github.event.head_commit.message }}" | npx commitlintPopular Linters by Language
| Language | Linter | What It Catches |
|---|---|---|
| JavaScript/TS | ESLint | Code quality, style, bugs |
| Python | Ruff, Flake8 | PEP 8, complexity, imports |
| Go | golangci-lint | Bugs, style, performance |
| Java | Checkstyle, SpotBugs | Coding standards, bugs |
| Dockerfile | Hadolint | Docker best practices |
| YAML | yamllint | Syntax, structure |
| Shell | ShellCheck | Common bash pitfalls |
| Terraform | tflint | HCL best practices |
| Kubernetes | kubeval | K8s manifest validation |
Why Lint First?
Lint takes: 10-15 seconds ⚡
Build takes: 60-120 seconds
Tests take: 2-5 minutes
If lint fails → save 3-7 minutes of wasted compute
At 50 commits/day → save 2.5-5.8 hours of CI time daily
Stage 3: Build
The build stage compiles code, installs dependencies, and creates deployable artifacts (Docker images, binaries, bundles).
What Happens During Build
Source code
↓
Install dependencies (npm ci, pip install, mvn install)
↓
Compile/transpile (TypeScript → JavaScript, Java → bytecode)
↓
Bundle/package (Webpack, Vite, Go binary)
↓
Build Docker image (if containerized)
↓
Tag with version (git SHA, semantic version)
↓
Output: Deployable artifact ✅
Real-World Build Stage
build:
name: Build Application
runs-on: ubuntu-latest
needs: lint # Only build if lint passes
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build application
run: npm run build
env:
NODE_ENV: production
- name: Build Docker image
run: |
docker build \
--tag myapp:${{ github.sha }} \
--tag myapp:latest \
--build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
--build-arg GIT_SHA=${{ github.sha }} \
.
- name: Push to container registry
run: |
echo "${{ secrets.DOCKER_PASSWORD }}" | \
docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
docker push myapp:${{ github.sha }}
docker push myapp:latest
# Save build output for later stages
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
retention-days: 7Build Optimization Tips
| Technique | Speedup | How |
|---|---|---|
| Dependency caching | 30-60% | Cache node_modules/, .m2/, etc. |
| Multi-stage Docker builds | 40-70% | Smaller final images, cached layers |
| Parallel builds | 20-50% | Build frontend + backend simultaneously |
| Incremental builds | 50-80% | Only rebuild changed modules |
| Build matrix | N/A | Test across OS/versions in parallel |
Multi-Stage Dockerfile (Optimized Build)
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
COPY . .
RUN npm run build
# Stage 2: Production (tiny image)
FROM node:20-alpine AS production
WORKDIR /app
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s CMD wget -q --spider http://localhost:3000/health || exit 1
CMD ["node", "dist/server.js"]Stage 4: Unit Testing
Unit tests verify that individual functions and modules work correctly in isolation. They are the foundation of the testing pyramid.
The Testing Pyramid
╱╲
╱ ╲ E2E Tests (few, slow, expensive)
╱ E2E╲ → Real browser, real database
╱──────╲ → 5-10 tests, 5-15 minutes
╱ ╲
╱Integration╲ Integration Tests (some, moderate)
╱────────────╲ → API calls, database queries
╱ ╲ → 50-200 tests, 2-5 minutes
╱ Unit Tests ╲ Unit Tests (many, fast, cheap)
╱──────────────────╲ → Pure functions, logic, calculations
→ 500-5000 tests, 10-60 seconds
Real-World Unit Test Stage
test-unit:
name: Unit Tests
runs-on: ubuntu-latest
needs: build
strategy:
matrix:
node-version: [18, 20, 22] # Test on multiple versions
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- name: Run unit tests with coverage
run: npm test -- --coverage --watchAll=false
env:
CI: true
- name: Check coverage threshold
run: |
COVERAGE=$(npx istanbul-coverage-check --statements 80 --branches 75 --functions 80 --lines 80)
echo "Coverage check: $COVERAGE"
- name: Upload coverage report
uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.info
fail_ci_if_error: true
flags: unit-tests
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results-node-${{ matrix.node-version }}
path: junit.xmlCoverage Thresholds
| Metric | Minimum | Recommended | Excellent |
|---|---|---|---|
| Statements | 70% | 80% | 90%+ |
| Branches | 60% | 75% | 85%+ |
| Functions | 70% | 80% | 90%+ |
| Lines | 70% | 80% | 90%+ |
Rule of thumb: Require 80% coverage for merges. Never allow coverage to decrease on a PR.
Stage 5: Integration Testing
Integration tests verify that multiple components work together correctly — your app talking to a database, an API calling another service, etc.
What Integration Tests Cover
✅ API endpoints returning correct responses
✅ Database queries and transactions
✅ Message queue producers and consumers
✅ Cache reads and writes (Redis)
✅ External service interactions (via mocks or stubs)
✅ Authentication and authorization flows
Real-World Integration Test Stage
test-integration:
name: Integration Tests
runs-on: ubuntu-latest
needs: build
services:
# Spin up real databases for testing
postgres:
image: postgres:16
env:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Run database migrations
run: npm run db:migrate
env:
DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
- name: Seed test data
run: npm run db:seed
env:
DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
- name: Run integration tests
run: npm run test:integration
env:
DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
REDIS_URL: redis://localhost:6379
NODE_ENV: testIntegration vs Unit Tests
| Aspect | Unit Tests | Integration Tests |
|---|---|---|
| Scope | Single function | Multiple components |
| Dependencies | Mocked | Real (DB, Redis, APIs) |
| Speed | Milliseconds | Seconds |
| Quantity | Thousands | Hundreds |
| Reliability | Very stable | Can be flaky |
| What they catch | Logic errors | Wiring/config errors |
Stage 6: Security Scanning
Security scanning detects vulnerabilities, exposed secrets, and compliance issues before code reaches production.
Types of Security Scans
SAST (Static Analysis)
→ Scans source code for vulnerabilities
→ Finds: SQL injection, XSS, insecure crypto
→ Tools: SonarQube, Semgrep, CodeQL
SCA (Software Composition Analysis)
→ Scans dependencies for known CVEs
→ Finds: Vulnerable npm packages, outdated libraries
→ Tools: Snyk, npm audit, Dependabot
Secret Detection
→ Scans for leaked credentials
→ Finds: API keys, passwords, tokens in code
→ Tools: TruffleHog, GitLeaks, detect-secrets
Container Scanning
→ Scans Docker images for vulnerabilities
→ Finds: OS-level CVEs, misconfigured base images
→ Tools: Trivy, Grype, Docker Scout
DAST (Dynamic Analysis)
→ Scans running application for vulnerabilities
→ Finds: Runtime injection, auth bypass, CSRF
→ Tools: OWASP ZAP, Burp Suite
Real-World Security Stage
security:
name: Security Scanning
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for secret scanning
# 1. Dependency vulnerability scanning
- name: npm audit
run: npm audit --audit-level=high
continue-on-error: false
# 2. Secret detection
- name: TruffleHog scan
uses: trufflesecurity/trufflehog@main
with:
path: ./
extra_args: --only-verified
# 3. SAST with CodeQL
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: javascript-typescript
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
# 4. Container image scanning
- name: Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
exit-code: '1' # Fail pipeline on critical/high
- name: Upload security results
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarifSeverity Response Matrix
| Severity | Action | Timeline | Block Deploy? |
|---|---|---|---|
| Critical | Fix immediately | Same day | ✅ Yes |
| High | Fix before release | 1-3 days | ✅ Yes |
| Medium | Schedule fix | 1-2 weeks | ⚠️ Depends |
| Low | Backlog | Next sprint | ❌ No |
| Informational | Note | No deadline | ❌ No |
Stage 7: Deploy to Staging
Staging is a production-identical environment where you validate the full application before releasing to real users.
What Staging Validates
✅ Docker image starts correctly
✅ Database migrations run successfully
✅ Environment variables are set correctly
✅ API endpoints respond with correct data
✅ Frontend loads and renders properly
✅ Third-party integrations work
✅ Performance meets baseline requirements
✅ No regressions from previous release
Real-World Staging Deployment
deploy-staging:
name: Deploy to Staging
runs-on: ubuntu-latest
needs: [test-unit, test-integration, security]
if: github.ref == 'refs/heads/develop'
environment:
name: staging
url: https://staging.myapp.com
steps:
- uses: actions/checkout@v4
- name: Configure kubectl
uses: azure/setup-kubectl@v3
- name: Set Kubernetes context
uses: azure/k8s-set-context@v3
with:
kubeconfig: ${{ secrets.KUBE_CONFIG_STAGING }}
- name: Deploy to staging
run: |
kubectl set image deployment/myapp \
myapp=myregistry.io/myapp:${{ github.sha }} \
-n staging
- name: Wait for rollout
run: kubectl rollout status deployment/myapp -n staging --timeout=5m
- name: Run smoke tests
run: |
# Wait for service to be ready
for i in $(seq 1 30); do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://staging.myapp.com/health)
if [ "$STATUS" = "200" ]; then
echo "✅ Health check passed"
break
fi
echo "Waiting for staging... ($i/30)"
sleep 5
done
# Verify critical endpoints
curl -f https://staging.myapp.com/api/status
curl -f https://staging.myapp.com/api/version
- name: Run E2E tests against staging
run: |
npx playwright test --config=playwright.staging.config.ts
env:
BASE_URL: https://staging.myapp.com
- name: Notify team
if: always()
uses: 8398a7/action-slack@v3
with:
status: ${{ job.status }}
text: |
Staging deployment: ${{ job.status }}
Version: ${{ github.sha }}
URL: https://staging.myapp.com
webhook_url: ${{ secrets.SLACK_WEBHOOK }}Stage 8: Deploy to Production
The final stage — your code goes live to real users. This is where deployment strategies protect you from risk.
Real-World Production Deployment
deploy-production:
name: Deploy to Production
runs-on: ubuntu-latest
needs: deploy-staging
environment:
name: production
url: https://myapp.com
steps:
- uses: actions/checkout@v4
- name: Configure kubectl
uses: azure/setup-kubectl@v3
- name: Set Kubernetes context
uses: azure/k8s-set-context@v3
with:
kubeconfig: ${{ secrets.KUBE_CONFIG_PRODUCTION }}
# Rolling deployment with health checks
- name: Deploy to production
run: |
kubectl set image deployment/myapp \
myapp=myregistry.io/myapp:${{ github.sha }} \
-n production
- name: Monitor rollout
run: |
kubectl rollout status deployment/myapp \
-n production \
--timeout=10m
# Post-deployment validation
- name: Production smoke tests
run: |
sleep 30 # Wait for DNS propagation
# Health check
curl -f https://myapp.com/health
# API validation
curl -f https://myapp.com/api/status
# Response time check
RESPONSE_TIME=$(curl -w "%{time_total}" -o /dev/null -s https://myapp.com)
echo "Response time: ${RESPONSE_TIME}s"
if (( $(echo "$RESPONSE_TIME > 3.0" | bc -l) )); then
echo "❌ Response time too slow!"
exit 1
fi
# Auto-rollback on failure
- name: Rollback on failure
if: failure()
run: |
echo "❌ Production deployment failed! Rolling back..."
kubectl rollout undo deployment/myapp -n production
kubectl rollout status deployment/myapp -n production
- name: Create release tag
if: success()
run: |
gh release create "v$(date +%Y%m%d-%H%M%S)" \
--title "Production Release" \
--notes "Deployed commit: ${{ github.sha }}"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Notify deployment
if: always()
run: |
STATUS="${{ job.status }}"
curl -X POST ${{ secrets.SLACK_WEBHOOK }} \
-H 'Content-Type: application/json' \
-d "{\"text\":\"🚀 Production deployment: ${STATUS}\nCommit: ${{ github.sha }}\nURL: https://myapp.com\"}"Complete Pipeline: All Stages Together
Here's how all 8 stages connect in a single GitHub Actions workflow:
name: Full CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
# Stage 1: Source (trigger is automatic)
# Stage 2: Lint
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: npx tsc --noEmit
# Stage 3: Build
build:
needs: 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 build
- uses: actions/upload-artifact@v4
with: { name: build, path: dist/ }
# Stage 4: Unit Tests
test-unit:
needs: 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 test -- --coverage
- uses: codecov/codecov-action@v4
# Stage 5: Integration Tests
test-integration:
needs: build
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env: { POSTGRES_DB: test, POSTGRES_USER: test, POSTGRES_PASSWORD: test }
ports: ['5432:5432']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- run: npm run test:integration
# Stage 6: Security
security:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm audit --audit-level=high
- uses: github/codeql-action/init@v3
with: { languages: javascript-typescript }
- uses: github/codeql-action/analyze@v3
# Stage 7: Deploy to Staging
deploy-staging:
needs: [test-unit, test-integration, security]
if: github.ref == 'refs/heads/develop'
runs-on: ubuntu-latest
environment: staging
steps:
- run: echo "Deploying to staging..."
# kubectl deploy commands here
# Stage 8: Deploy to Production
deploy-production:
needs: [test-unit, test-integration, security]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
steps:
- run: echo "Deploying to production..."
# kubectl deploy commands herePipeline Visualization
Parallel vs Sequential Stages
Sequential (slow):
Lint → Build → Unit → Integration → Security → Staging → Production
Total: 12 minutes
Parallel (fast):
Lint → Build → ┬─ Unit Tests ─────┐
├─ Integration ────┤ → Staging → Production
└─ Security ───────┘
Total: 7 minutes (40% faster!)
Rule: Run independent stages in parallel whenever possible.
Pipeline Status Dashboard
┌─────────────────────────────────────────────────────────┐
│ Pipeline #2847 | main | abc123f | 8 min ago │
├─────────────────────────────────────────────────────────┤
│ │
│ ✅ Lint ──→ ✅ Build ──→ ✅ Unit Tests ──┐ │
│ ✅ Integration ─┤→ ✅ Staging │
│ ✅ Security ────┘ │ │
│ ⏳ Prod │
│ (waiting for │
│ approval) │
│ │
└─────────────────────────────────────────────────────────┘
Stage Design Best Practices
✅ DO This
✅ Fail fast — Put fastest checks first (lint: 15s, not build: 2m)
✅ Cache aggressively — Cache node_modules/, Docker layers, build outputs
✅ Run independent stages in parallel — Tests + security + scanning simultaneously
✅ Use artifacts — Pass build output between stages instead of rebuilding
✅ Set timeouts — Prevent hung pipelines from burning CI minutes
jobs:
build:
timeout-minutes: 15 # Kill if stuck✅ Keep stages idempotent — Running the same pipeline twice should produce the same result
❌ DON'T Do This
❌ Don't build the same code twice — Build once, test the artifact
❌ Don't skip security — Every pipeline needs at least npm audit
❌ Don't deploy without tests — "It works on my machine" is not a test
❌ Don't ignore flaky tests — Fix them immediately or quarantine them
❌ Don't put all stages in sequence — Parallelize independent work
Common Pipeline Patterns
1. Feature Branch Pipeline (PR)
Lint → Build → Unit Tests → Integration Tests → Security Scan
(No deployment — just validation)
2. Develop Branch Pipeline
Lint → Build → All Tests → Security → Deploy to Staging → Smoke Tests
3. Main Branch Pipeline (Release)
Lint → Build → All Tests → Security → Deploy Staging → E2E Tests → Manual Approval → Deploy Production → Monitor
4. Hotfix Pipeline
Lint → Build → Critical Tests → Security → Deploy Production (fast-track)
Pipeline Metrics to Track
| Metric | Target | Why It Matters |
|---|---|---|
| Total duration | < 10 min | Developer focus time |
| Success rate | > 95% | Flaky pipelines = wasted time |
| Time to first failure | < 2 min | Fast feedback = faster fixes |
| Queue wait time | < 1 min | Runner capacity planning |
| Deployment frequency | 1+ per day | Measure team velocity |
| Change failure rate | < 15% | Pipeline effectiveness |