Cloudflare Web Setup
This is an operational runbook. A trusted operator executes it by hand, once, to bring the Cloudflare account, DNS zone, both Pages projects, and Email Routing into the state ADR 0015 requires and the GCP, WorkOS, and Supabase Exit plan assumes for app.feelproclub.com and app.dev.feelproclub.com. Nothing here is a Terraform apply: the Cloudflare account, zone, Pages projects, custom domains, and Email Routing rules are all configured through the Cloudflare Dashboard.
Prerequisites
- A Cloudflare account with permission to add sites, create Pages projects, and configure Email Routing.
- Access to the GoDaddy account that registers
feelproclub.com, to change its nameservers. - Access to the current DNS records at Vercel, where the domain's DNS was hosted before this migration — not at GoDaddy, which is the registrar only.
- Permission to add environment variables and a custom domain on each Cloudflare Pages project, and to add or change
allowed_originsininfra/environments/dev/main.tfand the WorkOS development redirect allowlist if a preview deployment must call the development API (step 7).
1. Create the Cloudflare account and add the zone
A Cloudflare account is the billing and membership container; a domain added to it is a site with its own zone (the set of DNS records and settings for that domain). Moving a zone between accounts afterward is difficult, but changing who has membership on an account is easy. For that reason:
- Create or select the Cloudflare account intended to own the product long-term, not a personal or throwaway account.
- Add
feelproclub.comas a site in that account. Cloudflare offers to scan for existing DNS records — let it, but do not trust the scan alone (step 2). - When ownership consolidates (for example, moving the product into an organization-owned Cloudflare account), change account membership rather than attempting to move the zone.
2. Capture DNS records from Vercel before switching nameservers
The registrar is GoDaddy, but the domain's DNS was hosted at Vercel, not at GoDaddy. Cloudflare's automatic scan during site setup is best-effort and can miss records a passive scan does not observe. Before changing nameservers, read the authoritative record list directly from the Vercel project's Domains settings and confirm every record below is re-created in Cloudflare. Any record not imported disappears the moment the nameservers cut over, because GoDaddy stops being the effective DNS authority at that point.
The records that existed at Vercel:
| Type | Host | Points to |
|---|---|---|
A | @ (apex) | [VERCEL_APEX_IP] |
A | app | [VERCEL_APP_IP] |
A | api | [VERCEL_API_IP] |
A | staging | [VERCEL_STAGING_IP] |
A | mail | [VERCEL_MAIL_IP] |
There were no MX records and no TXT records. Do not add either from habit or from a generic template; add only what Email Routing creates in step 8 and what any future sending service requires.
Re-create each A record in the Cloudflare DNS editor with the same host and target before proceeding. Records that will be superseded later in this runbook (the app record, once the production Pages custom domain takes it over in step 5, and the mail-related records, once Email Routing writes its own MX and TXT records in step 8) still need to exist during the cutover window so nothing resolves to nothing while the migration is in progress. app.dev did not exist at Vercel; it is created fresh in step 5.
3. Switch nameservers at GoDaddy
- In the Cloudflare Dashboard, copy the two nameservers Cloudflare assigned to the zone.
- In the GoDaddy DNS management page for
feelproclub.com, replace the existing nameservers with Cloudflare's two nameservers. - Wait for Cloudflare to report the zone as active. Propagation can take up to 24 hours, though it is typically much faster.
- Confirm every record captured in step 2 resolves correctly once the zone is active, before relying on it for the Pages custom domains or Email Routing.
4. Create both Cloudflare Pages projects
A Cloudflare Pages project has exactly one *.pages.dev hostname and one Git production branch; branch previews live beneath that hostname (<branch>.<project>.pages.dev), not beside it. Serving two distinct hostnames with two distinct sets of environment variables — production values on one, development values on the other — therefore takes two projects, not one project with two branches:
| Project | Production branch | Hostname | Custom domain |
|---|---|---|---|
feelproclub | main | feelproclub.pages.dev | app.feelproclub.com |
feelproclub-dev | dev | feelproclub-dev.pages.dev | app.dev.feelproclub.com |
Project identity is permanent, so get the name right before creating anything. Two facts make this true:
- Git integration cannot be added to an existing Pages application. A project created by
wrangler pages project createor by a direct upload is permanently direct-upload; converting it means deleting it and starting again, which also releases its*.pages.devsubdomain. Reaching for the CLI to save a few clicks costs the project name. - Renaming an existing project means the same thing: deleting it and recreating it, releasing its
*.pages.devsubdomain for anyone to claim in the interval. An earlier attempt at the project namefpccollided with an existing project elsewhere on Cloudflare and was silently assignedfpc-41xinstead offpc. If a project by that name exists in the account, delete it rather than reuse it — it is not a usable placeholder for either project above.
Create each project through the Cloudflare Dashboard, and only through the Dashboard: Workers & Pages → Create application → Pages → Import an existing Git repository. A Git-connected project requires installing the "Cloudflare Workers and Pages" GitHub App on the feelproclub organization, which is an app-installation flow that only a GitHub organization owner can complete interactively. No API, connector, or wrangler command can create a Git-connected project. The GitHub App installation is a one-time, account-level step; both projects can authorize against it without reinstalling.
For each project (feelproclub first, then feelproclub-dev):
Connect the Cloudflare Pages project to the
feelproclub/fpcGitHub repository, authorizing the GitHub App against the organization when prompted.Configure the build:
- Production branch:
mainforfeelproclub,devforfeelproclub-dev. - Build command:
pnpm --filter @feelproclub/shared build && pnpm --filter fpc-app build - Build output directory:
apps/app/dist - Root directory: the repository root
- Environment variable
NODE_VERSION:24
Both projects publish the same build output.
apps/app/public/_redirectsships insideapps/app/distas part of that build, so the single-page-application catch-all rewrite applies identically to both projects with no project-specific configuration.- Production branch:
Add the environment variables the built client reads at build time, matching
apps/app/.env.example:VITE_API_URLVITE_WORKOS_CLIENT_IDVITE_SENTRY_DSNVITE_PUBLIC_POSTHOG_KEYVITE_PUBLIC_POSTHOG_HOST
These are per-project, not shared:
feelproclub's Production environment variables hold production values (the productionrun.appAPI URL, the production WorkOS client id);feelproclub-dev's hold development values (the developmentrun.appAPI URL, the development WorkOS client id). A single project cannot cleanly serve both value sets at once, which is as much the reason for splitting the project as the two hostnames are. Setfeelproclub-dev's Preview environment variables to the same development values, since step 6 confines that project's non-production branches to previews that should exercise the development API.feelproclub's Preview environment does not need values: step 6 restricts that project to building only its production branch, so no Preview build ever runs there.VITE_WORKOS_REDIRECT_URIis optional and normally left unset on both projects. Unset, the client derives its callback from the origin it is served from, which is correct forapp.feelproclub.com,app.dev.feelproclub.com, and any preview URL. Set it only when the callback must differ from the serving origin — a deployment reached through a proxy, for instance. Step 7 explains why setting it does not remove the need to allowlist preview origins.Do not commit any of these values to the repository;
apps/app/.env.exampledocuments only placeholders.Trigger a build and confirm it succeeds before moving on to the custom domain.
5. Add the custom domains
Multi-level subdomains are fine for Pages custom domains, and not for everything else. A Pages custom domain provisions its own certificate per domain, so app.dev.feelproclub.com is issued a valid certificate and works on the free plan; this was verified against the live deployment. The zone's free Universal SSL certificate is a different mechanism and does cover only the root domain and its first-level subdomains, so a hostname served by a proxied DNS record rather than by a Pages custom domain — a future api.dev.feelproclub.com pointing at Cloud Run, for instance — would rely on that zone certificate and may not be covered. Verify certificate issuance for any such hostname before depending on it, rather than assuming the Pages behaviour generalises.
- On the
feelproclubproject's Custom domains tab, addapp.feelproclub.com. - On the
feelproclub-devproject's Custom domains tab, addapp.dev.feelproclub.com. - Cloudflare manages the required DNS record for each domain automatically once it is verified active on the zone.
app.feelproclub.comsupersedes theappArecord captured in step 2;app.dev.feelproclub.comis a new record with no prior entry to supersede. - Confirm
https://app.feelproclub.comserves thefeelproclubproject andhttps://app.dev.feelproclub.comserves thefeelproclub-devproject before treating the cutover as complete.
6. Restrict preview deployments to the development project
Both projects watch the same feelproclub/fpc repository, so without a restriction a feature branch would build in both projects and produce two preview URLs — one per project, for the same commit. That is redundant rather than useful, since only one of them (the development project, wired to the development API and WorkOS environment) is a preview anyone should actually use.
In the feelproclub project's build settings, restrict automatic deployments to its production branch (main) only, so pushes to other branches do not also build there. Leave feelproclub-dev building every branch, which is what makes it the previews project. If this setting cannot be located in the current Dashboard, treat it as a known gap rather than blocking the runbook on it: the consequence is duplicate builds on non-main branches — noisy build minutes and an extra unused preview URL per push — not a broken or insecure deployment.
7. The preview-deployment CORS and redirect consequence
Every non-production branch pushed to feelproclub-dev gets two native preview URLs: a per-commit URL (<hash>.feelproclub-dev.pages.dev) and a stable branch alias (<branch>.feelproclub-dev.pages.dev) that always points at that branch's latest commit. Branch names are lowercased and non-alphanumeric characters are replaced with hyphens to form the alias.
- If a preview build must call the development API, its exact origin must be added to
allowed_originsininfra/environments/dev/main.tf, and the same origin's/auth/callbackredirect URI must be added to the WorkOS development environment's allowlist (see WorkOS development setup). Without both, the preview build's requests fail CORS and its sign-in redirect is rejected. - Per-commit URLs are unbounded, so allowlisting each one is not practical. The stable
feelproclub-dev.pages.devorigin (the branch alias fordevitself) is the one allowlisted by default; treat individual per-commit preview URLs as visual-only unless a specific one is deliberately allowlisted for a short-lived reason and removed afterward. - Production needs neither change.
app.feelproclub.comis the unchanged production origin; it is not a*.pages.devaddress, andinfra/environments/prod/main.tf'sallowed_originsalready readshttps://app.feelproclub.com. Do not add apages.devorigin to the production root.
Pinning VITE_WORKOS_REDIRECT_URI to a fixed address does not avoid this. It sends the user back to that deployment after sign-in rather than to the preview they started from, so the preview never receives a session. A preview that must authenticate needs its own origin allowlisted; no configuration removes that requirement.
8. Enable Email Routing
- On the zone's Email Routing page, enable Email Routing. Cloudflare adds its own
MXrecords and an SPFTXTrecord to the zone automatically; do not hand-write these. - Add and verify a destination address — the existing mailbox that will actually receive forwarded mail. Verification requires clicking a confirmation link sent to that address.
- Create routing rules:
manu@feelproclub.com→ the verified destination address.support@feelproclub.com→ the verified destination address.- A catch-all rule → the verified destination address, so an address not explicitly listed still reaches someone instead of bouncing.
Two facts matter operationally and are easy to assume away:
- Email Routing forwards but cannot send. It receives mail addressed to the domain and forwards it to the destination address; it has no mechanism for sending mail from an address on the domain. Replying as
support@feelproclub.comor similar requires a separate outbound sending service — Email Routing does not provide one. - The
support@feelproclub.comGoogle Group is now vestigial. Because the zone'sMXrecords point at Cloudflare, Google can no longer receive mail for the domain, so any Google Group configured againstsupport@feelproclub.comreceives nothing. It is not deleted by this runbook, but it no longer does anything; do not rely on it for support intake.
Verification
https://app.feelproclub.comloads the application, served by thefeelproclubCloudflare Pages project rather than Vercel, andhttps://app.dev.feelproclub.comloads the application served by thefeelproclub-devproject.- A direct request to a client-side route on either domain — for example
https://app.feelproclub.com/invite/[SOME_CODE]— returns the application (a200servingindex.htmlthrough the single-page-application catch-all rewrite), not a404. This is the specific failure the rewrite exists to prevent: without it, only the root path resolves and every deep link into the invite flow breaks. dig feelproclub.com A,dig app.feelproclub.com,dig app.dev.feelproclub.com,dig api.feelproclub.com,dig staging.feelproclub.com, anddig mail.feelproclub.comall resolve, matching what step 2 captured or what later steps in this runbook superseded.dig feelproclub.com MXreturns Cloudflare's Email RoutingMXrecords, anddig feelproclub.com TXTincludes the SPF record Cloudflare added.- Sending a test message to
manu@feelproclub.comand tosupport@feelproclub.comeach arrives at the verified destination address. A message to an address with no explicit rule (for examplerandom@feelproclub.com) also arrives there, through the catch-all. - Pushing a non-
mainbranch produces a build infeelproclub-devonly, confirming step 6's build-branch restriction is in effect (or, if the restriction could not be configured, confirms the accepted duplicate-build gap and nothing worse). - The stable
feelproclub-dev.pages.devbranch-alias origin loads without a CORS error once it has been added to the developmentallowed_originsand WorkOS development redirect allowlist per step 7. Production sign-in and API calls continue to work with nopages.devorigin added, confirming the non-consequence for production is real and not just documented.