Introduction

Slow CI/CD pipelines waste developer time and delay releases. This guide covers proven techniques to optimize pipeline performance including build caching, parallel job execution, and efficient deployment strategies across popular CI/CD platforms.

Build Caching

Why Caching Matters

Without caching:

Build 1: npm install (5 min) β†’ tests (2 min) = 7 min
Build 2: npm install (5 min) β†’ tests (2 min) = 7 min
Build 3: npm install (5 min) β†’ tests (2 min) = 7 min
Total: 21 minutes

With caching:

Build 1: npm install (5 min) β†’ tests (2 min) = 7 min
Build 2: npm install (10 sec cache) β†’ tests (2 min) = 2.2 min
Build 3: npm install (10 sec cache) β†’ tests (2 min) = 2.2 min
Total: 11.4 minutes (46% faster)

GitHub Actions Caching

What is GitHub Actions Caching?

GitHub Actions provides a built-in caching mechanism that stores files between workflow runs. This dramatically reduces build times by reusing previously downloaded dependencies, compiled artifacts, and other files that don’t change frequently.

How it works:

  1. Cache Key - A unique identifier for your cache (usually based on file hashes)
  2. Cache Path - The directory/files to cache
  3. Restore Keys - Fallback keys if exact match not found
  4. Automatic Cleanup - GitHub removes caches not accessed in 7 days

Key concepts:

  • Cache hit - Exact cache key match found, cache restored
  • Cache miss - No match, dependencies downloaded fresh
  • Partial restore - Restore key matches, provides base to build on

When to use caching:

  • Package dependencies (npm, pip, maven, etc.)
  • Build outputs that take time to generate
  • Docker layers
  • Test data or fixtures

Node.js dependencies example:

name: CI

on: [push]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      # Cache npm dependencies
      - name: Cache node modules
        uses: actions/cache@v3
        with:
          # What to cache
          path: ~/.npm

          # Cache key (unique per OS and package-lock.json hash)
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}

          # Fallback keys if exact match not found
          restore-keys: |
            ${{ runner.os }}-node-

      - name: Install dependencies
        run: npm ci  # If cache hit, this is very fast

      - name: Run tests
        run: npm test

How the cache key works:

key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
# Example: "Linux-node-a3f2b1c8..."

# If package-lock.json changes:
# New hash β†’ new key β†’ cache miss β†’ fresh install β†’ new cache created

# If package-lock.json unchanged:
# Same hash β†’ same key β†’ cache hit β†’ restore from cache (fast!)

Docker layer caching:

Docker builds in layers, and each layer can be cached. This is extremely powerful for reducing build times because unchanged layers don’t need to be rebuilt.

Why Docker caching matters:

  • Each Dockerfile instruction creates a layer
  • Layers are cached based on instruction and content
  • Changing one layer invalidates all subsequent layers
  • Proper caching can reduce 10-minute builds to 30 seconds

Example: If you only change application code, you don’t need to reinstall dependencies.

name: Docker Build

on: [push]

jobs:
  docker:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      # Enable Docker BuildKit (required for advanced caching)
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2

      # Cache Docker layers between runs
      - name: Cache Docker layers
        uses: actions/cache@v3
        with:
          path: /tmp/.buildx-cache
          key: ${{ runner.os }}-buildx-${{ github.sha }}
          restore-keys: |
            ${{ runner.os }}-buildx-

      # Build with cache
      - name: Build Docker image
        uses: docker/build-push-action@v4
        with:
          context: .
          push: false
          tags: myapp:latest

          # Use cached layers from previous builds
          cache-from: type=local,src=/tmp/.buildx-cache

          # Save new layers to cache
          cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max

      # Update cache (avoids cache bloat)
      - name: Move cache
        run: |
          rm -rf /tmp/.buildx-cache
          mv /tmp/.buildx-cache-new /tmp/.buildx-cache

Cache optimization tip:

# Bad: Changes to app code rebuild everything
COPY . .
RUN npm install
RUN npm run build

# Good: Dependencies cached separately
COPY package*.json ./
RUN npm install  # Cached until package.json changes
COPY . .
RUN npm run build  # Only this rebuilds when code changes

Multiple cache layers:

- name: Cache Go modules
  uses: actions/cache@v3
  with:
    path: ~/go/pkg/mod
    key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}

