Skip to Content
Living documentation — last reviewed 2026-05-28
RunbooksDocs site deploy (Cloudflare Pages + Access)

Docs site deploy (Cloudflare Pages + Access)

The docs site at apps/docs/ (Nextra v4) deploys to Cloudflare Pages and is gated behind Cloudflare Access. This runbook reflects the live setup as configured on 2026-05-29.

Live configuration

ThingValue
Cloudflare accountTaikan (74e1f4e430cf29df86bd66ff89e4d1c0)
Pages projecttaikan-docs
Canonical URLhttps://docs.usetaikan.com
Pages URLhttps://taikan-docs.pages.dev
Preview URLshttps://<deploy-hash>.taikan-docs.pages.dev
Zero Trust team domaindesmotech.cloudflareaccess.com
Access applicationdocs (self-hosted)
Access policyAllow — saarku@gmail.com only
Login methodOne-time PIN (email)

All three hostnames (docs.usetaikan.com, taikan-docs.pages.dev, *.taikan-docs.pages.dev) are covered by the single docs Access application, so every entry point — production, the raw Pages URL, and per-deploy previews — is gated.

What ships where

SurfaceURLAuth
Local devhttp://localhost:3030none
Productionhttps://docs.usetaikan.comCloudflare Access
Source/docs/ in this repo (canonical markdown)repo permissions

Pipeline

