G
GuideDevOps
Lesson 6 of 11

Pipeline Stages

Part of the CI/CD Pipelines tutorial series.

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

TriggerWhen It FiresUse Case
pushCode pushed to a branchRun CI on every commit
pull_requestPR opened/updatedValidate before merge
tagGit tag createdTrigger a release build
scheduleCron scheduleNightly security scans
manualHuman clicks buttonProduction deployments
webhookExternal eventTrigger 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
          - production

GitLab 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 commitlint

Popular Linters by Language

LanguageLinterWhat It Catches
JavaScript/TSESLintCode quality, style, bugs
PythonRuff, Flake8PEP 8, complexity, imports
Gogolangci-lintBugs, style, performance
JavaCheckstyle, SpotBugsCoding standards, bugs
DockerfileHadolintDocker best practices
YAMLyamllintSyntax, structure
ShellShellCheckCommon bash pitfalls
TerraformtflintHCL best practices
KuberneteskubevalK8s 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: 7

Build Optimization Tips

TechniqueSpeedupHow
Dependency caching30-60%Cache node_modules/, .m2/, etc.
Multi-stage Docker builds40-70%Smaller final images, cached layers
Parallel builds20-50%Build frontend + backend simultaneously
Incremental builds50-80%Only rebuild changed modules
Build matrixN/ATest 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.xml

Coverage Thresholds

MetricMinimumRecommendedExcellent
Statements70%80%90%+
Branches60%75%85%+
Functions70%80%90%+
Lines70%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: test

Integration vs Unit Tests

AspectUnit TestsIntegration Tests
ScopeSingle functionMultiple components
DependenciesMockedReal (DB, Redis, APIs)
SpeedMillisecondsSeconds
QuantityThousandsHundreds
ReliabilityVery stableCan be flaky
What they catchLogic errorsWiring/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.sarif

Severity Response Matrix

SeverityActionTimelineBlock Deploy?
CriticalFix immediatelySame day✅ Yes
HighFix before release1-3 days✅ Yes
MediumSchedule fix1-2 weeks⚠️ Depends
LowBacklogNext sprint❌ No
InformationalNoteNo 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 here

Pipeline 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

MetricTargetWhy It Matters
Total duration< 10 minDeveloper focus time
Success rate> 95%Flaky pipelines = wasted time
Time to first failure< 2 minFast feedback = faster fixes
Queue wait time< 1 minRunner capacity planning
Deployment frequency1+ per dayMeasure team velocity
Change failure rate< 15%Pipeline effectiveness