Environments
Environment variables required by each app, validation schemas, and file conventions.
Overview
Environment variables are validated at startup using t3-oss/env with Zod schemas. If a required variable is missing or malformed, the app fails immediately with a clear error.
Shared env concerns live in their packages as prefix-agnostic factories — createAuthEnvConfig and createPaymentEnvConfig(runtimeEnv, { prefix }) — called by each host with its own env source and public-var prefix.
Prefix conventions
| Framework | Client prefix | Example |
|---|---|---|
| Vite (App, Learn, Editor, Player) | VITE_ | VITE_CLERK_PUBLISHABLE_KEY |
| Next.js (Web) | NEXT_PUBLIC_ | NEXT_PUBLIC_BASE_URL |
| Server (API) | (none) | DATABASE_URL |
Only prefixed variables are exposed to the browser. Server-side variables without a prefix stay on the server.
Build-time vs runtime
Variables behave differently depending on how the app is built:
- Build-time (inlined):
VITE_*andNEXT_PUBLIC_*variables are replaced with their values during the build step. They are baked into the JavaScript bundle and cannot be changed after build. This applies to the static apps (App, Editor, Web). For the API,PORT,BASE_PATH, andMIGRATIONS_PATHare also inlined at build time via esbuilddefine. - Runtime (server): Variables without a prefix are read from
process.envwhen the server process starts. They can be changed by editing the.envfile and restarting the container. This applies to Docker-based apps (API, Docs). - Runtime (injected): The Player's public config keeps the
VITE_prefix but is not inlined: the SSR host reads it fromprocess.envat startup and forwards it to the browser via an injectedbt-runtime-envJSON element (apps/player/src/lib/runtime-env.ts). See the Player section.
API (apps/api)
The API uses a split environment configuration:
- Build-time (
build-env.ts): Infrastructure config inlined into the bundle via esbuilddefineintsup.config.ts. Validated with Zod at build time. - Runtime (
env.ts): Secrets and per-environment config validated with t3-env at startup.
The migration entrypoint (migrate.ts) only depends on build-time config and DATABASE_URL, keeping it isolated from other service secrets.
Build-time (inlined)
| Variable | Required | Default | Description |
|---|---|---|---|
PORT | No | 3002 | Server port |
BASE_PATH | Yes | — | Route prefix (e.g. /api) |
MIGRATIONS_PATH | No | ../../packages/database/drizzle | Path to Drizzle migrations |
Runtime (secrets)
| Variable | Required | Description |
|---|---|---|
DATABASE_URL | Yes | PostgreSQL connection string |
DATABASE_POOL_MAX | No | Connections the API opens at most (default 10). Behind the Supabase transaction pooler, keep it plus the admin's below the pooler's pool size: a query left waiting there too long kills its connection |
SUPABASE_URL | Yes | Supabase REST API URL |
SUPABASE_SERVICE_ROLE_KEY | Yes | Supabase service role key (admin access) |
CLERK_SECRET_KEY | Yes | Clerk backend secret key |
API_KEY | Yes | API key for service-to-service authentication |
CORS_ORIGINS | Yes | Allowed origins (comma-separated); also the checkout and portal return URLs. In production it lives only in the remote .env the container reads, never in the committed .env.production |
CLERK_WEBHOOK_SECRET | No | Clerk webhook signing secret — webhook routes disabled when not set |
RESEND_API_KEY | No | Resend API key — transactional emails disabled when not set |
Template: apps/api/.env.local
App (apps/app)
All variables are build-time (VITE_ prefixed) — inlined into the bundle during pnpm build:app. Auth and payment configs come from the shared factories (createAuthEnvConfig / createPaymentEnvConfig with import.meta.env and the VITE_ prefix — payment is provided to the tree via <PaymentProvider>); the app-specific variables are validated by a local Zod schema with defaults and parsing in-schema.
Core
| Variable | Required | Phase | Description |
|---|---|---|---|
VITE_CLERK_PUBLISHABLE_KEY | Yes | Build | Clerk publishable key (starts with pk_) |
App-specific
| Variable | Required | Phase | Description |
|---|---|---|---|
VITE_API_MOCK | No | Build | "true" embeds the scenario runner; never set for deployed builds |
VITE_API_BASE_URL | No | Build | API base URL override |
VITE_FLAGS | No | Build | Feature flags: comma-separated list or "*" for all |
VITE_LOG_LEVEL | No | Build | Log level: debug, info, warn, error, silent |
VITE_DEV_USER_IDS | No | Build | Comma-separated Clerk user IDs that see devtools (put in .env.production.local) |
VITE_WEB_BASE_URL | No | Build | Marketing site URL (default: https://bridge-training.com) |
VITE_PLAYER_BASE_URL | No | Build | Deal player URL |
VITE_LEARN_URL | No | Build | Learner space, linked from the screen an account signed up there gets instead of the app (default: https://learn.bridge-training.com) |
VITE_E2E | No | Build | Set to "true" to enable E2E testing mode |
VITE_SUPPORT_EMAIL | No | Build | Support email displayed in UI |
Stripe donation links
| Variable | Required | Phase | Description |
|---|---|---|---|
VITE_STRIPE_DONATE_ONETIME_3 | No | Build | One-time 3€ donation link |
VITE_STRIPE_DONATE_ONETIME_5 | No | Build | One-time 5€ donation link |
VITE_STRIPE_DONATE_ONETIME_10 | No | Build | One-time 10€ donation link |
VITE_STRIPE_DONATE_ONETIME_20 | No | Build | One-time 20€ donation link |
VITE_STRIPE_DONATE_ONETIME_50 | No | Build | One-time 50€ donation link |
VITE_STRIPE_DONATE_ONETIME_CUSTOM | No | Build | Custom amount donation link |
VITE_STRIPE_DONATE_MONTHLY_2 | No | Build | Monthly 2€ donation link |
VITE_STRIPE_DONATE_MONTHLY_5 | No | Build | Monthly 5€ donation link |
VITE_STRIPE_DONATE_MONTHLY_10 | No | Build | Monthly 10€ donation link |
VITE_STRIPE_DONATE_MONTHLY_15 | No | Build | Monthly 15€ donation link |
VITE_STRIPE_BILLING_PORTAL_URL | No | Build | Stripe billing portal for subscription management |
Build modes
| Mode | Command | Behavior |
|---|---|---|
| Development | pnpm dev:app | All logs, all flags, local API |
| Production | pnpm build:app (run by pnpm release:app) | Minified, .env.production + .env.production.local, default log level warn, devtools gated by VITE_DEV_USER_IDS |
Template: apps/app/.env.example
Learn (apps/learn)
The learner space. All variables are build-time (VITE_ prefixed), validated by src/lib/env.ts. The publishable key is the same Clerk instance as the App's: the session is shared across the parent domain.
| Variable | Required | Phase | Description |
|---|---|---|---|
VITE_CLERK_PUBLISHABLE_KEY | Yes | Build | Clerk publishable key (starts with pk_) |
VITE_API_BASE_URL | No | Build | API base URL (default: the local api) |
VITE_WEB_BASE_URL | No | Build | Public site hosting the legal pages (default: https://bridge-training.com) |
VITE_PLAYER_URLS | No | Build | Comma-separated players a deal return may lead back to (default: the SSR and the static player) |
VITE_APP_URL | No | Build | Teachers' app, linked from the screen an account signed up there gets instead of the learner plans (default: https://app.bridge-training.com) |
VITE_PLAYER_URLS | No | Build | Players a deal return may lead back to, comma-separated (default: the SSR and the static player) |
VITE_KNOWN_VIEWER_COOKIE_DOMAIN | No | Build | Parent domain of the known-viewer cookie the players read (.bridge-training.com in production); unset, the cookie stays on the current host |
VITE_LOG_LEVEL | No | Build | Log level: debug, info, warn, error, silent |
VITE_AXIOM_TOKEN / VITE_AXIOM_DATASET / VITE_AXIOM_EDGE | No | Build | Browser log forwarding, token and dataset together or neither |
Production values go in apps/learn/.env.production.local (gitignored). The API must list the app's origin in CORS_ORIGINS: checkout and portal return URLs are validated against the same list.
Template: apps/learn/.env.example
Web (apps/web)
All variables are build-time (NEXT_PUBLIC_ prefixed) — inlined during next build. The Stripe vars are read server-side by @workspace/payment/service-nextjs during prerender and passed to client components as props, so they must be present at build time.
| Variable | Required | Phase | Description |
|---|---|---|---|
NEXT_PUBLIC_BASE_URL | No | Build | Site base URL for sitemap and OG tags |
NEXT_PUBLIC_SIGN_IN_URL | No | Build | Sign-in page URL |
NEXT_PUBLIC_SIGN_UP_URL | No | Build | Sign-up page URL |
NEXT_PUBLIC_EDITOR_URL | No | Build | Editor app URL |
NEXT_PUBLIC_STRIPE_DONATE_ONETIME_* | No | Build | Same Stripe links as App, with NEXT_PUBLIC_ prefix |
NEXT_PUBLIC_STRIPE_DONATE_MONTHLY_* | No | Build | Same Stripe links as App, with NEXT_PUBLIC_ prefix |
NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL | No | Build | Stripe billing portal |
Template: apps/web/.env.example
Player (apps/player)
Two distinct halves — see apps/player/.env.example for the full annotated list:
Build-time (inlined)
| Variable | Required | Description |
|---|---|---|
VITE_PLAYER_API_BASE_PATH | No | Same-origin API mount the browser calls (default /api) |
VITE_PLAYER_DEALS_API_KEY | No | X-API-Key sent with deal requests. Only for hosts calling the API cross-origin with no proxy (legacy) — public in the bundle |
VITE_DEBUG_CONTROLS | No | Enables in-app dev tooling; false tree-shakes it out |
VITE_DEBUG_EVENT_PANEL | No | Forces the game-event debug panel on |
Runtime (SSR host)
Read from process.env when the SSR host starts. In production these live in the remote deploy dir's .env (compose env_file), not in the image — changing them is a container restart, not a rebuild. Public VITE_* values are forwarded to the browser via the injected bt-runtime-env JSON element; the plain Vite dev server has no SSR host, so the client falls back to import.meta.env in dev.
| Variable | Public | Description |
|---|---|---|
PLAYER_DEALS_API_URL | No | Upstream deal API base |
PLAYER_DEALS_API_KEY | No | Server-to-server API key for the deal proxy |
VITE_ADS_ENABLED / VITE_ADS_PROVIDER / VITE_ADS_DEBUG | Yes | Ads setup (@workspace/ads) |
VITE_EZOIC_SITE_ID | Yes | Ezoic site id |
VITE_STRIPE_DONATE_* / VITE_STRIPE_BILLING_PORTAL_URL | Yes | Same Stripe links as App (@workspace/payment) |
VITE_WEB_BASE_URL | Yes | Web app base URL for donate/legal links |
VITE_LEARN_URL | Yes | Learner space base URL, where « Jouer sans publicité » (/welcome, with the deal as the return), « Se connecter » (/resume) and the « Mon espace » of an account signed up there lead. Optional: without it the player shows no link to the learner space; the SSR host warns at startup in production |
VITE_APP_URL | Yes | Teachers' app, where the « Mon espace » of an account signed up there leads. Optional: without it that item doesn't render for those accounts |
VITE_CLERK_PUBLISHABLE_KEY | Yes | Clerk publishable key (pk_) that identifies a signed-in player on deal reads; empty means every player reads anonymously |
VITE_KNOWN_VIEWER_COOKIE_DOMAIN | Yes | Parent domain of the known-viewer cookie a signed-in player leaves (.bridge-training.com in production); unset, the cookie stays on the player's host |
Static build (legacy host)
The legacy host has no runtime .env: pnpm release:player-legacy builds in Vite's legacy mode, which reads apps/player/.env.legacy (committed defaults: base path, API base, ads on, demo and debug off) then apps/player/.env.legacy.local (gitignored: deal API key, Ezoic site, Axiom, telemetry, Clerk key…) and bakes every value into the bundle, so changing one is a re-release. The runtime keys above are set the same way for that host — VITE_CLERK_PUBLISHABLE_KEY among them; left empty, every player of the static bundle reads anonymously.
Which file for which build
| File | Read by | Holds |
|---|---|---|
.env | every mode | Stripe links, common to all hosts |
.env.development | vite dev server | Stripe test links, dev tooling on |
.env.production | vite build (SSR image, local pnpm build) | the SSR host's build-time flags (preprod ships demo and dev tooling) |
.env.production.local | local pnpm start | deal API pair for a local prod-mode run |
.env.legacy / .env.legacy.local | vite build --mode legacy, output in dist-legacy | everything the static bundle bakes in |
.env.deploy only knows hosts, remote dirs, ports and image names.
Template: apps/player/.env.example
Database (packages/database)
Used by Drizzle Kit and Supabase CLI for migrations and local development.
| Variable | Required | Phase | Description |
|---|---|---|---|
DATABASE_URL | Yes | Runtime | PostgreSQL connection string |
SUPABASE_WORKDIR | No | Runtime | Supabase CLI working directory (default: ./supabase) |
Template: packages/database/.env (committed, local Supabase). Its .env.production (template .env.production.example) serves drizzle-kit against production only; the operations on production data read apps/cli/.env.production.local (CLI).
Translations (apps/admin)
The translations tool lives in the Admin app (apps/admin), which mixes build-time (NEXT_PUBLIC_ prefixed) and runtime server variables. Validated with @t3-oss/env-nextjs. Uses a GitHub App for branch/PR management.
Auth
| Variable | Required | Phase | Description |
|---|---|---|---|
CLERK_SECRET_KEY | Yes | Runtime | Clerk backend secret key |
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY | Yes | Build | Clerk publishable key (starts with pk_) |
GitHub App
| Variable | Required | Phase | Description |
|---|---|---|---|
GITHUB_OWNER | Yes | Runtime | Repository owner (org or user) |
GITHUB_REPO | Yes | Runtime | Repository name |
GITHUB_BASE_BRANCH | Yes | Runtime | Base branch for translation PRs |
GITHUB_APP_ID | Yes | Runtime | GitHub App ID |
GITHUB_APP_INSTALLATION_ID | Yes | Runtime | GitHub App installation ID |
GITHUB_APP_PRIVATE_KEY | Yes | Runtime | GitHub App private key (PEM format) |
Translations
| Variable | Required | Phase | Description |
|---|---|---|---|
TRANSLATIONS_PACKAGE_PATH | Yes | Runtime | Path to translations package relative to repo root (e.g. packages/i18n-messages) |
TRANSLATIONS_SOURCE_PATH | Yes | Runtime | Path to locale files relative to the translations package (e.g. src/locales) |
TRANSLATIONS_SUPPORTED_LOCALES | No | Runtime | Comma-separated list of supported locales (e.g. en,fr) |
TRANSLATIONS_BRANCH_PREFIX | No | Runtime | Branch name prefix for translation PRs (default: translations) |
Template: apps/admin/.env.example
CLI (apps/cli)
Operations CLI (pnpm cli <command>). The global --env local|prod picks the files it loads from apps/cli: .env.local for local (the default), .env.production.local then .env.production for prod. A variable set in the shell wins over the files. Each command validates only the variables of the adapters it uses.
| Variable | Required | Phase | Description |
|---|---|---|---|
DATABASE_URL | Yes | Runtime | PostgreSQL connection string |
CLERK_SECRET_KEY | For Clerk commands | Runtime | Clerk backend secret key (sync-clerk-to-db) |
Templates: apps/cli/.env.example (local), apps/cli/.env.production.example (prod)
The production credentials of the operations live in apps/cli/.env.production.local, gitignored like every .env* of apps/cli but the templates; the former packages/database ops scripts are commands here now (#1774), so their :prod env no longer serves them. The commands whose services take a ServiceContext get it from the global --actor <accountId>, never from the env.
Deploy (/.env.deploy)
Used by release scripts only. See Deployment for details.
| Variable | Required | Phase | Description |
|---|---|---|---|
API_HOST | Yes | Deploy | SSH host alias for API server |
API_REMOTE_DIR | Yes | Deploy | Remote directory for API deployment |
API_PORT | Yes | Deploy | API container exposed port |
API_IMAGE | Yes | Deploy | Docker image name for API |
DOCS_HOST | Yes | Deploy | SSH host alias for Docs server |
DOCS_REMOTE_DIR | Yes | Deploy | Remote directory for Docs deployment |
DOCS_PORT | Yes | Deploy | Docs container exposed port |
DOCS_IMAGE | Yes | Deploy | Docker image name for Docs |
API_URL | No | Deploy | API URL (displayed after release) |
DOCS_URL | No | Deploy | Docs URL (displayed after release) |
Override locally with .env.deploy.local (gitignored).
Validation schemas
Schemas are colocated with the packages that own the concern:
| Schema | Package | Used by |
|---|---|---|
| Admin env (incl. translations) | apps/admin/src/lib/env.ts | Admin |
createAuthEnvConfig | packages/auth/src/env.ts | App |
createPaymentEnvConfig | packages/payment/src/env.ts | App, Web (server), Player (runtime injection) |
getDatabaseEnv | packages/database/env.ts | Database tooling: drizzle-kit configs, seed scripts, integration tests |
| Build env | apps/api/tsup.config.ts | API (inlined at build) |
| Runtime env | apps/api/src/lib/env.ts | API (validated at startup) |
| Web env | apps/web/src/lib/env.ts | Web |
| App env | apps/app/src/lib/env.ts | App |