All pages

Environment variables

apps/api

Variable Required Value Where it comes from
DATABASE_URL yes pooled Postgres connection string Neon, Connect, pooled connection
DB_NAME yes the production database name you choose it, postflare in the example
DB_NAME_DEV no the development database name defaults to ${DB_NAME}_dev
CLERK_SECRET_KEY yes sk_test_... or sk_live_... Clerk, API keys, Secret key
CLERK_PUBLISHABLE_KEY yes pk_test_... or pk_live_... Clerk, API keys, Publishable key
NODE_ENV no development by default your host sets it to production
PORT no 5001 by default your host, if it assigns one
CORS_ORIGINS yes comma-separated origins every domain that calls the api
WEB_URL yes origin of apps/web, no trailing slash your own deploy
ADMIN_CLERK_IDS no comma-separated Clerk user ids Clerk, Users, the id on a user's page
STRIPE_SECRET_KEY no sk_... or rk_... Stripe, Developers, API keys
STRIPE_WEBHOOK_SECRET no whsec_... Stripe, Webhooks, your endpoint, signing secret
STRIPE_PORTAL_CONFIGURATION_ID no bpc_... the configuration setup creates with billingPortal.configurations.create; unset uses the account's saved portal settings
CLERK_WEBHOOK_SECRET no whsec_... Clerk, Webhooks, your endpoint, signing secret
RESEND_API_KEY no re_... Resend, API Keys
EMAIL_FROM no the From: header, display name included your verified sending domain

apps/api/.env is committed encrypted by dotenvx and decrypts with the root .env.keys; without that file, copy apps/api/.env.example to apps/api/.env and run pnpm env:encrypt. CI fails when a committed env file holds a plaintext value. Every row above is parsed by apps/api/src/env.ts at module load, and a failure throws before the server binds a port.

DATABASE_URL_DIRECT appears in .env.example and is not in the schema above, because apps/api/src/env.ts never reads it. It is read by drizzle.config.ts, which is the migration CLI's config and the one documented exception to "no process.env outside env.ts". Leave it unset and pnpm db:migrate falls back to DATABASE_URL.

apps/web

Variable Required Value Read by
VITE_API_URL yes base URL of apps/api, no trailing slash src/lib/api/client.ts
VITE_STRIPE_ENV yes live or test src/env.ts
VITE_CLERK_PUBLISHABLE_KEY yes pk_test_... or pk_live_... @clerk/tanstack-react-start
CLERK_SECRET_KEY yes sk_test_... or sk_live_... @clerk/tanstack-react-start server side
VITE_CLERK_SIGN_IN_URL yes /auth/login Clerk provider and request middleware
VITE_CLERK_SIGN_UP_URL yes /auth/signup Clerk provider
VITE_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL no /dashboard Clerk provider
VITE_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL no /dashboard Clerk provider
VITE_POSTHOG_PROJECT_TOKEN no public PostHog project token analytics initialization and proxy gate
VITE_POSTHOG_REGION no eu or us, defaults to eu regional PostHog proxy targets

apps/web/.env.development follows the same rule. Its name keeps the file out of production builds: Vite and Nitro read .env.development only in development mode. Clerk uses its secret key on the web server for identity handling. Stripe, database and email credentials belong only to apps/api. PostHog is a public browser integration: the project token may be exposed, while a personal management key beginning phx_ must never be placed in either app's environment.

The two SIGN_IN_URL and SIGN_UP_URL values are listed as required even though the app boots without them. Leave them out and Clerk falls back to its hosted Account Portal, which sends a reader to a different domain mid sign-in. That looks like a bug and it is the one thing owning the auth UI was meant to avoid.

They are FALLBACK redirects rather than FORCE ones. FORCE destroys the return path, so a reader interrupted by a sign-in loses the page they were trying to reach.

apps/web/src/config/config.ts

Five committed values that are not environment variables, because they are the same in every deployment of one product. appConfig, in apps/web/src/config/config.ts, is the only object they are read from.

Field What it is Where it is read
siteName the product name components/Header, components/Footer, components/AuthShell as an interpolation, and the <title> in routes/__root.tsx
siteUrl the canonical production origin, no trailing slash lib/route.ts for canonical links, routes/index.tsx, routes/robots[.]txt.ts, and the sitemap in lib/content/resources.server.ts
contactEmail the address the FAQ renders as a mailto: components/FAQ
youtubeUrl the channel the footer links to components/Footer
description the <meta name="description"> sentence routes/__root.tsx, through the request's translator

Identity belongs here and prose does not. A sentence a person reads on the page fails that test however often you want to change it, and it lives in the component that renders it as a module-top const, the way TAGLINE does in Footer.tsx. Rebrand the storefront walks the whole change.

The rules behind the tables

A variable that is present but empty reads as unset. That is the state a freshly copied .env.example is in, and treating '' as set is the failing-open bug the whole file exists to prevent.

Required and optional are two tiers, and the split is a decision. Required is what there is no app without: a database and an identity provider, plus the non-secret operational values. Optional is the service keys, because a product with no billing or no email must still be able to boot. Every optional value is read through requireConfigured() at its point of use, so those call sites are also the inventory of what is optional.

Reaching an unconfigured feature answers 501 not_configured, never 500. A wrong value is a different failure with a different code: a misspelled Stripe key comes back as Stripe's own authentication error. Collapsing the two would make a typo read as "I guess I did not set up billing", which is the one message that stops somebody looking.

Key prefixes are checked at boot. keyLike() rejects a publishable key pasted into the secret slot, which is the most common setup mistake there is. Clerk's own middleware does check, but at request time, so without this the api boots happily and answers 500 on every route.

VITE_* values are statically replaced in browser code at build. Changing VITE_STRIPE_ENV or either PostHog value means rebuilding the web app, not restarting it. Only variables with the VITE_ prefix are exposed through import.meta.env; CLERK_SECRET_KEY remains available only to server code.

A blank PostHog project token disables analytics. The SDK is not imported or initialized, and the web server does not proxy /ffd-ph-gest. When enabled, the standard SDK captures browser analytics and uses its default local-storage and cookie persistence. PostHog project settings control additional remotely configured features.

VITE_STRIPE_ENV has no default, on purpose. It names the Stripe account whose prices the site displays, and the two candidate defaults are "show sandbox prices on a live storefront" and "show live prices nobody can buy". Vite's build mode cannot substitute for it because deployment mode and billing account are separate choices.

ADMIN_CLERK_IDS may be empty, and empty means this deployment has no admins. That is a valid closed state and the one a fresh clone is in. There is no table, no column and no UI behind it, so there is no way for an admin to make another admin.

WEB_URL and CORS_ORIGINS are not interchangeable. CORS_ORIGINS is the allowlist of origins permitted to call the api. WEB_URL is the single origin every link the api builds is built from: Stripe Checkout's success and cancel URLs, the Customer Portal's return URL, and the dashboard link in mail about a failed payment. It is used instead of the request's Origin header because that header is caller-controlled, and a mail has no request to read one from.

appConfig.siteUrl carries no trailing slash, and a preview deploy must not replace it. robots[.]txt.ts and the sitemap concatenate paths onto it, and pageHead() resolves canonical links against it. Point it at a preview host and that deployment publishes canonical URLs for a host you do not want indexed.

  • docs/adr/0013-api-hardening-baseline.md, why configuration failures are boot failures
  • docs/adr/0014-two-tier-env-schema.md, the required and optional split
  • docs/adr/0023-two-databases-in-one-neon-project.md, DB_NAME and DB_NAME_DEV
  • docs/adr/0012-slug-major-catalog-carries-prices.md, why the Stripe environment is a key prefix on one side and a variable on the other
  • docs/adr/0035-storefront-copy-is-code-config-holds-identity.md, why appConfig holds identity and not copy

Updated