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.
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 tomainand every pull request tomain.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, unlikenpm install).npm run typecheck— the first gate. A type error fails the job and blocks the PR.Build→npm run build— prerendersⓘ every page todist/and runscsp-hash.mjsas its last step (the reason we build here, not in Cloudflare's container).VITE_GA_IDcomes 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-actionuploadsdist/to thepangaea-idPages project.Notify IndexNow— same gate, pluscontinue-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 name —
github-actions-pages-deploy - Permission —
Account·Cloudflare Pages·Edit(this one, nothing else) - Account Resources —
Include· 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 · Editpermission - Scope it to your one account, and copy the value immediately
Don't
- Grant
DNS/Zone/Workers/SSL—pages deploydoesn'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 2CLOUDFLARE_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.
Common questions
How does merging a Pull Request actually ship the site?
Merging to main triggers the deploy pipeline: GitHub Actions builds the site (npm run build, then the mandatory csp-hash.mjs post-build step that fills the real CSP hash into _headers), uploads the result to Cloudflare Pages with wrangler, then runs a final IndexNow ping (npm run indexnow) so Bing/Yandex recrawl the changed pages within minutes. One push to main equals one live deploy, typically in about 30 seconds. (The API token and two repository secrets are wired in the section above.)
Why GitHub Actions instead of Cloudflare's "Connect to Git"?
Because native Git integration builds inside Cloudflare's own container and would skip the repo's csp-hash.mjs step, leaving the CSP hash wrong. It's also a one-way door — once a project is on Git integration you can't switch it back to direct upload. So the build stays in GitHub Actions, which runs the post-build step and then deploys.
Something shipped wrong — how do I undo it?
Roll back instantly in Pages → Deployments → Rollback. It re-points the live site to a previous successful deployment with no rebuild, so recovery is immediate.
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