Bridge Training
Contributing

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

FrameworkClient prefixExample
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_* and NEXT_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, and MIGRATIONS_PATH are also inlined at build time via esbuild define.
  • Runtime (server): Variables without a prefix are read from process.env when the server process starts. They can be changed by editing the .env file 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 from process.env at startup and forwards it to the browser via an injected bt-runtime-env JSON 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 esbuild define in tsup.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)

VariableRequiredDefaultDescription
PORTNo3002Server port
BASE_PATHYes—Route prefix (e.g. /api)
MIGRATIONS_PATHNo../../packages/database/drizzlePath to Drizzle migrations

Runtime (secrets)

VariableRequiredDescription
DATABASE_URLYesPostgreSQL connection string
DATABASE_POOL_MAXNoConnections 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_URLYesSupabase REST API URL
SUPABASE_SERVICE_ROLE_KEYYesSupabase service role key (admin access)
CLERK_SECRET_KEYYesClerk backend secret key
API_KEYYesAPI key for service-to-service authentication
CORS_ORIGINSYesAllowed 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_SECRETNoClerk webhook signing secret — webhook routes disabled when not set
RESEND_API_KEYNoResend 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

VariableRequiredPhaseDescription
VITE_CLERK_PUBLISHABLE_KEYYesBuildClerk publishable key (starts with pk_)

App-specific

