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
Term What It Does Triggered By
Continuous Integration (CI) Automatically build and test every commit/PR Every push or pull request
Continuous Delivery (CD) Automatically prepare releases (deploy to staging) Merge to main branch
Continuous Deployment (CD) Automatically deploy to production After 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
Strategy How It Works Downtime Risk Rollback Speed
Recreate Stop old, start new Yes (seconds-minutes) Low complexity Redeploy old version
Rolling Replace instances one by one No Medium (mixed versions briefly) Continue rolling with old
Blue-Green Run two identical environments, switch traffic No Low (instant switch) Switch back instantly
Canary Route small % of traffic to new version No Lowest (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
← Chapter 12 — Reverse Proxies & LB
Next: Chapter 14 — Infrastructure as Code →