Getting Started

Going to production

Attach your domain, provision production, and let a push to main reach real users — with every account and credential gathered up front.

This is stage 3, and the one to take slowly. Production is the only Runtime Context that carries your domain, indexes in search engines, and charges real money.

flowchart LR
    L["1. Local setup"] --> N["2. First deploy<br/>development, preview, staging"]
    N --> P["**3. Going to production**<br/>Your domain, real users"]

Do this after you've promoted developmentstaging → and back a few times, and staging looks like the product you want people to see. Nothing on this page is reversible for free: a search engine that indexes a half-finished site keeps the result, and a Polar production token moves real money.

Production is the one stage where you can stop and restart later. Every other context keeps working while production sits unconfigured — that's what REPLACE_ME in apps/*/.env.production means.

Prerequisites

Gather all of these before you run a command. Each one is a console you'd otherwise open in the middle of a step.

What you need before you start

You needHow to get itRequired
First deploy finisheddevelopment, preview and staging all deployingYes
Your zone active in CloudflareAdd PROJECT_ZONE as a zone and move its nameservers, so a Custom Domain can attachYes
A production PostgresA managed instance of its own — never the one staging usesYes
A production Google OAuth clientSet up sign-in and payments — a separate clientOnly for Google sign-in
A production GitHub OAuth appSame page — a separate app, one callback URL per originOnly for GitHub sign-in
A Polar production organization and tokenSame page, from the production dashboard rather than the sandboxOnly for payments
A fresh NUXT_BETTER_AUTH_SECRETopenssl rand -base64 32 — its own value, not staging'sYes
A main branchYour clone has one, and production deploys from itYes

Decide your production hostnames

PROJECT_DOMAIN decides where both applications answer and where their mail comes from. It defaults to PROJECT_ZONE, which is the answer when your project owns a whole zone. Set it only when the project lives at a subdomain of a zone you use for other things.

What you setapps/site servesapps/web serves
Nothing — the domain is the zoneexample.comapp.example.com
PROJECT_DOMAIN=my-app.example.commy-app.example.comapp.my-app.example.com

Worked scenarios has the full table, including the case where your slug and your domain are different words, and the two topologies that need you to write CLOUDFLARE_WORKER_ROUTE by hand.

The production Secret Tier

Every key below lives in apps/web's Infisical project, environment production, path /. Production is the one tier where a placeholder is worse than a missing value: REPLACE_ME in a credential lets the build succeed and fails at the first sign-in instead.

KeyComes fromNeeded for
DATABASE_URLYour production PostgresEvery request, and the Hyperdrive plan reads it too
NUXT_BETTER_AUTH_SECRETopenssl rand -base64 32Every sign-in (@app/layer-auth)
GOOGLE_CLIENT_SECRETYour production Google clientGoogle sign-in (@app/layer-auth)
GITHUB_CLIENT_SECRETYour production GitHub appGitHub sign-in (@app/layer-auth)
POLAR_ACCESS_TOKENThe Polar production dashboardPayments (@app/layer-payments)
NUXT_WEBHOOK_POLAR_SECRET_KEYYour production Polar webhookPayments (@app/layer-payments)
NUXT_EMAIL_PLATFORM_ADMIN_EMAILSAddresses you choosePlatform alerts (@app/layer-email). Optional — leave it empty to turn them off

Set them together, with the project id committed in apps/web/.env.infisical:

infisical secrets set \
  DATABASE_URL='<production connection string>' \
  NUXT_BETTER_AUTH_SECRET="$(openssl rand -base64 32)" \
  GOOGLE_CLIENT_SECRET='<production Google client secret>' \
  GITHUB_CLIENT_SECRET='<production GitHub client secret>' \
  POLAR_ACCESS_TOKEN='<production Polar token>' \
  NUXT_WEBHOOK_POLAR_SECRET_KEY='<production Polar webhook secret>' \
  --env=production --path=/ --projectId='<apps/web project id>'

Check they landed. infisical secrets prints values in full, so send it through jq and emit the key names alone:

out=$(infisical secrets --projectId='<apps/web project id>' \
  --env=production --path=/ --output=json --silent)
printf '%s' "$out" | jq -r '.[].secretKey' | sort
The Infisical CLI has no masking flag. A bare infisical secrets puts every production credential on your screen, into your scrollback, and into any terminal recording or CI log. Capture to a variable and print only what you need.
A Polar sandbox token fails against production at the first API call rather than at startup, so the deploy goes green and a checkout breaks. Take the token from the production dashboard.

Register the production callback URLs

One callback URL per exact origin, on the production OAuth applications rather than the ones staging uses:

ProviderCallback URL
Googlehttps://app.<domain>/api/auth/callback/google
GitHubhttps://app.<domain>/api/auth/callback/github
Polarhttps://app.<domain>/api/webhooks/polar

Provision and deploy

Set PROJECT_DOMAIN if it isn't your zone

Leave it alone when your project owns the whole zone. Otherwise:

.env.project.schema
PROJECT_DOMAIN=my-app.example.com

Both production routes and both production sending domains follow this one line.

Activate the production context

Production ships as an example so a project without one plans nothing:

git mv infra/contexts/production.tfvars.example infra/contexts/production.tfvars

