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.
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