CI/CD Pipeline Design: From Zero to Production Deployment


Disclosure: This article may contain affiliate links. We only recommend products we believe in. See how we make money.

CI is “every change is tested before it is anyone else’s problem.” CD is “a green build can be shipped without a ritual.” The rest is taste: how fast the feedback is, and whether a human still has to press the last button.

This page is a pipeline you can paste and then tighten. It assumes a Node app and GitHub Actions because that is what most of the tutorials on this site build toward — including this site’s own host, Cloudflare Pages. The git workflow is the branch policy that sits underneath. An AI review job can be one more check; it is not a substitute for tests.

Desk note — who this is for / what it’s bad at: Teams shipping a Node or static app from GitHub with tests that actually run. Bad as a reason to buy a second CI product when Actions plus the host’s preview URLs are unread, and a poor substitute for a rollback plan.

What to check before you buy a runner (or click deploy)

Listing identity: GitHub Actions is the YAML in this article — lint, test, build, then deploy from main. Vercel / Netlify / Cloudflare Pages git-push builds are host builds; they are not your unit suite. CircleCI / Buildkite / self-hosted runner farms are extra CI products. A host build is not a test suite.

Before you add a paid runner or connect a second CI:

  • Fail cheap first. Lint and typecheck before Playwright. A pipeline people disable is not CD.
  • Skip a second CI invoice if Actions already runs npm ci, tests, and build, and the host already gives per-PR preview URLs. Another vendor does not write the tests you skipped. The AI unit-testing page is what those tests should assert.
  • Do not treat the host build as CI. Pages or Vercel running astro build will not run AI review or your unit tests. Use the host for previews — see web hosting — and Actions for the red X.
  • Do not buy a flagship card so CI can run a local model. A 4090 search is unused VRAM on a GitHub-hosted runner (that runner does not have your card). Local inference for private diffs is a self-hosted runner plus the local LLM page, or skip the bot. Cursor’s HTTPS hop is the tunnel setup, not this YAML.
  • Skip auto-deploy to production if you cannot roll back. That is continuous delivery: staging is automatic, production is a person. Retrieval and index rebuilds belong in this pipeline — see vector databases — not in a hope. An agent with a deploy tool is a write you have not earned yet.

Practical cadence: lint → test → build on every PR; preview URL from the host; production only from main after those checks. More articles live on the blog index. Amazon search links on this page use tcalnet-20; see how we make money.

What the words mean

Continuous integration. Every push and every pull request runs automated checks. A red X is a reject, not a suggestion.

Continuous delivery. main stays deployable. Staging updates itself. Production is one approval (or one click) away.

Continuous deployment. The same green build goes to production with no approval step. Only do this if the tests actually protect you and you can roll back.

If you cannot roll back, you do not have CD. You have a script that hopes.

Fail cheap, then fail real

Order the work by cost:

  1. Lint / format / typecheck — seconds, catches the boring mistakes.
  2. Unit tests — still fast if you did not couple them to the network.
  3. Build — the artifact you will actually ship (npm run build, not “it works on my laptop”).
  4. Integration / e2e — against a preview URL or an ephemeral database, not against production.

A pipeline that runs Playwright before ESLint is a pipeline people will disable. The AI unit-testing notes are about what those tests should assert; this page is about when they run.

A pipeline that matches a static or edge host

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

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npm run test
      - run: npm run build

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci && npm run build
      - name: Deploy to Cloudflare Pages
        run: npx wrangler pages deploy dist/ --project-name="$PROJECT"
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          PROJECT: ${{ vars.CF_PAGES_PROJECT }}

Notes that are easy to skip:

  • npm ci, not npm install. ci installs the lockfile. install may rewrite it. Reproducible builds are a security feature as much as a convenience — see AI-generated dependency risk.
  • concurrency. A newer push to the same branch cancels the stale run. Otherwise you can deploy an older SHA after a newer one.
  • environment: production. GitHub Environments give you a separate secret scope and an optional required reviewer. That is how you get continuous delivery (human gate) without forking the workflow.
  • Same Node version as production. This site’s package.json asks for Node >=22.12.0. Pinning 22 in Actions is the minimum honest move.

Vercel, Netlify, and Cloudflare Pages will also build on git push if you connect the repo in their UI. That is fine. You still want this workflow for tests, because a host build that only runs astro build will not run your unit tests. Use the host for preview URLs; use Actions for the red X.

Preview deploys vs production

EventWhat should happen
Pull requestlint, test, build; optional preview URL
Push to mainall of the above, then production
Tag / releaseoptional extra job (npm publish, changelog)

Do not deploy PR branches to the production project. A preview hostname per PR is how designers and QA look at the change without touching customers. Playwright against that preview is the first e2e that is worth the minutes.

Secrets, logs, and the things that page you

  • Secrets live in Actions secrets or the host’s dashboard. A token in YAML is a leaked token.
  • echo of env vars in logs is how tokens die in public forks. Prefer ::add-mask::.
  • Fork PRs from public repos should not get your deploy token. Restrict the deploy job to github.event_name == 'push' on main, as above.
  • A red deploy with a green test job is still a red pipeline. Do not continue-on-error the production deploy to keep a badge green.

If you add an AI review step, give it pull-requests: write and its own API key. Do not reuse the Cloudflare token.

When not to automate the last mile

  • You have no rollback (no previous Pages deployment, no image tag, no database backward-compat).
  • The release needs a migration you have not tested against a copy of production data.
  • The only test is “the author clicked around.”
  • You ship native binaries to customers who cannot take a broken update.

In those cases, stop at continuous delivery: staging is automatic, production is a person who can see the diff.