Skip to content
BoringStack
Star

Environment variables

4 min read

The stack reads three sets of environment files with this precedence order:

  1. apps/api/.env: consumed by the API process when running outside Docker
  2. compose/.env: injected into dev containers via env_file: references
  3. compose/api.prod.env: API-specific production overrides

In Docker Compose (the default), compose/.env is the single file you edit most often. It feeds into both app containers and the infra services (Postgres, Valkey, Traefik, observability).

LocationConsumed byFormat
apps/api/.envAPI Bun process (outside Docker)All vars
apps/ui/.env.localVite at build/dev timeVITE_* only
compose/.envall services via docker-compose.ymlsee docker-compose.yml env_file refs
compose/api.prod.envAPI service in prodAPI vars + STACK=prod-specific

These deserve special handling because they affect security, availability, or startup:

JWT_SECRET: Signs access cookies and refresh tokens. Rotation forces every user to sign in again. Generate with openssl rand -base64 48.

MFA_ENCRYPTION_KEY: AES-256-GCM key for TOTP secrets. Once users enrol, losing this key means every MFA user re-enrols from scratch. Generate with openssl rand -base64 32 and back up to hardware or paper.

POSTGRES_PASSWORD / VALKEY_PASSWORD: Data plane secrets. Rotate in production by updating the var, then restarting services. Database passwords should be 32+ random bytes. VALKEY_PASSWORD is required in production — the Valkey server itself enforces it via --requirepass, and the API’s env validator refuses to boot without it whenever queues, the Valkey cache, SSE notifications, or OAuth are enabled. Leaving it empty (the dev default) disables Valkey auth for local convenience only.

DATABASE_URL: Postgres connection string. Must include TLS mode for production (?ssl=require). In development defaults to localhost; in production, points to a managed provider or socket.

PUBLIC_UI_HOST: Hostname for the app in production, app.<domain> in the default split layout. Traefik uses this to request Let’s Encrypt certificates and routes /api/* and /health on this host to the API, everything else to the UI. Same-origin: the app and its API share one host, so no CORS and cookies stay on the app host. With WITH_SITE=0, set it to the apex instead to keep the single-domain layout.

FRONTEND_URL, PUBLIC_API_URL: In the split layout both are https://app.<domain>. FRONTEND_URL is where OAuth callbacks, Stripe return URLs and email links point. PUBLIC_API_URL is the origin only; the API appends /api/v1.

PUBLIC_SITE_HOST: Apex hostname for the public marketing site in production (for example example.com). Required when WITH_SITE=1. Must differ from PUBLIC_UI_HOST.

SITE_CONTACT_EMAIL, SITE_IMAGE_TAG: Required in production when WITH_SITE=1. The contact mailbox shown on the site, and a pinned image tag (semver or sha-<digest>, never latest). SITE_IMAGE can replace the tag with a full pinned reference.

EMAIL_PROVIDER + EMAIL_FROM: Sender identity and service. Cloudflare is the default; Resend and SendGrid are drop-in alternatives. Set both to avoid bounce errors.

For defaults, type hints, and complete enumeration:

The .env.example files define every variable and its default. When you need to understand a variable’s default or type, start there.

Optional features (toggle by setting vars)

Section titled “Optional features (toggle by setting vars)”
  • Billing: set BILLING_ENABLED=true + Stripe keys
  • OAuth: set GOOGLE_OAUTH_CLIENT_ID / CLIENT_SECRET or GitHub/LinkedIn equivalents
  • Observability: set WITH_OBSERVABILITY=0 to opt out (on by default)
  • Error tracking: set WITH_GLITCHTIP=0 to opt out (on by default)
  • Queue dashboard: set WITH_BULLMQ=1 (dev only, no auth)
  • Email catcher: set WITH_MAILPIT=1 (dev only)
  • Public marketing site: WITH_SITE is on in dev and prod. Dev serves it on :7333. Prod needs PUBLIC_SITE_HOST, SITE_CONTACT_EMAIL and SITE_IMAGE_TAG. Set WITH_SITE=0 to opt out. See Public site