Going to production
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 development → staging → 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.
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 need | How to get it | Required |
|---|---|---|
| First deploy finished | development, preview and staging all deploying | Yes |
| Your zone active in Cloudflare | Add PROJECT_ZONE as a zone and move its nameservers, so a Custom Domain can attach | Yes |
| A production Postgres | A managed instance of its own — never the one staging uses | Yes |
| A production Google OAuth client | Set up sign-in and payments — a separate client | Only for Google sign-in |
| A production GitHub OAuth app | Same page — a separate app, one callback URL per origin | Only for GitHub sign-in |
| A Polar production organization and token | Same page, from the production dashboard rather than the sandbox | Only for payments |
A fresh NUXT_BETTER_AUTH_SECRET | openssl rand -base64 32 — its own value, not staging's | Yes |
A main branch | Your clone has one, and production deploys from it | Yes |
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 set | apps/site serves | apps/web serves |
|---|---|---|
| Nothing — the domain is the zone | example.com | app.example.com |
PROJECT_DOMAIN=my-app.example.com | my-app.example.com | app.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.
| Key | Comes from | Needed for |
|---|---|---|
DATABASE_URL | Your production Postgres | Every request, and the Hyperdrive plan reads it too |
NUXT_BETTER_AUTH_SECRET | openssl rand -base64 32 | Every sign-in (@app/layer-auth) |
GOOGLE_CLIENT_SECRET | Your production Google client | Google sign-in (@app/layer-auth) |
GITHUB_CLIENT_SECRET | Your production GitHub app | GitHub sign-in (@app/layer-auth) |
POLAR_ACCESS_TOKEN | The Polar production dashboard | Payments (@app/layer-payments) |
NUXT_WEBHOOK_POLAR_SECRET_KEY | Your production Polar webhook | Payments (@app/layer-payments) |
NUXT_EMAIL_PLATFORM_ADMIN_EMAILS | Addresses you choose | Platform 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
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.Register the production callback URLs
One callback URL per exact origin, on the production OAuth applications rather than the ones staging uses:
| Provider | Callback URL |
|---|---|
https://app.<domain>/api/auth/callback/google | |
| GitHub | https://app.<domain>/api/auth/callback/github |
| Polar | https://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:
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.
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:
| Resource | Name | Declared in |
|---|---|---|
| R2 bucket | <slug>-web-production | infra/web-assets.tf |
| Hyperdrive pool | <slug>-web-production | infra/web-database.tf |
| D1 database | <slug>-site-production | infra/site-content.tf |
Then paste each printed id into the key it names:
| Output | Goes to |
|---|---|
web_hyperdrive_id | apps/web/.env.production → CLOUDFLARE_HYPERDRIVE_ID |
site_d1_database_id | apps/site/.env.production → CLOUDFLARE_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:
NUXT_PUBLIC_APP_BASE_URL=https://app.<domain>
NUXT_PUBLIC_APP_WEBSITE_BASE_URL=https://<domain>
NUXT_PUBLIC_APP_BASE_URL=https://<domain>
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:
| Setting | Ships as | Why |
|---|---|---|
NUXT_PUBLIC_SITE_INDEXABLE | true | The one context search engines should find |
POLAR_SERVER | production | Needs the production Polar token, not a sandbox one |
Two more are yours:
| Setting | What to put in |
|---|---|
GOOGLE_CLIENT_ID | Your production Google client id — a client id isn't secret |
GITHUB_CLIENT_ID | Your production GitHub client id |
NUXT_PUBLIC_CONTACT_EMAIL | In 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:
| Application | Sends from |
|---|---|
apps/web | app.<domain> |
apps/site | site.<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.
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
- Both hostnames answer, over HTTPS, on your own domain.
https://<domain>/robots.txtallows indexing, and staging's still doesn't.- Sign up with a real address and confirm the verification email arrives — not in spam.
- Sign in with Google and GitHub, if you wired them up.
- Run a checkout and confirm Polar shows it in the production dashboard.
When it does not work
| What you see | What to fix |
|---|---|
| A green run that shipped nothing | A REPLACE_ME left in .env.production or .env.project.schema |
The plan stops naming DATABASE_URL | Production's connection string isn't in the production tier at / |
Authentication error [code: 10000] at the deploy | The deploy token's R2 permission — see First deploy |
| The domain returns a Cloudflare error, not your app | The zone isn't active in the account, so the Custom Domain never attached |
| Sign-in redirects to a mismatch error | The production callback URL isn't registered on the production OAuth client |
| Mail sends but never arrives | That exact hostname isn't onboarded — wrangler email sending list |
| A checkout fails at the first API call | POLAR_ACCESS_TOKEN is a sandbox token |
Next steps
- Infrastructure — the runbook for every later apply, including how to retire a context
- Deploy pipeline — what each event does from here
- Getting updates — pull NuxtStart's later changes into your project
