Environment Variables

The two-layer env model, the VITE_ rule, and production config

Last updated October 22, 2026


Environment variables come in two layers, validated by two Zod schemas. Business code must not read process.env or import.meta.env directly — always go through these modules.

FileContents
src/env/public.tsVITE_ prefix, inlined into the client bundle at build time
src/env/server.tsServer secrets, read only at Workers runtime

Prerequisites

None. This page stands alone, though Cloudflare is worth reading before configuring a deploy.

Setup

The `VITE_` rule

VITE_ variables are inlined into the client bundle at build time — anyone who opens the page can read them.

PrefixMeaningExamples
VITE_PublicVITE_BASE_URL, VITE_GA_ID
No prefixServer secretBETTER_AUTH_SECRET, STRIPE_SECRET_KEY
Never put a secret behind `VITE_`. pnpm check:client-bundle scans the build output and fails on a leak. It requires pnpm build first.

Public variables

VariableDefaultNotes
VITE_BASE_URLhttp://localhost:3000Site base URL for canonical, OG, sitemap
VITE_PAYMENT_PROVIDER—Payment provider (only stripe)
VITE_GA_ID—Google Analytics
VITE_PLAUSIBLE_DOMAIN—Plausible
VITE_UMAMI_WEBSITE_ID—Umami
VITE_UMAMI_SCRIPT_URL—Umami script URL (credential-free HTTPS)
VITE_CLARITY_PROJECT_ID—Microsoft Clarity
VITE_TURNSTILE_SITE_KEY—Turnstile site key

Server variables

All optional. A missing variable means the provider is not registered rather than falling back to a placeholder — an unconfigured feature stays off instead of pretending to work.

VariableConstraintUsed by
BETTER_AUTH_SECRET≥ 32 charsAuth, required in production
BETTER_AUTH_URLURLAbsolute callback URL; inferred from request origin when empty
STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET—Payment
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRETMust be pairedGoogle sign-in
RESEND_API_KEY / MAIL_FROMMAIL_FROM must be a valid addressEmail
RESEND_AUDIENCE_ID—Newsletter (Resend)
BEEHIIV_API_KEY / BEEHIIV_PUBLICATION_ID—Newsletter (Beehiiv)
DISCORD_WEBHOOK_URLMust start with https://discord.com/api/webhooks/Notifications
FEISHU_WEBHOOK_URLMust start with https://open.feishu.cn/open-apis/bot/v2/hook/Notifications
CLOUDFLARE_ACCOUNT_ID / CLOUDFLARE_API_TOKEN—Deploy and API
ADMIN_EMAILS—/api/email recipient allowlist
NODE_ENVdevelopment / production / testDefaults to development

Empty strings normalize to unset, so a blank line in .env is the same as omitting it.

Workers bindings

Resource bindings do not go through the env schema; they are declared in wrangler.jsonc:

BindingTypePurpose
DBD1Drizzle database
CACHEKVCache and rate limiting
BUCKETR2Object storage
EMAILSendEmailCloudflare Email

Production config

Local .env holds development values; production lives in .env.production:

bash
cp .env.example .env.production

Change at least two things: set VITE_BASE_URL to a real HTTPS domain and BETTER_AUTH_SECRET to a production secret. The file is git-ignored and must not be committed.

Priority (high → low): process env → `.env.production` → `.env`. CI therefore needs no such file — put the values in the workflow env: block instead.

When .env.production exists, check-env-prod.ts uses it and does not fall back to .env — otherwise a wrong production value would be masked by a valid local one. Fallback only happens when the file is absent.

Commands

CommandBehavior
pnpm check:envValidate local .env; invalid fields fail
pnpm check:env:prodProduction check: VITE_BASE_URL must be a real HTTPS domain
pnpm check:client-bundleRequires pnpm build; confirms no secrets in the bundle

The production URL check rejects localhost, 127.*, 0.0.0.0, URLs with embedded credentials, and placeholder domains like example.com or *.test.

References

What's next