VariableRequiredPhaseDescription
VITE_API_MOCKNoBuild"true" embeds the scenario runner; never set for deployed builds
VITE_API_BASE_URLNoBuildAPI base URL override
VITE_FLAGSNoBuildFeature flags: comma-separated list or "*" for all
VITE_LOG_LEVELNoBuildLog level: debug, info, warn, error, silent
VITE_DEV_USER_IDSNoBuildComma-separated Clerk user IDs that see devtools (put in .env.production.local)
VITE_WEB_BASE_URLNoBuildMarketing site URL (default: https://bridge-training.com)
VITE_PLAYER_BASE_URLNoBuildDeal player URL
VITE_LEARN_URLNoBuildLearner space, linked from the screen an account signed up there gets instead of the app (default: https://learn.bridge-training.com)
VITE_E2ENoBuildSet to "true" to enable E2E testing mode
VITE_SUPPORT_EMAILNoBuildSupport email displayed in UI
VariableRequiredPhaseDescription
VITE_STRIPE_DONATE_ONETIME_3NoBuildOne-time 3€ donation link
VITE_STRIPE_DONATE_ONETIME_5NoBuildOne-time 5€ donation link
VITE_STRIPE_DONATE_ONETIME_10NoBuildOne-time 10€ donation link
VITE_STRIPE_DONATE_ONETIME_20NoBuildOne-time 20€ donation link
VITE_STRIPE_DONATE_ONETIME_50NoBuildOne-time 50€ donation link
VITE_STRIPE_DONATE_ONETIME_CUSTOMNoBuildCustom amount donation link
VITE_STRIPE_DONATE_MONTHLY_2NoBuildMonthly 2€ donation link
VITE_STRIPE_DONATE_MONTHLY_5NoBuildMonthly 5€ donation link
VITE_STRIPE_DONATE_MONTHLY_10NoBuildMonthly 10€ donation link
VITE_STRIPE_DONATE_MONTHLY_15NoBuildMonthly 15€ donation link
VITE_STRIPE_BILLING_PORTAL_URLNoBuildStripe billing portal for subscription management

Build modes

ModeCommandBehavior
Developmentpnpm dev:appAll logs, all flags, local API
Productionpnpm 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.

VariableRequiredPhaseDescription
VITE_CLERK_PUBLISHABLE_KEYYesBuildClerk publishable key (starts with pk_)
VITE_API_BASE_URLNoBuildAPI base URL (default: the local api)
VITE_WEB_BASE_URLNoBuildPublic site hosting the legal pages (default: https://bridge-training.com)
VITE_PLAYER_URLSNoBuildComma-separated players a deal return may lead back to (default: the SSR and the static player)
VITE_APP_URLNoBuildTeachers' app, linked from the screen an account signed up there gets instead of the learner plans (default: https://app.bridge-training.com)
VITE_PLAYER_URLSNoBuildPlayers a deal return may lead back to, comma-separated (default: the SSR and the static player)
VITE_KNOWN_VIEWER_COOKIE_DOMAINNoBuildParent domain of the known-viewer cookie the players read (.bridge-training.com in production); unset, the cookie stays on the current host
VITE_LOG_LEVELNoBuildLog level: debug, info, warn, error, silent
VITE_AXIOM_TOKEN / VITE_AXIOM_DATASET / VITE_AXIOM_EDGENoBuildBrowser 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.

VariableRequiredPhaseDescription
NEXT_PUBLIC_BASE_URLNoBuildSite base URL for sitemap and OG tags
NEXT_PUBLIC_SIGN_IN_URLNoBuildSign-in page URL
NEXT_PUBLIC_SIGN_UP_URLNoBuildSign-up page URL
NEXT_PUBLIC_EDITOR_URLNoBuildEditor app URL
NEXT_PUBLIC_STRIPE_DONATE_ONETIME_*NoBuildSame Stripe links as App, with NEXT_PUBLIC_ prefix
NEXT_PUBLIC_STRIPE_DONATE_MONTHLY_*NoBuildSame Stripe links as App, with NEXT_PUBLIC_ prefix
NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URLNoBuildStripe 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)

VariableRequiredDescription
VITE_PLAYER_API_BASE_PATHNoSame-origin API mount the browser calls (default /api)
VITE_PLAYER_DEALS_API_KEYNoX-API-Key sent with deal requests. Only for hosts calling the API cross-origin with no proxy (legacy) — public in the bundle
VITE_DEBUG_CONTROLSNoEnables in-app dev tooling; false tree-shakes it out
VITE_DEBUG_EVENT_PANELNoForces 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.

VariablePublicDescription
PLAYER_DEALS_API_URLNoUpstream deal API base
PLAYER_DEALS_API_KEYNoServer-to-server API key for the deal proxy
VITE_ADS_ENABLED / VITE_ADS_PROVIDER / VITE_ADS_DEBUGYesAds setup (@workspace/ads)
VITE_EZOIC_SITE_IDYesEzoic site id
VITE_STRIPE_DONATE_* / VITE_STRIPE_BILLING_PORTAL_URLYesSame Stripe links as App (@workspace/payment)
VITE_WEB_BASE_URLYesWeb app base URL for donate/legal links
VITE_LEARN_URLYesLearner 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_URLYesTeachers' 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_KEYYesClerk publishable key (pk_) that identifies a signed-in player on deal reads; empty means every player reads anonymously
VITE_KNOWN_VIEWER_COOKIE_DOMAINYesParent 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

FileRead byHolds
.envevery modeStripe links, common to all hosts
.env.developmentvite dev serverStripe test links, dev tooling on
.env.productionvite build (SSR image, local pnpm build)the SSR host's build-time flags (preprod ships demo and dev tooling)
.env.production.locallocal pnpm startdeal API pair for a local prod-mode run
.env.legacy / .env.legacy.localvite build --mode legacy, output in dist-legacyeverything 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.

VariableRequiredPhaseDescription
DATABASE_URLYesRuntimePostgreSQL connection string
SUPABASE_WORKDIRNoRuntimeSupabase 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

VariableRequiredPhaseDescription
CLERK_SECRET_KEYYesRuntimeClerk backend secret key
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEYYesBuildClerk publishable key (starts with pk_)

GitHub App

VariableRequiredPhaseDescription
GITHUB_OWNERYesRuntimeRepository owner (org or user)
GITHUB_REPOYesRuntimeRepository name
GITHUB_BASE_BRANCHYesRuntimeBase branch for translation PRs
GITHUB_APP_IDYesRuntimeGitHub App ID
GITHUB_APP_INSTALLATION_IDYesRuntimeGitHub App installation ID
GITHUB_APP_PRIVATE_KEYYesRuntimeGitHub App private key (PEM format)

Translations

VariableRequiredPhaseDescription
TRANSLATIONS_PACKAGE_PATHYesRuntimePath to translations package relative to repo root (e.g. packages/i18n-messages)
TRANSLATIONS_SOURCE_PATHYesRuntimePath to locale files relative to the translations package (e.g. src/locales)
TRANSLATIONS_SUPPORTED_LOCALESNoRuntimeComma-separated list of supported locales (e.g. en,fr)
TRANSLATIONS_BRANCH_PREFIXNoRuntimeBranch 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.

VariableRequiredPhaseDescription
DATABASE_URLYesRuntimePostgreSQL connection string
CLERK_SECRET_KEYFor Clerk commandsRuntimeClerk 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.

VariableRequiredPhaseDescription
API_HOSTYesDeploySSH host alias for API server
API_REMOTE_DIRYesDeployRemote directory for API deployment
API_PORTYesDeployAPI container exposed port
API_IMAGEYesDeployDocker image name for API
DOCS_HOSTYesDeploySSH host alias for Docs server
DOCS_REMOTE_DIRYesDeployRemote directory for Docs deployment
DOCS_PORTYesDeployDocs container exposed port
DOCS_IMAGEYesDeployDocker image name for Docs
API_URLNoDeployAPI URL (displayed after release)
DOCS_URLNoDeployDocs URL (displayed after release)

Override locally with .env.deploy.local (gitignored).


Validation schemas

Schemas are colocated with the packages that own the concern:

SchemaPackageUsed by
Admin env (incl. translations)apps/admin/src/lib/env.tsAdmin
createAuthEnvConfigpackages/auth/src/env.tsApp
createPaymentEnvConfigpackages/payment/src/env.tsApp, Web (server), Player (runtime injection)
getDatabaseEnvpackages/database/env.tsDatabase tooling: drizzle-kit configs, seed scripts, integration tests
Build envapps/api/tsup.config.tsAPI (inlined at build)
Runtime envapps/api/src/lib/env.tsAPI (validated at startup)
Web envapps/web/src/lib/env.tsWeb
App envapps/app/src/lib/env.tsApp