Cloudflare

Account, API token, resources, first deploy, and CI deployment

Last updated October 29, 2026


The site runs on Cloudflare Workers. This page sets up your account and API token, walks through the first deploy, and wires up production deployment after publishing a stable GitHub Release. Pushing or merging to main runs CI only.

Database, storage, and cache each have their own page — configure credentials and token permissions here first, then those pages will work.

Prerequisites

Setup

1. Create an API token

wrangler supports two auth methods: wrangler login (OAuth, interactive) and API token (non-interactive, required for CI). CI uses the latter.

In My Profile → API Tokens, click Create Token → Create Custom Token and select:

ScopePermissionWhen needed
AccountWorkers Scripts → EditRequired to deploy
AccountD1 → EditUsing D1
AccountWorkers KV Storage → EditUsing KV cache
AccountWorkers R2 Storage → EditUsing R2 storage
AccountWorkers Routes → EditCustom domain
AccountEmail Routing Addresses → EditCloudflare Email

The core path (D1 + KV + Worker) needs no Zone or R2 permissions.

Add the Account ID and Token to .env:

bash
CLOUDFLARE_ACCOUNT_ID=your-account-id
CLOUDFLARE_API_TOKEN=your-api-token

Verify:

bash
pnpm exec wrangler whoami
GET /user/tokens/verify returns code 1000 Invalid API Token for account-scoped tokens. That is expected. Verify with wrangler whoami instead.

2. Configure a workers.dev subdomain

The account needs a workers.dev subdomain before the first deploy. It determines the URL:

Code
<worker-name>.<account-subdomain>.workers.dev

wrangler whoami does not show it. Check:

bash
curl -s "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/subdomain" \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN"

code 10007 means none exists yet. Create it:

bash
curl -s -X PUT "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/subdomain" \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{"subdomain":"your-subdomain"}'
The subdomain cannot be changed via API after creation. PUT returns code 10036 and PATCH returns code 10405. Renaming is Dashboard-only: Workers &amp; Pages → Your subdomain → Change. Pick a name you like the first time.

3. Set the project name

name in wrangler.jsonc determines the Worker name and therefore the URL:

wrangler.jsonc
{
  // with account subdomain saas-template-demo → https://demo.saas-template-demo.workers.dev
  "name": "demo"
}

This is independent of name in package.json, which is only the npm package name.

4. First deploy

bash
pnpm deploy

This runs production env validation → build → client bundle leak check → wrangler deploy.

Three things must be in place first:

  1. .env.production exists with a real HTTPS VITE_BASE_URL (see Environment);
  2. Binding IDs in wrangler.jsonc are filled in, with no `REPLACE_ME` (see

Database and Cache);

  1. Runtime secrets are set:
bash
printf '%s' "$BETTER_AUTH_SECRET" | pnpm exec wrangler secret put BETTER_AUTH_SECRET
pnpm exec wrangler secret put RESEND_API_KEY
pnpm exec wrangler secret put MAIL_FROM
pnpm exec wrangler secret put BETTER_AUTH_URL

Currently mail.enabled: true selects resend and auth.requireEmailVerification: true. Verify your sending domain in Resend, then configure RESEND_API_KEY and a plain MAIL_FROM address on that domain, without a display name or angle brackets. Local development uses .env. For manual production deployment, supply the same environment's values in .env.production for validation and keep Worker runtime bindings consistent. BETTER_AUTH_URL is http://localhost:3000 locally and the actual HTTPS origin in production, matching VITE_BASE_URL. Missing mail configuration makes auth report incomplete, /api/auth/* return 503, and production validation fail. Email verification is never implicitly disabled. See Email.

wrangler deploy does not clear existing secrets; repeated deploys are idempotent.

On success the output includes the live URL:

Code
Deployed demo triggers
  https://demo.saas-template-demo.workers.dev

5. Custom domain (optional)

The domain must already be on Cloudflare (nameservers pointed at Cloudflare). Then add:

wrangler.jsonc
"routes": [
  { "pattern": "your-domain.com", "custom_domain": true }
]

After binding, update VITE_BASE_URL and BETTER_AUTH_URL in .env.production and redeploy — the former is inlined at build time.

A custom domain requires Workers Routes → Edit; workers.dev does not.

The zone must be in the same Cloudflare account as the Worker. Cloudflare creates the DNS records and certificate inside the owning account, and it cannot create a custom domain on a zone you do not own; pointing a CNAME at workers.dev across accounts is blocked (CNAME Cross-User Banned, error 1014). When the domain lives in another account, either move the zone into the Worker's account, or deploy the Worker in that account and handle custom hostnames through Workers for Platforms. To find which account owns a zone:

bash
curl -s "https://api.cloudflare.com/client/v4/zones?name=your-domain.com" \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN"

An empty result array means the current token cannot see the zone — usually because it belongs to another account or the token lacks Zone read permission.

6. Receiving and sending on the same domain

One domain can receive mail through Cloudflare Email Routing and send through Resend at the same time; their records have different names and coexist:

PurposeRecordsAdded by
Receiving (forward to Gmail and similar)Root MX → route1/2/3.mx.cloudflare.net, root TXT → v=spf1 include:_spf.mx.cloudflare.net ~all, DKIM at cf2024-1._domainkeyAdded automatically when Email Routing is enabled
Sending (verification emails and similar)DKIM at resend._domainkey, Return-Path on the send subdomain (MX + TXT, or two CNAME records for domains created after August 2026)The Records tab in the Resend dashboard

Key points:

  • Only one SPF record may exist at the root. Email Routing already claims it; if another