git mv stages the file, and being staged is what makes production active: infra/tofu.sh reads the active set out of the git index, so a copy you have not added is refused by name.

From the commit that lands this file onward, every pull request plans production. A plan without production's DATABASE_URL fails by name, so set that secret first — the prerequisites above do it.

Apply production

infra/tofu.sh production init
infra/tofu.sh production plan
infra/tofu.sh production apply
infra/tofu.sh production output

Read the plan before the apply. Every resource it names is production's own, created fresh:

ResourceNameDeclared in
R2 bucket<slug>-web-productioninfra/web-assets.tf
Hyperdrive pool<slug>-web-productioninfra/web-database.tf
D1 database<slug>-site-productioninfra/site-content.tf

Then paste each printed id into the key it names:

OutputGoes to
web_hyperdrive_idapps/web/.env.productionCLOUDFLARE_HYPERDRIVE_ID
site_d1_database_idapps/site/.env.productionCLOUDFLARE_D1_DATABASE_ID

Both ship as REPLACE_ME, which is how a value file says "this context is not configured here". Replacing them is part of what lets a push to main through.

Write production's origins

No origin is derived, and production's has to agree with its route. CLOUDFLARE_WORKER_ROUTE is already built from PROJECT_DOMAIN, so in most projects you write only the origins:

apps/web/.env.production
NUXT_PUBLIC_APP_BASE_URL=https://app.<domain>
NUXT_PUBLIC_APP_WEBSITE_BASE_URL=https://<domain>
apps/site/.env.production
NUXT_PUBLIC_APP_BASE_URL=https://<domain>
The route and the origin are two values that have to agree, and nothing checks them against each other. CLOUDFLARE_WORKER_ROUTE decides where the Worker answers; NUXT_PUBLIC_APP_BASE_URL is baked into the bundle and reaches your canonical tags, social images, sitemap and email. A mismatch serves one hostname and advertises another. apps/web's NUXT_PUBLIC_APP_WEBSITE_BASE_URL must point at the site's origin, or its links to /docs and /blog 404.

Attaching a domain covers the two route shapes, and the path-scoped one that also needs CLOUDFLARE_WORKER_ROUTE_ZONE.

Fill in the rest of the production value files

Two keys flip in production and nowhere else, and both are already set in the files you inherited. Confirm rather than change them:

SettingShips asWhy
NUXT_PUBLIC_SITE_INDEXABLEtrueThe one context search engines should find
POLAR_SERVERproductionNeeds the production Polar token, not a sandbox one

Two more are yours:

SettingWhat to put in
GOOGLE_CLIENT_IDYour production Google client id — a client id isn't secret
GITHUB_CLIENT_IDYour production GitHub client id
NUXT_PUBLIC_CONTACT_EMAILIn apps/site/.env.production, the address your site publishes

Both client ids ship empty and the schema marks them required, so a resolution stops on them by name. Why some credentials are committed explains why a client id lives in Git and a client secret doesn't.

Onboard the production sending domains

Production is the one context whose senders carry your domain rather than your slug:

ApplicationSends from
apps/webapp.<domain>
apps/sitesite.<domain>

app.<domain> is also apps/web's Worker route, and that's not a collision — a Custom Domain is a proxied address record, and the SPF and DKIM records live at the same name alongside it.

Read each hostname off the resolved environment rather than assembling it:

cd apps/web && APP_RUNTIME_CONTEXT=production pnpm exec varlock load --filter NUXT_EMAIL_SENDER_DOMAIN

Then onboard it and fetch its records:

pnpm exec wrangler email sending enable app.acme.com
pnpm exec wrangler email sending dns get app.acme.com

Repeat for apps/site. When your zone is in the same Cloudflare account the records are added for you — confirm with wrangler email sending list.

A hostname you have not onboarded still resolves, still builds and still deploys. The message fails at the recipient instead, and in production that's a user who never gets their verification email.

Promote to main

Commit everything above on development, promote it forward, and push:

git push origin development
# merge development into staging, push, check staging
git checkout main && git merge staging
git push origin main

A push to main deploys production. Nothing enforces the order — the gate reads only the branch pushed — so promote by merging forward and never commit straight to main.

Check that it worked

  1. Both hostnames answer, over HTTPS, on your own domain.
  2. https://<domain>/robots.txt allows indexing, and staging's still doesn't.
  3. Sign up with a real address and confirm the verification email arrives — not in spam.
  4. Sign in with Google and GitHub, if you wired them up.
  5. Run a checkout and confirm Polar shows it in the production dashboard.

When it does not work

What you seeWhat to fix
A green run that shipped nothingA REPLACE_ME left in .env.production or .env.project.schema
The plan stops naming DATABASE_URLProduction's connection string isn't in the production tier at /
Authentication error [code: 10000] at the deployThe deploy token's R2 permission — see First deploy
The domain returns a Cloudflare error, not your appThe zone isn't active in the account, so the Custom Domain never attached
Sign-in redirects to a mismatch errorThe production callback URL isn't registered on the production OAuth client
Mail sends but never arrivesThat exact hostname isn't onboarded — wrangler email sending list
A checkout fails at the first API callPOLAR_ACCESS_TOKEN is a sandbox token

Next steps

Copyright © 2026