Notes from production
CI/CDGitHub ActionsCloudflare

CI/CD: GitHub Actions → Cloudflare Pages

Your code lives in a private GitHub repo (harryosmar/pangaea.id), and every change flows through a Pull Request. This is the part that turns "merged a PR" into "the live site updated" — the CI/CD wiring, end to end, with every command you'll actually run.

One feature = one Pull Request

Nothing ships straight to main. A change starts on a branch, opens a PR, and only merges once the checks are green. That green merge is what publishes the site.

feature branchfeature/<name>open a Pull Requestgh pr createCI gate — build + typecheck+ a PR preview · merge only when greensquash-merge → mainone feature = one commit on maindeploy → Pages (live)
One feature = one Pull Request. Every PR builds and typechecks; only a green merge to main publishes the live site.

Two ways to connect Pages

There are two ways to get a Cloudflare Pages deploy. Both work; we use the second, and the reason matters.

The easy way — native Git integration

The way we use — GitHub Actions

The repo already contains .github/workflows/deploy.yml: it runs build + typecheck on every PR, and deploys to Pages only after you add two repository secrets. Until then it stays green but dormant — so you can adopt native integration now and switch later.

The workflow file, explained

The whole pipeline is one file the repo already ships — .github/workflows/deploy.yml. Here it is, lightly trimmed:

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

permissions:
  contents: read              # least-privilege: the job only reads the repo

jobs:
  build-deploy:
    runs-on: ubuntu-latest
    env:
      CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run typecheck
      - name: Build
        run: npm run build      # prerender → dist/ + csp-hash.mjs (its last step)
        env:
          VITE_GA_ID: ${{ vars.VITE_GA_ID }}

      # Deploy ONLY on a push to main, and only once the token secret exists.
      - name: Deploy to Cloudflare Pages
        if: ${{ github.event_name == 'push' && env.CF_API_TOKEN != '' }}
        uses: cloudflare/wrangler-action@v4
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          command: pages deploy apps/labs/dist --project-name=pangaea-id --branch=main

      # After a real deploy, ping IndexNow so Bing/Yandex recrawl within minutes.
      - name: Notify IndexNow (Bing/Yandex)
        if: ${{ github.event_name == 'push' && env.CF_API_TOKEN != '' }}
        continue-on-error: true
        run: npm run indexnow -w @pangaea/labs

Step by step:

  • on: — the job runs on every push to main and every pull request to main.
  • permissions: contents: read — the job's GitHub token can only read the repo; nothing more.
  • env: CF_API_TOKEN — surfaces the secret once so the deploy steps can test whether it's set.
  • checkout + setup-node — clone the repo and install Node 20 with the npm cache warmed.
  • npm ci — a clean, lockfile-exact install (reproducible, unlike npm install).
  • npm run typecheck — the first gate. A type error fails the job and blocks the PR.
  • Buildnpm run build — prerenders every page to dist/ and runs csp-hash.mjs as its last step (the reason we build here, not in Cloudflare's container). VITE_GA_ID comes from a GitHub Actions Variable (public by design; unset = GA4 stays a no-op).
  • Deploy … — the gate that matters: if: github.event_name == 'push' && env.CF_API_TOKEN != ''. So a PR builds + typechecks but never deploys, and before the secrets exist the step simply skips (the job stays green). When it does run, wrangler-action uploads dist/ to the pangaea-id Pages project.
  • Notify IndexNow — same gate, plus continue-on-error: true. After a real deploy it pings Bing/Yandex so new or changed pages recrawl in minutes; a transient failure never red-Xes a good deploy.

Wire the deploy: token → secrets → Actions → Pages → domains

This is the path the site actually uses. GitHub Actions builds (so csp-hash.mjs always runs) and uploads to Pages with wrangler. One push to main = one live deploy.

Step 1 — Create the Pages project (once)

wrangler pages deploy does not auto-create the project — it errors Project not found [8000007]. Create it once first. Either in the dashboard (Workers & Pages → Create → Pages → Use direct upload, not "Connect to Git" → name it exactly pangaea-id, which must match --project-name in the workflow → Create), or from the CLI:

npx wrangler login   # opens a browser OAuth, one time
npx wrangler pages project create pangaea-id --production-branch=main

Step 2 — Make a least-privilege API token

Profile → API Tokens → Create Token → Create Custom Token, with exactly:

  • Token namegithub-actions-pages-deploy
  • PermissionAccount · Cloudflare Pages · Edit (this one, nothing else)
  • Account ResourcesInclude · your account

Then Continue → Create, and copy the token now — Cloudflare shows it only once.

Do

  • Build a Custom Token with the single Cloudflare Pages · Edit permission
  • Scope it to your one account, and copy the value immediately

Don't

  • Grant DNS / Zone / Workers / SSLpages deploy doesn't need them, and a narrow token limits the damage if it ever leaks
  • Use a ready-made template (e.g. "Edit Cloudflare Workers" is the wrong, broader one)

Step 3 — Grab your Account ID

Workers & Pages → right sidebar → Account ID (a 32-character hex string).

Step 4 — Add the two GitHub secrets

Repo → Settings → Secrets and variables → Actions → the Secrets tab (not Variables) → New repository secret. The names must match exactly:

  • CLOUDFLARE_API_TOKEN — the token from Step 2
  • CLOUDFLARE_ACCOUNT_ID — the ID from Step 3

Step 5 — Deploy

Push or merge to main. Watch GitHub → Actions → Deploy: the "Deploy … to Cloudflare Pages" step flips from skipped to success, and pangaea-id.pages.dev goes live. A final "Notify IndexNow" step then pings Bing/Yandex so new or changed pages recrawl within minutes — best-effort (continue-on-error), and it runs only after a real deploy.

Step 6 — Point the domains at it

In the Pages project → Custom domains → Set up a domain, add www.pangaea.id and pangaea.id (this swaps the parking record for the correct proxied one). Then add the apex → www 301 redirect — see Part 3 · Root → www.

Cheatsheet

The whole pipeline in one line:

merge a PR → Actions builds (npm run build → csp-hash) → wrangler pages deploy → npm run indexnow → live on www.pangaea.id in ~30s

Roll back anytime in Pages → Deployments → Rollback (instant, no rebuild). Verify the wiring, read-only:

gh run list --branch main --limit 1      # newest "Deploy" run should read: success
curl -sI https://pangaea-id.pages.dev/   # 200 once the project has a deployment

Troubleshooting: the first deploy says "project not found"

npx wrangler login   # if not already logged in
npx wrangler pages project create pangaea-id --production-branch=main
npx wrangler pages deploy apps/labs/dist --project-name=pangaea-id

Not sure whether it already exists? List your projects first — if pangaea-id is there, skip the create step and go straight to deploy (or a push to main):

npx wrangler pages project list

Don't

  • Keep re-running the deploy hoping it succeeds. "Not found" means the project genuinely isn't there yet — create it once, then deploy.

Next

This pipeline ships the static site that Part 1 pointed the domain at. The plain-English story of why Cloudflare Pages over a rented VPS is in our build diary, Ship it — Git → CI/CD → Pages; the DNS handoff that came first is Point the domain at Cloudflare (DNS).

Sources

  1. cloudflare/wrangler-action — deploy from GitHub Actions
  2. Cloudflare Pages — Git integration vs Direct Upload
  3. Cloudflare — API token permissions (Pages: Edit)