service must send as this domain, merge its include: into that record — a second v=spf1 record breaks SPF outright.

  • Resend's SPF lives on the send subdomain for bounce handling and does not affect the root.
  • Resend CNAME records must not be proxied (orange cloud), and no other record may share

the same name; otherwise verification never completes.

  • Email Routing receives only, never sends: forwarded mail cannot be replied to from the

original address, so reply from the Gmail account.

CI deployment

Merging to main runs CI only. Publishing a stable GitHub Release triggers production deployment through release.published. Drafts, prereleases, and tag pushes alone do not deploy.

Use this sequence: PR → merge main → wait for that commit's CI → publish a tag matching package.json (for example v0.2.0) → Deploy → smoke tests. The workflow verifies that the tag commit belongs to main and its push CI succeeded, then deploys the verified immutable SHA. If CI is still running at publication, rerun Deploy after it succeeds.

GitHub configuration

Create the production Environment in Settings → Environments. If deployment branch rules are enabled, allow version tags such as v*. Set these environment values; repository-level values also work:

TypeNameValue / purpose
SecretCLOUDFLARE_API_TOKENDeployment token with Workers Scripts Edit, D1 Edit, and account read access; add KV / Workers Routes permissions as needed
VariableCLOUDFLARE_ACCOUNT_IDAccount that owns the resources in wrangler.jsonc
VariableVITE_BASE_URLProduction HTTPS origin without a path
VariableBETTER_AUTH_URLOptional; defaults to VITE_BASE_URL for auth callbacks
SecretBETTER_AUTH_SECRETAt least 32 characters; preserve the existing value for an established site
SecretRESEND_API_KEYRequired for current Resend mail and email verification; use an API key with sending permission
VariableMAIL_FROMPlain email address on the Resend-verified domain, without display name or angle brackets; a same-name Secret also works, with the Variable taking precedence
SecretSTRIPE_SECRET_KEYProduction Stripe API secret key; use a test key only for an intentionally test-mode demo
SecretSTRIPE_WEBHOOK_SECRETSigning secret (whsec_…) for the same-mode endpoint at <VITE_BASE_URL>/api/webhooks/stripe
SecretDISCORD_WEBHOOK_URLChannel webhook URL; required when notifications are enabled (currently disabled)
VariableVITE_GA_IDGA4 Measurement ID (G-…); required when analytics is enabled (currently disabled)

Existing repository Secrets for CLOUDFLARE_ACCOUNT_ID and VITE_BASE_URL remain supported. Use Variables for new public configuration. In the same Stripe environment, configure an active USD 99 one-time price with lookup key jerbix_template; see Payment for webhook events. Disable unused notification or analytics modules in src/config/website.ts instead of leaving required configuration empty.

Neither RESEND_API_KEY nor MAIL_FROM uses a VITE_ prefix. MAIL_FROM is the sender; the support inbox is configured separately in websiteConfig.mail.supportEmail. Setting one does not populate the other. Support is service@jerbix.site and the intended production domain is https://www.jerbix.site. Set production VITE_BASE_URL and BETTER_AUTH_URL to that origin and configure a separate Resend sender. Domain bindings and environment changes take effect during deployment.

Optional mappings are included for Google OAuth (Variable GOOGLE_CLIENT_ID, Secret GOOGLE_CLIENT_SECRET), Turnstile (Variable VITE_TURNSTILE_SITE_KEY, Secret TURNSTILE_SECRET_KEY), and administrator emails (Variable ADMIN_EMAILS). These and both Resend fields are already mapped. When enabling additional providers, extend both workflow env mappings and the runtime upload allowlist.

CLI prompts keep values out of shell history:

bash
gh secret set RESEND_API_KEY --env production
gh variable set MAIL_FROM --env production
gh secret set STRIPE_SECRET_KEY --env production
gh secret set STRIPE_WEBHOOK_SECRET --env production
gh secret set DISCORD_WEBHOOK_URL --env production
gh variable set VITE_GA_ID --env production

Deployment and migration boundaries

The workflow validates production configuration, builds, scans the client bundle, and uses wrangler deploy --secrets-file to upload allowlisted runtime bindings with the Worker version. RESEND_API_KEY and MAIL_FROM are already allowlisted; MAIL_FROM is uploaded as a server binding even when sourced from a GitHub Variable. GitHub is the configuration source for automatic deployments; no separate manual synchronization to Worker secrets is needed. The temporary JSON file has restricted permissions and is deleted on exit. Secret values are never printed, and Cloudflare deployment credentials are excluded from Worker bindings.

Production D1 migrations are not automated. Back up and review them separately before release. Migration 0008_payment_simplification.sql requires empty payment tables and must not be applied directly to historical production payment data. D1 / KV bindings remain configured in wrangler.jsonc.

Secret upload is additive: omitted Worker secrets persist. Removing a GitHub Secret does not delete its remote counterpart. Remove retired values in Cloudflare explicitly. A Cloudflare-only edit will be overwritten by the GitHub value on the next automatic deployment.

Build-time vs runtime values

TypeHow to setWhen it takes effectExample
Build-time (VITE_*)Local .env.production / GitHub VariablesInlined into the bundle at vite buildVITE_BASE_URL
RuntimeLocal wrangler secret put / automatic Release uploadRead from env at Worker runtimeBETTER_AUTH_SECRET

Build-time values require a rebuild and redeploy to change — they are literals in the client JS.

BETTER_AUTH_SECRET appears in both places for different reasons: the .env.production copy only satisfies production validation; runtime reads the Worker secret. They must be the same value.

References

What's next