.github/workflows/deploy-docs.yml triggers on main pushes that touch docs/**, apps/docs/**, or the workflow itself.

push to main with docs change build apps/docs (pnpm install + pnpm build → out/) cloudflare/wrangler-action@v3 → `wrangler pages deploy out --project-name=taikan-docs --branch=main` Cloudflare Pages serves out/ from taikan-docs.pages.dev + docs.usetaikan.com Cloudflare Access gates the request (one-time PIN to policy-listed emails)

Builds run on every matching push. Concurrency group docs-deploy prevents overlapping runs.

The build emits ~1,800 files / ~190 MB — Nextra v4 inlines the sidebar page-map into every page and ships per-page RSC .txt segments, so the output is heavier than a typical static site. This is well within Cloudflare Pages limits (25 MiB/file, 20,000 files) but is why the dashboard’s drag-and-drop uploader (1,000-file cap) can’t be used — deploys go through wrangler.

One-time setup (already done — kept for rebuild / DR)

1. Pages project

Created via wrangler rather than the dashboard, because the static export exceeds the dashboard uploader’s 1,000-file limit:

pnpm dlx wrangler login # interactive OAuth (local only) pnpm -C apps/docs build # produces apps/docs/out/ pnpm dlx wrangler pages deploy apps/docs/out \ --project-name=taikan-docs --branch=main # creates the project if absent

The first pages deploy creates the taikan-docs project automatically.

2. CI credentials (GitHub repo secrets)

The local wrangler login only authenticates your machine. CI can’t do interactive OAuth, so the workflow uses an API token instead.

  1. Cloudflare dashboard → My ProfileAPI TokensCreate TokenCustom token.
    • Permission: Account → Cloudflare Pages → Edit, scoped to the Taikan account.
  2. Account ID: dashboard right sidebar (74e1f4e430cf29df86bd66ff89e4d1c0).
  3. Repo → Settings → Secrets and variables → Actions → add:
NameValue
CLOUDFLARE_API_TOKENthe custom token
CLOUDFLARE_ACCOUNT_ID74e1f4e430cf29df86bd66ff89e4d1c0

3. Custom domain (docs.usetaikan.com)

Gotcha discovered during setup: adding a custom domain to a Pages project does not auto-create the DNS record, even when the zone is on the same account. You must do both:

  1. Attach the domain to the project: POST /accounts/{account}/pages/projects/taikan-docs/domains with { "name": "docs.usetaikan.com" }.
  2. Create the proxied CNAME in the usetaikan.com zone: docs.usetaikan.com CNAME taikan-docs.pages.devproxied (orange cloud), TTL auto.

Without step 2 the domain sits in initializing/pending forever with a misleading “CNAME record not set” message. Once the proxied CNAME exists, Cloudflare validates over HTTP and provisions a Google-issued cert within a few minutes.

4. Cloudflare Access

Zero Trust dashboard → Access → Applications → Add → Self-hosted.

  • Application name: docs
  • Session duration: 24h
  • auto_redirect_to_identity: on (skips the email-entry screen when there’s a single login method)
  • Self-hosted domains — add all three so no entry point is left open:
    • docs.usetaikan.com
    • taikan-docs.pages.dev
    • *.taikan-docs.pages.dev ← covers preview deploys

Then add a policy:

FieldValue
ActionAllow
IncludeEmails → saarku@gmail.com (extend as needed)

How Cloudflare Access OTP actually behaves

Understanding this avoids false alarms:

  • The email-entry + “send code” screen never blocks anyone. That step is authentication (proving you control an inbox), not authorization. Anyone can type any email there.
  • A one-time PIN is only emailed to addresses that match a policy. Random / test emails that aren’t in the allow list receive nothing — Cloudflare won’t relay codes to arbitrary inboxes. So “I entered a test email and no code arrived” is correct, expected behavior, not a bug.
  • The policy is enforced after the code is verified. Even a non-listed email that somehow had a valid code would hit a hard “you don’t have access” wall.

Net: the only way in is an email explicitly listed in the policy. To let more people in, add their emails (or a whole email_domain) to the policy.

Day-to-day

Triggering a redeploy

Push to main with a change under docs/** or apps/docs/**, or run Actions → Deploy docs to Cloudflare Pages → Run workflow. Cloudflare retains recent deployments — roll back from the Pages project dashboard if a deploy goes bad.

Granting / revoking access

Zero Trust → Access → Applications → docs → Policies. Add or remove emails / domains. Changes apply within seconds; no redeploy needed.

To allow an entire domain, the policy include becomes:

include: [{ email_domain: { domain: "desmotech.com" } }]

Adding a new doc

Edit any file under /docs/ (canonical source) and push to main. The workflow rebuilds and redeploys. Don’t edit apps/docs/content/ — it’s a regenerated mirror (see apps/docs/scripts/sync-content.mjs).

Manual deploy from your machine

If CI is down and you need to ship:

pnpm -C apps/docs build pnpm dlx wrangler pages deploy apps/docs/out --project-name=taikan-docs --branch=main

Requires a prior wrangler login.

Why this setup, not GitHub Pages

  • Auth. GitHub Pages on a regular plan is public-only. Cloudflare Access gates by email/SSO without a GitHub Enterprise Cloud subscription.
  • Custom domain under the same gate. docs.usetaikan.com and the .pages.dev URLs all sit behind one Access app.
  • Faster invalidation. Cloudflare’s CDN updates within ~30s of a deploy.

Failure modes

SymptomCauseFix
Login page shows an unexpected team domain (e.g. fragrant-rain-…cloudflareaccess.com)Stale browser redirect / cached Access session from earlier setupRetry in a fresh incognito window; the live team domain is desmotech.cloudflareaccess.com.
Test email “sends a code” but it never arrivesWorking as designed — OTP only goes to policy-listed emailsUse a listed email, or add the address to the policy.
Custom domain stuck in pending / “CNAME record not set”Proxied CNAME never created (Pages doesn’t auto-create it)Add docs.usetaikan.com CNAME taikan-docs.pages.dev, proxied, in the usetaikan.com zone.
.pages.dev URL serves docs without a login promptHostname not in the Access app’s self-hosted domainsAdd taikan-docs.pages.dev + *.taikan-docs.pages.dev to the docs app.
Workflow fails at the deploy step with an auth errorCLOUDFLARE_API_TOKEN missing / expired / wrong scopeRe-mint with Pages → Edit; resave the repo secret.
Dashboard “Upload files” rejects the buildExport exceeds the 1,000-file dashboard capDeploy via wrangler (20,000-file limit), not the dashboard uploader.
Workflow succeeds but URL shows old contentCDN cachePages busts cache per deploy; if stuck, Purge Everything on the zone.
Nextra build hangs on Pagefind indexingRunner memory capTemporarily set search: true (drop codeblocks) in apps/docs/next.config.mjs.