- name: Cache Go build
  uses: actions/cache@v3
  with:
    path: ~/.cache/go-build
    key: ${{ runner.os }}-go-build-${{ hashFiles('**/*.go') }}

GitLab CI Caching

Basic cache:

# .gitlab-ci.yml

stages:
  - build
  - test

cache:
  key: ${CI_COMMIT_REF_SLUG}
  paths:
    - node_modules/
    - .npm/

build:
  stage: build
  script:
    - npm ci --cache .npm --prefer-offline
    - npm run build
  artifacts:
    paths:
      - dist/

test:
  stage: test
  script:
    - npm test

Per-job caching:

build-frontend:
  stage: build
  cache:
    key: frontend-${CI_COMMIT_REF_SLUG}
    paths:
      - frontend/node_modules/
  script:
    - cd frontend
    - npm ci
    - npm run build

build-backend:
  stage: build
  cache:
    key: backend-${CI_COMMIT_REF_SLUG}
    paths:
      - backend/node_modules/
  script:
    - cd backend
    - npm ci
    - npm run build

Docker layer caching (GitLab):

build-image:
  stage: build
  image: docker:latest
  services:
    - docker:dind
  variables:
    DOCKER_DRIVER: overlay2
    DOCKER_BUILDKIT: 1
  before_script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    # Pull previous image for cache
    - docker pull $CI_REGISTRY_IMAGE:latest || true

    # Build with cache
    - >
      docker build
      --cache-from $CI_REGISTRY_IMAGE:latest
      --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
      --tag $CI_REGISTRY_IMAGE:latest
      .

    # Push new image
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
    - docker push $CI_REGISTRY_IMAGE:latest

Jenkins Caching

Declarative pipeline with caching:

pipeline {
    agent any

    environment {
        NPM_CACHE = "${WORKSPACE}/.npm-cache"
    }

    stages {
        stage('Install Dependencies') {
            steps {
                // Cache npm packages
                cache(maxCacheSize: 250, caches: [
                    arbitraryFileCache(
                        path: "${NPM_CACHE}",
                        cacheValidityDecidingFile: 'package-lock.json'
                    )
                ]) {
                    sh 'npm ci --cache ${NPM_CACHE}'
                }
            }
        }

        stage('Build') {
            steps {
                sh 'npm run build'
            }
        }

        stage('Test') {
            steps {
                sh 'npm test'
            }
        }
    }
}

Docker layer caching (Jenkins):

pipeline {
    agent any

    environment {
        DOCKER_BUILDKIT = '1'
    }

    stages {
        stage('Build Image') {
            steps {
                script {
                    // Pull previous image
                    sh 'docker pull myapp:latest || true'

                    // Build with cache
                    sh '''
                        docker build \
                          --cache-from myapp:latest \
                          --tag myapp:${GIT_COMMIT} \
                          --tag myapp:latest \
                          .
                    '''

                    // Push to registry
                    sh '''
                        docker push myapp:${GIT_COMMIT}
                        docker push myapp:latest
                    '''
                }
            }
        }
    }
}

Parallel Job Execution

What is parallel execution?

Instead of running jobs sequentially (one after another), parallel execution runs multiple jobs simultaneously. This is one of the most effective ways to reduce total pipeline time.

Sequential execution:

Lint (2 min) β†’ Unit Tests (5 min) β†’ Integration Tests (8 min) β†’ Deploy (3 min)
Total: 18 minutes

Parallel execution:

β”Œβ”€ Lint (2 min) ────────┐
β”œβ”€ Unit Tests (5 min) ───→ Deploy (3 min)
└─ Integration Tests (8 min)β”€β”˜
Total: 11 minutes (39% faster)

When to use parallel jobs:

  • Independent tests (unit, integration, e2e)
  • Building multiple services (frontend, backend, mobile)
  • Testing across different environments (OS, versions)
  • Linting and security scans

Key principle: Jobs can run in parallel if they don’t depend on each other’s outputs.

GitHub Actions Parallelization

Matrix strategy:

Matrix builds automatically create multiple jobs from a single configuration. Perfect for testing across multiple versions, operating systems, or configurations.

How it works:

  • Define matrix variables (e.g., node-version, os)
  • GitHub creates one job per combination
  • All matrix jobs run in parallel
name: Test Matrix

on: [push]

jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        # Creates 9 jobs (3 versions Γ— 3 OS = 9 combinations)
        node-version: [16, 18, 20]
        os: [ubuntu-latest, macos-latest, windows-latest]

    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

# Result: All 9 jobs run simultaneously
# Time: ~5 minutes (instead of 45 minutes sequentially)

Parallel jobs:

jobs:
  # Run in parallel
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - run: npm ci
      - run: npm run lint

  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - run: npm ci
      - run: npm run test:unit

  integration-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - run: npm ci
      - run: npm run test:integration

  # Deploy waits for all tests
  deploy:
    needs: [lint, unit-tests, integration-tests]
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying..."

Conditional parallel execution:

jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      frontend: ${{ steps.filter.outputs.frontend }}
      backend: ${{ steps.filter.outputs.backend }}
    steps:
      - uses: actions/checkout@v3
      - uses: dorny/paths-filter@v2
        id: filter
        with:
          filters: |
            frontend:
              - 'frontend/**'
            backend:
              - 'backend/**'

  build-frontend:
    needs: changes
    if: ${{ needs.changes.outputs.frontend == 'true' }}
    runs-on: ubuntu-latest
    steps:
      - run: echo "Building frontend..."

  build-backend:
    needs: changes
    if: ${{ needs.changes.outputs.backend == 'true' }}
    runs-on: ubuntu-latest
    steps:
      - run: echo "Building backend..."

GitLab CI Parallelization

Parallel keyword:

test:
  stage: test
  parallel: 5  # Run 5 instances in parallel
  script:
    - npm run test -- --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL

# Or with matrix
test-matrix:
  stage: test
  parallel:
    matrix:
      - NODE_VERSION: [16, 18, 20]
        OS: [ubuntu, alpine]
  image: node:${NODE_VERSION}-${OS}
  script:
    - npm test

Independent jobs:

stages:
  - build
  - test
  - deploy

# Run in parallel (same stage)
build-frontend:
  stage: build
  script:
    - cd frontend && npm run build

build-backend:
  stage: build
  script:
    - cd backend && npm run build

# Run in parallel (same stage)
test-unit:
  stage: test
  script:
    - npm run test:unit

test-integration:
  stage: test
  script:
    - npm run test:integration

test-e2e:
  stage: test
  script:
    - npm run test:e2e

# Runs after all tests pass
deploy:
  stage: deploy
  script:
    - ./deploy.sh

Jenkins Parallelization

Parallel stages:

pipeline {
    agent any

    stages {
        stage('Build') {
            parallel {
                stage('Build Frontend') {
                    steps {
                        dir('frontend') {
                            sh 'npm ci'
                            sh 'npm run build'
                        }
                    }
                }

                stage('Build Backend') {
                    steps {
                        dir('backend') {
                            sh 'npm ci'
                            sh 'npm run build'
                        }
                    }
                }

                stage('Build Mobile') {
                    steps {
                        dir('mobile') {
                            sh 'npm ci'
                            sh 'npm run build'
                        }
                    }
                }
            }
        }

        stage('Test') {
            parallel {
                stage('Unit Tests') {
                    steps {
                        sh 'npm run test:unit'
                    }
                }

                stage('Integration Tests') {
                    steps {
                        sh 'npm run test:integration'
                    }
                }

                stage('E2E Tests') {
                    steps {
                        sh 'npm run test:e2e'
                    }
                }
            }
        }

        stage('Deploy') {
            steps {
                sh './deploy.sh'
            }
        }
    }
}

Dynamic parallel stages:

pipeline {
    agent any

    stages {
        stage('Test Microservices') {
            steps {
                script {
                    def services = ['auth', 'users', 'orders', 'payments']
                    def parallelStages = [:]

                    services.each { service ->
                        parallelStages[service] = {
                            stage("Test ${service}") {
                                sh "cd services/${service} && npm test"
                            }
                        }
                    }

                    parallel parallelStages
                }
            }
        }
    }
}

Optimization Techniques

1. Skip Unnecessary Steps

GitHub Actions:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      # Only run on specific paths
      - name: Check if build needed
        id: changes
        uses: dorny/paths-filter@v2
        with:
          filters: |
            src:
              - 'src/**'
              - 'package.json'

      - name: Build
        if: steps.changes.outputs.src == 'true'
        run: npm run build

      # Skip on draft PRs
      - name: Deploy
        if: github.event.pull_request.draft == false
        run: ./deploy.sh

