CI/CD Pipeline Design Guide 2026 — From Commit to Production in Under 15 Minutes

Sanjeev SharmaSanjeev Sharma
6 min read

Advertisement

Introduction

Why This Matters

A well-designed CI/CD pipeline takes you from code commit to production in under 15 minutes with zero downtime. Teams with mature pipelines deploy multiple times per day while maintaining higher reliability than teams that deploy monthly. This guide builds a complete pipeline covering quality gates, Docker builds, staged deployments, blue-green releases, and rollback strategies.

Pipeline Architecture

Commit
  ├── Lint + Type Check (parallel)
  ├── Unit Tests (parallel)
  └── Security Scan (parallel)
         ↓  (all pass)
     Docker Build + Push

     Deploy to Staging

     E2E Tests on Staging
         ↓  (manual approval optional)
     Blue-Green Deploy to Production

     Smoke Tests

     Slack Notification

Complete Pipeline with GitHub Actions

# .github/workflows/pipeline.yml
name: Production Pipeline
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
 
env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
 
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true
 
jobs:
  quality:
    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
      - run: npm run lint
      - run: npm run typecheck
      - run: npm run format:check
 
  test:
    name: Tests
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_PASSWORD: test
          POSTGRES_DB: testdb
        options: >-
          --health-cmd pg_isready
          --health-interval 5s
          --health-timeout 3s
          --health-retries 5
        ports: ['5432:5432']
      redis:
        image: redis:7-alpine
        ports: ['6379:6379']
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - run: npm run db:migrate
        env:
          DATABASE_URL: postgresql://postgres:test@localhost:5432/testdb
      - run: npm run test:ci
        env:
          DATABASE_URL: postgresql://postgres:test@localhost:5432/testdb
          REDIS_URL: redis://localhost:6379
      - uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
 
  build:
    name: Build Docker Image
    runs-on: ubuntu-latest
    needs: [quality, test]
    outputs:
      image: ${{ steps.meta.outputs.tags }}
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=sha-
            type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
      - uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          platforms: linux/amd64,linux/arm64
 
  deploy-staging:
    name: Deploy to Staging
    runs-on: ubuntu-latest
    needs: build
    environment:
      name: staging
      url: https://staging.myapp.com
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1
      - name: Deploy to staging ECS
        run: |
          aws ecs update-service \
            --cluster staging \
            --service myapp-staging \
            --force-new-deployment
          aws ecs wait services-stable \
            --cluster staging \
            --services myapp-staging
 
  e2e:
    name: E2E Tests on Staging
    runs-on: ubuntu-latest
    needs: deploy-staging
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test
        env:
          BASE_URL: https://staging.myapp.com
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 7
 
  deploy-production:
    name: Deploy to Production
    runs-on: ubuntu-latest
    needs: e2e
    environment:
      name: production
      url: https://myapp.com
    steps:
      - uses: actions/checkout@v4
      - name: Blue-green deploy
        run: |
          kubectl apply -f k8s/production/
          kubectl rollout status deployment/myapp-production --timeout=5m

Blue-Green Deployment

# Switch traffic from blue (old) to green (new) with zero downtime
apiVersion: v1
kind: Service
metadata:
  name: myapp-production
spec:
  selector:
    app: myapp
    slot: blue   # Switch to 'green' to flip traffic
 
---
# Deploy green, verify, then flip the service
# kubectl apply -f k8s/deployment-green.yaml
# kubectl rollout status deployment/myapp-green
# kubectl patch service myapp-production \
#   -p '{"spec":{"selector":{"slot":"green"}}}'
#
# Rollback: flip back to blue
# kubectl patch service myapp-production \
#   -p '{"spec":{"selector":{"slot":"blue"}}}'

Canary Releases

# Send 10% of traffic to the new version with nginx-ingress
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: myapp-canary
  annotations:
    nginx.ingress.kubernetes.io/canary: "true"
    nginx.ingress.kubernetes.io/canary-weight: "10"
spec:
  rules:
    - host: myapp.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: myapp-canary-service
                port:
                  number: 80

Rollback Strategy

#!/bin/bash
# emergency-rollback.sh
set -euo pipefail
 
DEPLOYMENT="${1:-myapp-production}"
NAMESPACE="${2:-production}"
 
echo "Rolling back $DEPLOYMENT in $NAMESPACE..."
 
kubectl rollout undo "deployment/$DEPLOYMENT" -n "$NAMESPACE"
kubectl rollout status "deployment/$DEPLOYMENT" -n "$NAMESPACE" --timeout=3m
 
echo "Rollback complete. New active image:"
kubectl get deployment "$DEPLOYMENT" -n "$NAMESPACE" \
  -o jsonpath='{.spec.template.spec.containers[0].image}'

Deployment Notifications

  notify:
    name: Notify Team
    needs: [deploy-production]
    runs-on: ubuntu-latest
    if: always()
    steps:
      - name: Slack notification
        uses: slackapi/slack-github-action@v1
        with:
          channel-id: '#deployments'
          slack-message: |
            *Deployment ${{ needs.deploy-production.result == 'success' && 'Succeeded' || 'Failed' }}*
            Version: `${{ github.sha }}`
            Branch: `${{ github.ref_name }}`
            By: ${{ github.actor }}
        env:
          SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}

Common Mistakes

  • No concurrency groups — without them, multiple pushes to main trigger parallel deployments that race and corrupt state
  • Missing needs: between jobs — without it, deploy runs in parallel with tests and may deploy broken code
  • Single-environment pipelines — deploying directly to production without a staging environment removes your safety net
  • No rollback plan — every deployment needs a documented and tested rollback path before it is approved
  • Not caching Docker layers — without cache-from: type=gha, Docker rebuilds from scratch on every run

Best Practices

  • Run quality checks (lint, types) in parallel with tests to minimize total pipeline time
  • Use GitHub Environments with required reviewers for production deploys to enforce human approval
  • Always build the Docker image once and promote the same image through staging to production
  • Track DORA metrics (deployment frequency, lead time, failure rate, recovery time) to measure pipeline health
  • Set timeout-minutes on every job to prevent runaway pipelines from consuming CI minutes

Key Takeaways

  • Parallel jobs for lint, tests, and security scanning minimize total pipeline time without cutting corners
  • concurrency groups cancel in-progress runs on the same branch, preventing stale deploys from racing with newer ones
  • Build Docker images once with a git SHA tag and promote the same artifact from staging to production
  • Blue-green deployments achieve zero-downtime by running two identical environments and switching traffic at the load balancer
  • Canary releases let you validate a new version on 5-10% of traffic before committing to a full rollout
  • DORA metrics are the industry standard for measuring pipeline maturity: deployment frequency, lead time, change failure rate, and MTTR
  • GitHub Environments with protection rules enforce approval gates before sensitive deploys execute
  • Emergency rollback with kubectl rollout undo reverts to the previous ReplicaSet in under 30 seconds

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading