Skip to main content
DevOps, Deployment & Operations
🚀 DevOps & OpsLesson 4 of 13

CI/CD Pipelines

Automate test-and-deploy so shipping is boring, safe, and frequent.

CI/CD Pipelines

Stage 3 · DevOps, Deployment & Operations · B.U.I.L.D. letter: D

You built something that works on your machine. Now make every future change provably safe before it ever touches production — automatically, every single push.


⚠️ The vibe trap

Vibe coding gets you to a working app faster than almost any workflow in history — that's real, and it's worth celebrating. But "push to main and refresh Vercel" is a deployment strategy that works right up until it doesn't. Without an automated gate, the first time a refactor quietly breaks your auth flow or a dependency update scrambles your build, you find out when a user tells you. CI/CD is the engineering layer that catches broken code before it ships, every time, without you thinking about it.


🔄 CI vs CD — Two Halves of the Same Pipeline

Continuous Integration (CI) means every push to the repo triggers an automated run of your tests, linter, and build. The goal is simple: know immediately if something broke.

Continuous Delivery / Deployment (CD) means when CI passes on your main branch, the app automatically deploys to production. No manual "okay ship it" step.

Developer pushes code
        │
        ▼
┌───────────────────────────────────────────────┐
│  CI — runs on EVERY branch, EVERY push        │
│                                               │
│  1. Install dependencies                      │
│  2. Lint (catch style + obvious errors)       │
│  3. Run tests (catch logic errors)            │
│  4. Build (catch type errors, bundler errors) │
│                                               │
│  ❌ Any step fails → pipeline is RED          │
│     → PR cannot be merged (branch protection) │
│     → team is notified                        │
└───────────────────────────────────────────────┘
        │
        │  (only if CI is GREEN and branch = main)
        ▼
┌───────────────────────────────────────────────┐
│  CD — runs only on main after green CI        │
│                                               │
│  5. Deploy to production                      │
│                                               │
│  ✅ Users see the new version                  │
└───────────────────────────────────────────────┘

Mental model: CI is the quality gate. CD is the conveyor belt. The gate only opens when everything passes.

Why it matters: Without CI, broken code merges silently. Without CD, deployments require a human who can forget steps, skip them under pressure, or deploy at the wrong time.

Common mistake: Treating CI and CD as optional extras you add "later." Later never comes. Wire them up on day one when the pipeline is tiny and easy — not after you have 40 PRs in flight.


🚦 GitHub Actions — The Engine

GitHub Actions is a CI/CD platform built directly into GitHub. You define workflows as YAML files in .github/workflows/. When you push code, GitHub spins up a fresh virtual machine and runs your workflow.

Here is a complete, real workflow that installs, lints, tests, builds, and deploys a Node.js / Next.js app to Vercel. Every line is explained below it.

# .github/workflows/ci-cd.yml

name: CI / CD                          # Display name in GitHub's Actions tab

on:
  push:
    branches: ["**"]                   # Run CI on every branch, every push
  pull_request:
    branches: [main]                   # Also run CI when a PR targets main

jobs:
  # ─────────────────────────────────────────────
  # JOB 1: Continuous Integration
  # ─────────────────────────────────────────────
  ci:
    name: Test & Build
    runs-on: ubuntu-latest             # Fresh Ubuntu VM for every run

    steps:
      - name: Checkout code
        uses: actions/checkout@v4      # Pull your repo onto the VM

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"                 # Cache node_modules between runs (speed)

      - name: Install dependencies
        run: npm ci                    # 'ci' is stricter than 'install' — uses lockfile exactly

      - name: Lint
        run: npm run lint              # Fails the job if lint errors exist

      - name: Run tests
        run: npm test -- --ci          # --ci flag disables watch mode, fails on any failure

      - name: Build
        run: npm run build             # Catches TypeScript errors, missing imports, etc.
        env:
          # Public env vars only — safe to expose here
          NEXT_PUBLIC_APP_URL: ${{ vars.NEXT_PUBLIC_APP_URL }}

  # ─────────────────────────────────────────────
  # JOB 2: Continuous Deployment (main only)
  # ─────────────────────────────────────────────
  deploy:
    name: Deploy to Production
    runs-on: ubuntu-latest
    needs: ci                          # Only starts if the 'ci' job passed
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    # ↑ Only deploy when a push lands on main (not on PRs, not on other branches)

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Deploy to Vercel
        run: npx vercel deploy --prod --yes
        env:
          VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}      # From GitHub repo secrets
          VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
          VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}