GitLab CI:

build:
  stage: build
  script:
    - npm run build
  only:
    changes:
      - src/**
      - package.json

deploy:
  stage: deploy
  script:
    - ./deploy.sh
  except:
    variables:
      - $CI_MERGE_REQUEST_TITLE =~ /^WIP:/

2. Fail Fast

GitHub Actions:

jobs:
  test:
    strategy:
      fail-fast: true  # Stop all jobs on first failure
      matrix:
        node: [16, 18, 20]
    steps:
      - run: npm test

  # Or check before expensive operations
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - run: npm run lint

  test:
    needs: lint  # Only run if lint passes
    runs-on: ubuntu-latest
    steps:
      - run: npm test

3. Optimize Docker Builds

Multi-stage build optimization:

# Dependency stage (cached separately)
FROM node:18-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

# Build stage
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Production stage (minimal)
FROM node:18-alpine
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/index.js"]

BuildKit cache mounts:

# syntax=docker/dockerfile:1

FROM node:18-alpine AS builder

WORKDIR /app

# Use cache mount for npm
RUN --mount=type=cache,target=/root/.npm \
    npm ci

COPY . .

# Use cache mount for build cache
RUN --mount=type=cache,target=/app/.next/cache \
    npm run build

4. Artifact Reuse

What are artifacts?

Artifacts are files produced by one job that can be reused by subsequent jobs. Instead of rebuilding the same files multiple times, you build once and share the output across jobs.

Why use artifacts:

  • Avoid redundant work - Build once, use multiple times
  • Consistency - All jobs use the exact same build output
  • Faster pipelines - No need to rebuild for each test/deploy stage
  • Debugging - Download artifacts to investigate failures

Common use cases:

  • Compiled applications
  • Built frontend assets
  • Test results and coverage reports
  • Docker images (via registries)

Without artifacts:

Build Job: checkout β†’ install β†’ build (5 min)
Test Job: checkout β†’ install β†’ build (5 min) β†’ test (3 min)
Deploy Job: checkout β†’ install β†’ build (5 min) β†’ deploy (2 min)
Total: 15 minutes of redundant building

With artifacts:

Build Job: checkout β†’ install β†’ build (5 min) β†’ upload artifact
Test Job: download artifact (10 sec) β†’ test (3 min)
Deploy Job: download artifact (10 sec) β†’ deploy (2 min)
Total: Save 10 minutes

GitHub Actions example:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - run: npm run build

      # Upload build artifacts
      - uses: actions/upload-artifact@v3
        with:
          name: build-output
          path: dist/
          retention-days: 1  # Auto-delete after 1 day

  test:
    needs: build  # Wait for build to complete
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      # Download artifacts (fast - just downloads, no rebuild)
      - uses: actions/download-artifact@v3
        with:
          name: build-output
          path: dist/

      - run: npm test  # Test against built files

  deploy:
    needs: [build, test]  # Wait for both to complete
    runs-on: ubuntu-latest
    steps:
      # Only download what we need for deployment
      - uses: actions/download-artifact@v3
        with:
          name: build-output

      - run: ./deploy.sh  # Deploy the exact same build that was tested

GitLab CI:

build:
  stage: build
  script:
    - npm run build
  artifacts:
    paths:
      - dist/
    expire_in: 1 day

test:
  stage: test
  dependencies:
    - build
  script:
    - npm test  # Uses dist/ from build job

deploy:
  stage: deploy
  dependencies:
    - build
  script:
    - ./deploy.sh  # Uses dist/ from build job

5. Resource Optimization

Self-hosted runners (GitHub):

jobs:
  build:
    runs-on: self-hosted  # Faster, more resources
    steps:
      - run: npm run build

Larger runners (GitLab):

build:
  stage: build
  tags:
    - docker
    - large-cpu  # Custom runner tag
  script:
    - npm run build

Node labels (Jenkins):

pipeline {
    agent {
        label 'large-node'  // Use powerful node
    }

    stages {
        stage('Build') {
            steps {
                sh 'npm run build'
            }
        }
    }
}

Deployment Strategies

Choosing the right deployment strategy reduces risk and improves reliability. Different strategies offer different trade-offs between speed, safety, and resource usage.

1. Incremental Deployment

What is it?

Incremental deployment only deploys the components that actually changed. If you have a monorepo with multiple services, you don’t need to redeploy everything when only one service changes.

Benefits:

  • Faster deployments - Only deploy what changed
  • Lower risk - Fewer changes per deployment
  • Resource efficiency - Don’t waste CI/CD minutes on unchanged services
  • Parallel deploys - Changed services can deploy simultaneously

How it works:

  1. Detect which files/directories changed
  2. Only trigger deployments for affected services
  3. Skip deployment for unchanged services

Real-world example:

Changed files: frontend/src/App.js
Result: Deploy only frontend (skip backend, mobile, api)
Time saved: 70% (only 1 of 4 services deployed)

GitHub Actions implementation:

# .github/workflows/deploy.yml
jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      # Output which services changed
      frontend: ${{ steps.filter.outputs.frontend }}
      backend: ${{ steps.filter.outputs.backend }}
    steps:
      - uses: actions/checkout@v3

      # Detect changes in specific paths
      - uses: dorny/paths-filter@v2
        id: filter
        with:
          filters: |
            frontend:
              - 'frontend/**'
            backend:
              - 'backend/**'

  deploy-frontend:
    needs: detect-changes
    # Only run if frontend files changed
    if: needs.detect-changes.outputs.frontend == 'true'
    runs-on: ubuntu-latest
    steps:
      - run: ./deploy-frontend.sh

  deploy-backend:
    needs: detect-changes
    # Only run if backend files changed
    if: needs.detect-changes.outputs.backend == 'true'
    runs-on: ubuntu-latest
    steps:
      - run: ./deploy-backend.sh

# If only frontend changed: backend deployment skipped automatically

2. Blue-Green Deployment

What is it?

Blue-green deployment runs two identical production environments (“blue” and “green”). At any time, one serves live traffic while the other is idle. To deploy, you update the idle environment and switch traffic instantly.

How it works:

  1. Current state: Blue = live (serving users), Green = idle
  2. Deploy: Update green environment with new version
  3. Test: Run smoke tests on green
  4. Switch: Route all traffic from blue to green instantly
  5. Rollback: If issues, switch back to blue immediately

Benefits:

  • Zero downtime - Instant traffic switch
  • Easy rollback - Switch back if problems detected
  • Full testing - Test in production environment before going live
  • Safety - Old version stays running during deployment

Drawbacks:

  • Resource cost - Need 2Γ— infrastructure
  • Database challenges - Both versions must work with same DB schema

Visual:

Before:        Blue (v1.0) ← 100% traffic    Green (idle)
Deploy:        Blue (v1.0) ← 100% traffic    Green (v1.1) deploying...
Test:          Blue (v1.0) ← 100% traffic    Green (v1.1) testing...
Switch:        Blue (v1.0)                    Green (v1.1) ← 100% traffic βœ“
Cleanup:       Blue (deleted)                 Green (v1.1) ← 100% traffic

GitHub Actions implementation:

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      # Step 1: Deploy new version to green (idle) environment
      - name: Deploy to green environment
        run: |
          kubectl set image deployment/myapp \
            myapp=myapp:${{ github.sha }} \
            --namespace=green

      # Step 2: Wait for deployment to complete
      - name: Wait for rollout
        run: |
          kubectl rollout status deployment/myapp \
            --namespace=green \
            --timeout=5m

      # Step 3: Test green environment before switching traffic
      - name: Run smoke tests
        run: ./smoke-tests.sh https://green.example.com

      # Step 4: Switch all traffic from blue to green
      - name: Switch traffic to green
        run: |
          kubectl patch service myapp \
            -p '{"spec":{"selector":{"version":"green"}}}'

      # Step 5: Monitor for issues
      - name: Monitor for issues
        run: sleep 300  # Monitor for 5 minutes

      # Step 6: Clean up old blue environment
      - name: Cleanup blue environment
        if: success()
        run: kubectl delete deployment myapp --namespace=blue

# Rollback: Just switch service selector back to blue

3. Canary Deployment

What is it?

Canary deployment gradually rolls out changes to a small subset of users before deploying to everyone. If the canary (small group) shows problems, you can abort before affecting all users.

Name origin: Named after “canary in a coal mine” - miners used canaries to detect toxic gas. If the canary died, miners evacuated.

How it works:

  1. Deploy to 10% of servers/users
  2. Monitor metrics (errors, latency, user feedback)
  3. If healthy: Increase to 25%, then 50%, then 100%
  4. If problems: Rollback immediately, only 10% affected

Benefits:

  • Low risk - Limit blast radius to small percentage
  • Real user testing - Test with actual production traffic
  • Data-driven - Make decisions based on metrics
  • Gradual rollout - Increase confidence with each stage

Comparison to blue-green:

  • Blue-green: 0% β†’ 100% instantly
  • Canary: 0% β†’ 10% β†’ 25% β†’ 50% β†’ 100% gradually

Visual:

Stage 1:  10% new version  |  90% old version   Monitor...
Stage 2:  25% new version  |  75% old version   Monitor...
Stage 3:  50% new version  |  50% old version   Monitor...
Stage 4:  100% new version |  0% old version    Complete!

If problems detected at any stage: Immediate rollback to 100% old version

When to use:

  • High-traffic applications (enough data at 10% for metrics)
  • Risk-averse deployments
  • When you can segment traffic (by user, region, etc.)

GitHub Actions implementation:

jobs:
  deploy-canary:
    runs-on: ubuntu-latest
    steps:
      # Stage 1: Deploy to 10% of traffic
      - name: Deploy canary (10%)
        run: |
          kubectl set image deployment/myapp-canary \
            myapp=myapp:${{ github.sha }}

          kubectl scale deployment/myapp-canary --replicas=1   # 10%
          kubectl scale deployment/myapp-stable --replicas=9   # 90%

      # Monitor metrics before proceeding
      - name: Monitor metrics
        run: ./check-metrics.sh  # Checks error rate, latency, etc.

      # Stage 2: Increase to 50% if metrics good
      - name: Increase to 50%
        if: success()
        run: |
          kubectl scale deployment/myapp-canary --replicas=5   # 50%
          kubectl scale deployment/myapp-stable --replicas=5   # 50%

      # Monitor again
      - name: Monitor metrics
        run: ./check-metrics.sh

      # Stage 3: Full rollout if all metrics healthy
      - name: Full rollout
        if: success()
        run: |
          # Update stable deployment to new version
          kubectl set image deployment/myapp-stable \
            myapp=myapp:${{ github.sha }}

          kubectl scale deployment/myapp-stable --replicas=10  # 100%
          kubectl scale deployment/myapp-canary --replicas=0   # 0%

# If any stage fails: Pipeline stops, canary scaled to 0, stable stays at 100%

Automated metrics checking:

#!/bin/bash
# check-metrics.sh

# Get error rate from Prometheus
ERROR_RATE=$(curl -s 'http://prometheus/api/v1/query?query=error_rate' | jq '.data.result[0].value[1]')

# Fail if error rate > 1%
if (( $(echo "$ERROR_RATE > 0.01" | bc -l) )); then
  echo "Error rate too high: $ERROR_RATE"
  exit 1
fi

echo "Metrics healthy, proceeding..."
exit 0

4. Feature Flags

Decouple deployment from release:

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy with feature flag OFF
        run: |
          kubectl set image deployment/myapp myapp=myapp:${{ github.sha }}
          kubectl set env deployment/myapp \
            FEATURE_NEW_UI=false

      # Later, enable for subset of users
      - name: Enable for beta users
        run: |
          # Update feature flag service
          curl -X POST https://flags.example.com/api/flags/new-ui \
            -d '{"enabled": true, "rollout": 10}'

Complete Example Pipeline

Optimized GitHub Actions Workflow

name: Optimized CI/CD

on:
  push:
    branches: [main]
  pull_request:

env:
  NODE_VERSION: '18'

jobs:
  # Fast preliminary checks
  checks:
    runs-on: ubuntu-latest
    outputs:
      changed: ${{ steps.filter.outputs.src }}
    steps:
      - uses: actions/checkout@v3

      - uses: dorny/paths-filter@v2
        id: filter
        with:
          filters: |
            src:
              - 'src/**'
              - 'package*.json'

      - name: Lint
        if: steps.filter.outputs.src == 'true'
        uses: actions/setup-node@v3
        with:
          node-version: ${{ env.NODE_VERSION }}

      - uses: actions/cache@v3
        with:
          path: ~/.npm
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}

      - run: npm ci
      - run: npm run lint

  # Parallel builds
  build:
    needs: checks
    if: needs.checks.outputs.changed == 'true'
    runs-on: ubuntu-latest
    strategy:
      matrix:
        target: [frontend, backend, mobile]
    steps:
      - uses: actions/checkout@v3

      - uses: actions/setup-node@v3
        with:
          node-version: ${{ env.NODE_VERSION }}

      - uses: actions/cache@v3
        with:
          path: ~/.npm
          key: ${{ runner.os }}-node-${{ matrix.target }}-${{ hashFiles('**/package-lock.json') }}

      - run: |
          cd ${{ matrix.target }}
          npm ci
          npm run build

      - uses: actions/upload-artifact@v3
        with:
          name: ${{ matrix.target }}-build
          path: ${{ matrix.target }}/dist/

  # Parallel tests
  test:
    needs: build
    runs-on: ubuntu-latest
    strategy:
      fail-fast: true
      matrix:
        test-type: [unit, integration, e2e]
    steps:
      - uses: actions/checkout@v3

      - uses: actions/download-artifact@v3
        with:
          name: frontend-build
          path: frontend/dist/

      - uses: actions/setup-node@v3
        with:
          node-version: ${{ env.NODE_VERSION }}

      - uses: actions/cache@v3
        with:
          path: ~/.npm
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}

      - run: npm ci
      - run: npm run test:${{ matrix.test-type }}

  # Docker build with caching
  docker:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - uses: docker/setup-buildx-action@v2

      - uses: docker/login-action@v2
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/build-push-action@v4
        with:
          context: .
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:latest
            ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  # Deploy
  deploy:
    needs: docker
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v3

      - name: Deploy to Kubernetes
        run: |
          kubectl set image deployment/myapp \
            myapp=ghcr.io/${{ github.repository }}:${{ github.sha }}

      - name: Wait for rollout
        run: kubectl rollout status deployment/myapp

      - name: Smoke tests
        run: ./scripts/smoke-tests.sh

Monitoring Pipeline Performance

Metrics to Track

Key metrics:

  • Total pipeline duration
  • Time per stage
  • Cache hit rate
  • Parallel job efficiency
  • Deployment frequency
  • Lead time for changes
  • Change failure rate
  • Time to restore service

GitHub Actions insights:

# Available in GitHub UI
# Actions β†’ Workflow β†’ Insights

# Shows:
# - Average duration
# - Success rate
# - Job duration breakdown

GitLab CI analytics:

# Settings β†’ CI/CD β†’ Pipelines
# Shows:
# - Pipeline duration trends
# - Success rates
# - Job execution times

Optimization Checklist

Build optimization:

  • Caching dependencies
  • Caching build artifacts
  • Multi-stage Docker builds
  • BuildKit cache mounts
  • Parallel job execution
  • Skip unchanged paths
  • Fail fast on errors

Test optimization:

  • Parallel test execution
  • Test splitting/sharding
  • Run fast tests first
  • Skip tests for unchanged code
  • Cache test dependencies

Deployment optimization:

  • Incremental deployment
  • Blue-green deployment
  • Canary releases
  • Feature flags
  • Automated rollback

Conclusion

Optimizing CI/CD pipelines requires:

  1. Caching - Dependencies, builds, Docker layers
  2. Parallelization - Independent jobs run concurrently
  3. Smart execution - Skip unnecessary steps
  4. Efficient builds - Multi-stage Docker, BuildKit
  5. Fast feedback - Fail fast, run quick tests first
  6. Strategic deployment - Blue-green, canary, feature flags

Key takeaways:

  • Cache everything that can be cached
  • Run jobs in parallel when possible
  • Fail fast to save time
  • Use artifacts to avoid rebuilding
  • Monitor and optimize continuously
  • Deploy incrementally for safety

Well-optimized pipelines improve developer productivity, enable faster releases, and reduce infrastructure costs.

Before optimization:

  • Pipeline duration: 45 minutes
  • Deploy frequency: 2x per week
  • Developer waiting time: High

After optimization:

  • Pipeline duration: 8 minutes (82% faster)
  • Deploy frequency: 10x per day
  • Developer waiting time: Minimal

The investment in pipeline optimization pays dividends in team velocity and satisfaction.