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.
Related
docs/adr/0013-api-hardening-baseline.md, why configuration failures are boot failuresdocs/adr/0014-two-tier-env-schema.md, the required and optional splitdocs/adr/0023-two-databases-in-one-neon-project.md,DB_NAMEandDB_NAME_DEVdocs/adr/0012-slug-major-catalog-carries-prices.md, why the Stripe environment is a key prefix on one side and a variable on the otherdocs/adr/0035-storefront-copy-is-code-config-holds-identity.md, whyappConfigholds identity and not copy
