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, ffd 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 |
GITHUB_TOKEN |
no | a token that may add collaborators, and checks a username before checkout | GitHub, Developer settings |
GITHUB_REPO_OWNER |
no | the organization that owns the repo | your fork |
GITHUB_REPO_NAME |
no | the repository name | your fork |
Copy apps/api/.env.example to apps/api/.env. 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 |
Copy apps/web/.env.example to apps/web/.env.local. Clerk uses its secret key
on the web server for identity handling. Stripe, database, email and Fulfillment
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 buyer whose product has no billing, no email or no GitHub invite 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.
GITHUB_REPO_OWNER must name an organization. GitHub honors the requested
pull permission only for organization-owned repositories. A personal-account
repository silently gives collaborators write access instead; see
docs/adr/0026-org-owned-repo-for-read-only-buyers.md.
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