Chapter 13 — CI/CD Pipelines

CI/CD (Continuous Integration / Continuous Deployment) automates the path from code commit to production. No more manual deployments, no more "I forgot to run the tests."

CI vs CD

TermWhat It DoesTriggered By
Continuous Integration (CI)Automatically build and test every commit/PREvery push or pull request
Continuous Delivery (CD)Automatically prepare releases (deploy to staging)Merge to main branch
Continuous Deployment (CD)Automatically deploy to productionAfter all checks pass
Developer pushes code │ ▼ ┌─────────────────────────── CI Pipeline ───────────────────────────┐ │ │ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐│ │ │ Lint │──▶│ Test │──▶│ Build │──▶│ Scan │──▶│Artifact││ │ │ │ │(unit+ │ │(compile│ │(security│ │(Docker ││ │ │ │ │integr.)│ │bundle) │ │ vulns) │ │ image) ││ │ └────────┘ └────────┘ └────────┘ └────────┘ └────────┘│ │ │ └────────────────────────────────┬───────────────────────────────────┘ │ All green ✓ ▼ ┌─────────────────────────── CD Pipeline ───────────────────────────┐ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │ │ Deploy │──▶│ Smoke │──▶│ Deploy │──▶│ Monitor │ │ │ │ Staging │ │ Tests │ │Production│ │ + Auto-rollback│ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────────┘ │ │ │ └────────────────────────────────────────────────────────────────────┘

GitHub Actions — Complete Workflow

# .github/workflows/deploy.yml
name: CI/CD Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  # ─── CI: Test & Build ───────────────────────────────────
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: test
          POSTGRES_PASSWORD: test
        ports: ['5432:5432']
        options: >-
          --health-cmd pg_isready
          --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
      - run: npm run lint
      - run: npm run test
        env:
          DATABASE_URL: postgres://postgres:test@localhost:5432/test

  # ─── Build & Push Docker Image ─────────────────────────
  build:
    needs: test
    runs-on: ubuntu-latest
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    permissions:
      contents: read
      packages: write
    outputs:
      image-tag: ${{ steps.meta.outputs.tags }}
    steps:
      - uses: actions/checkout@v4

      - 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=
            type=raw,value=latest

      - 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

  # ─── Deploy to Production ──────────────────────────────
  deploy:
    needs: build
    runs-on: ubuntu-latest
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    environment: production    # Requires approval if configured
    steps:
      - name: Deploy to server via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: deploy
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
            docker stop myapp || true
            docker rm myapp || true
            docker run -d \
              --name myapp \
              --restart unless-stopped \
              -p 3000:3000 \
              --env-file /etc/myapp.env \
              ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
            # Health check
            sleep 5
            curl -f http://localhost:3000/health || (docker logs myapp && exit 1)

Deployment Strategies

StrategyHow It WorksDowntimeRiskRollback Speed
RecreateStop old, start newYes (seconds-minutes)Low complexityRedeploy old version
RollingReplace instances one by oneNoMedium (mixed versions briefly)Continue rolling with old
Blue-GreenRun two identical environments, switch trafficNoLow (instant switch)Switch back instantly
CanaryRoute small % of traffic to new versionNoLowest (limited blast radius)Route 100% back to old
Blue-Green Deployment: Before: [Load Balancer] ──100%──▶ [Blue v1.0] (active) [Green v1.1] (idle, being tested) Switch: [Load Balancer] ──100%──▶ [Green v1.1] (now active) [Blue v1.0] (idle, rollback ready) Canary Deployment: Step 1: [Load Balancer] ──95%───▶ [v1.0] (stable) ──5%────▶ [v1.1] (canary) Step 2: [Load Balancer] ──50%───▶ [v1.0] ──50%───▶ [v1.1] (looking good) Step 3: [Load Balancer] ──100%──▶ [v1.1] (fully rolled out)

GitLab CI Example

# .gitlab-ci.yml
stages:
  - test
  - build
  - deploy

test:
  stage: test
  image: node:20
  services:
    - postgres:16
  variables:
    DATABASE_URL: postgres://postgres:test@postgres:5432/test
  script:
    - npm ci
    - npm run lint
    - npm run test

build:
  stage: build
  image: docker:24
  services:
    - docker:24-dind
  script:
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
  only:
    - main

deploy:
  stage: deploy
  script:
    - ssh deploy@$SERVER "docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA && docker-compose up -d"
  only:
    - main
  environment:
    name: production