Line-by-line highlights:

  • on: push: branches: ["**"] — CI fires on every branch so developers get feedback before they even open a PR.
  • cache: "npm" — GitHub caches your node_modules between runs. First run: 60 s. Subsequent runs: 15 s. Free speed.
  • npm ci instead of npm install — installs exactly what is in your lockfile, no silent upgrades, reproducible every time.
  • needs: ci — the deploy job literally cannot start until the ci job succeeds. This is the gate.
  • if: github.ref == 'refs/heads/main' — production deploys only happen from main. Feature branches test, they never ship.
  • ${{ secrets.VERCEL_TOKEN }} — secrets are injected as environment variables at runtime and are never stored in logs or code.

Mental model: The workflow file is a recipe. GitHub is the kitchen. Every push sends a fresh order: start from zero, follow the recipe, report pass or fail.

Common mistake: Putting needs: ci on the deploy job but forgetting the if: condition. Without it, every branch that passes CI will attempt a production deploy. Always pair them.


🔐 Secrets in CI

Never hardcode API keys, database URLs, or tokens in your workflow file. GitHub provides an encrypted secrets store that injects values as environment variables at runtime.

Where to add secrets:
  GitHub repo → Settings → Secrets and variables → Actions → New repository secret

Secret name        What it holds
──────────────     ──────────────────────────────────────────────
VERCEL_TOKEN       Your Vercel personal access token
VERCEL_ORG_ID      Your Vercel team/org ID (from vercel.json or Vercel dashboard)
VERCEL_PROJECT_ID  The specific project ID
DATABASE_URL       Your production Postgres connection string (if needed at build time)

Reference them in your workflow as ${{ secrets.SECRET_NAME }}. They are masked in all logs — if a step accidentally prints them, GitHub replaces the value with ***.

Mental model: Secrets are environment variables that live in GitHub's vault, not in your repo. They are injected at runtime and never touch your git history.

Why it matters: A leaked API key in a public repo is an incident. Using secrets means your workflow file can be fully public with zero risk.

Common mistake: Adding a secret to the environment at the job level (env: under jobs.deploy) but forgetting to pass it to the specific step that needs it. Add env vars at the lowest scope that needs them — usually the individual run: step.


🛡️ Branch Protection — The Enforcement Layer

Writing a CI workflow is half the work. Enforcing it is the other half. Without branch protection, a developer can merge a red PR anyway. Enable it once per repo.

GitHub repo → Settings → Branches → Add branch protection rule
  Branch name pattern:  main

  ✅ Require a pull request before merging
  ✅ Require status checks to pass before merging
       → Search for and add: "Test & Build"   ← this must match your job's 'name:' field
  ✅ Require branches to be up to date before merging
  ✅ Do not allow bypassing the above settings

After this is enabled:

  1. No direct push to main — everything goes through a PR.
  2. The PR cannot be merged until the CI job named "Test & Build" reports green.
  3. Even repo admins cannot bypass it (if you check the last box).

Mental model: CI catches the failure. Branch protection enforces that a failed CI cannot be ignored. Together they create a hard gate, not a soft suggestion.

Common mistake: The required status check name must match the name: field of your workflow job exactly, including capitalization and spaces. If it says Test & Build in the workflow but you type test and build in the branch rule, GitHub won't recognize them as the same check and the gate won't work.


🛠️ Your Mission

Add a CI workflow to your existing app that runs on every push, catches failures automatically, and blocks merging a broken PR.

Steps:

  1. Create the file .github/workflows/ci-cd.yml in your repo using the workflow above as your base.
  2. Update the test and lint commands to match your package.json scripts (npm test, npm run lint, etc.). If you don't have tests yet, add one trivial test so the step exists — an empty test suite that passes is better than no test step at all.
  3. Push a branch and open a PR. Watch the Actions tab — you should see your workflow running.
  4. Intentionally break a test or add a lint error, push again, and confirm the check goes red and the merge button is blocked.
  5. Fix the error, push again, confirm the check goes green.
  6. Add your Vercel secrets to the repo's secret store and enable the deploy job. Merge a passing PR to main and watch it deploy automatically.
  7. Enable branch protection on main as described above.

✅ You're done when…

  • Your GitHub Actions workflow (ci-cd.yml) appears in the Actions tab and runs on every push
  • A commit with a failing test shows a red check on the PR and blocks the merge button
  • A commit with a passing test and clean lint shows a green check
  • Your Production-Readiness Checklist includes: CI pipeline configured, branch protection enabled, secrets in GitHub vault (not in code), deploy only from main after green
  • Merging a passing PR to main triggers an automatic Vercel production deploy without any manual step
  • You can explain to a teammate why needs: ci and the if: github.ref == 'refs/heads/main' condition must both be present on the deploy job

➡️ Next: Containers & Docker. Build It Right, Or Don't Build It At All. 🏛️

Always-on rigor toolkit

🏛️ Build It Right, Or Don't Build It At All.