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 yournode_modulesbetween runs. First run: 60 s. Subsequent runs: 15 s. Free speed.npm ciinstead ofnpm 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:
- No direct push to main — everything goes through a PR.
- The PR cannot be merged until the CI job named "Test & Build" reports green.
- 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:
- Create the file
.github/workflows/ci-cd.ymlin your repo using the workflow above as your base. - Update the test and lint commands to match your
package.jsonscripts (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. - Push a branch and open a PR. Watch the Actions tab — you should see your workflow running.
- Intentionally break a test or add a lint error, push again, and confirm the check goes red and the merge button is blocked.
- Fix the error, push again, confirm the check goes green.
- 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.
- 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: ciand theif: 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. 🏛️