Environment Variables
Every variable, where it is used, and what it controls.
Last updated on
8 min readEvery variable the kit reads, grouped by purpose. A fresh clone runs with just three set (DATABASE_URL, NEXT_PUBLIC_APP_URL, and BETTER_AUTH_SECRET), and every optional group degrades cleanly when left blank (auth-only, billing-off, no-storage, no-analytics).
Env templates
The three .env templates that ship in the repo and where each one belongs.
Required vars
The minimum surface every deploy needs to boot.
How env is read
The two validated seams app code goes through instead of process.env.
Test and CI escapes
Determinism flags that are hard-blocked in production.
Before You Read
Three templates ship in the repo. Whichever you start from, the keys mean the same thing.
| Template | Copy to | What it's for |
|---|---|---|
apps/web/.env.example | apps/web/.env | Local dev and production. The canonical surface. |
apps/web/.env.test.example | apps/web/.env.test | CI and Playwright. Includes the test escapes documented at the bottom. |
.env.docker.example | .env (repo root) | Self-hosting via docker-compose.yml. Adds NEXT_SERVER_ACTIONS_ENCRYPTION_KEY for multi-instance deploys. |
packages/database/.env.example is a Prisma-CLI shim that points at apps/web/.env. Leave it alone.
Build-Time vs Runtime
Every NEXT_PUBLIC_* var is inlined into the JS bundle at build time, so changing one needs a rebuild on every host. Everything else is read at runtime. See Deployment build-time vs runtime env for the per-host guide.
How the Kit Reads Env
App code never reads process.env.<NAME> for these variables directly. It goes through two seams, both backed by the validated catalog in packages/shared/src/setup (the single source of truth):
- Server seam:
getServerEnv()andrequireServerEnv(name)from@syntaxkit/shared. They return resolved, validated values (URL/email/secret-length checks, theBETTER_AUTH_URL→NEXT_PUBLIC_APP_URLfallback).requireServerEnvthrows when a value is missing or invalid. Used in server code (routers, S3/Prisma/PostHog/Plunk/Redis clients, auth providers). - Client seam:
publicEnvandisAnalyticsEnabled()from@syntaxkit/shared/client. Captures eachNEXT_PUBLIC_*via a literalprocess.envreference so Next.js still inlines it, then derives client capability flags.apps/web/lib/env.tsre-exports it asenv.
What keeps the seam from being bypassed?
Two guards. A boot-time hard stop (assertValidSetupEnv, wired through apps/web/lib/env.server.ts) refuses to start a misconfigured production deploy. And an ESLint rule (no-restricted-syntax in @syntaxkit/eslint-config) blocks new direct process.env reads of the validated server vars. Operational vars (NODE_ENV, NEXT_RUNTIME, …) and NEXT_PUBLIC_* literals stay allowed.
Required For Any Deploy
The minimum surface every deploy needs.
| Variable | Required | What it controls |
|---|---|---|
DATABASE_URL | Always | Postgres connection string. Driver adapter is auto-selected (Neon serverless for *.neon.tech, @prisma/adapter-pg elsewhere). |
NEXT_PUBLIC_APP_URL | Always | Canonical app origin. Drives metadata, OG image, CSP, CORS, OAuth callbacks, and sitemap entries. |
BETTER_AUTH_SECRET | Always | Session cookie encryption. Generate with openssl rand -base64 32; minimum 32 characters. |
See Database and Authentication for deeper context.
| Variable | Required | What it controls |
|---|---|---|
EMAIL_DELIVERY_MODE | Optional | log (dev default), noop (CI), or plunk (production). Auto-resolves to plunk when PLUNK_API_KEY is set, noop when NODE_ENV=test, else log. Production rejects noop and log at boot: both report success without delivering, which would silently drop signups, resets, and invitations. |
PLUNK_API_KEY | When mode is plunk | Token for Plunk's transactional API. |
CONTACT_FORM_TO_EMAIL | Contact form enabled | Recipient for public contact-form submissions. |
EMAIL_OUTBOX_DIR | Optional | Override for the log-mode outbox. Defaults to .local/email-outbox/. |
See Email for delivery modes, templates, and the swap-providers walkthrough.
OAuth
Both halves of a provider pair must be set together. Missing either disables that provider gracefully: the button still renders, but disabled.
| Variable | Required | What it controls |
|---|---|---|
GITHUB_CLIENT_ID | GitHub OAuth enabled | Client id from github.com/settings/developers. |
GITHUB_CLIENT_SECRET | GitHub OAuth enabled | Matching app secret. |
GOOGLE_CLIENT_ID | Google OAuth enabled | Client id from console.cloud.google.com/apis/credentials. |
GOOGLE_CLIENT_SECRET | Google OAuth enabled | Matching client secret. |
Callback URLs must point at <NEXT_PUBLIC_APP_URL>/api/auth/callback/<provider>. See Authentication.
Captcha (Cloudflare Turnstile)
| Variable | Required | What it controls |
|---|---|---|
TURNSTILE_SECRET_KEY | Captcha enabled | Server-side secret used by Better Auth's captcha plugin and verifyTurnstileToken in the contact form. |
NEXT_PUBLIC_TURNSTILE_SITE_KEY | Captcha enabled | Public site key for the browser widget. |
Both must be set; either missing disables the captcha plugin and the contact-form check gracefully. See Security.
Stripe Billing
| Variable | Required | What it controls |
|---|---|---|
STRIPE_SECRET_KEY | Billing enabled | Server-side Stripe API key (sk_live_* in production, sk_test_* elsewhere). |
STRIPE_WEBHOOK_SECRET | Billing enabled | Verifies webhook signatures. The production secret differs from the dev / Stripe-CLI one. |
STRIPE_PRICE_ID_PRO_MONTHLY | Billing enabled | Price id for the monthly Pro plan. The kit fails at startup if either price id is missing. |
STRIPE_PRICE_ID_PRO_YEARLY | Billing enabled | Price id for the yearly Pro plan. |
STRIPE_AUTOMATIC_TAX | Optional | Set to "true" to enable Stripe Tax on Checkout (automatic_tax, tax_id_collection, customer_update, required billing address). Needs Stripe Tax activated with a registration in the Dashboard, or Checkout fails. Leave unset otherwise. |
See Billing for the plan catalog and Webhooks And Async Workflows for the inbound contract.
PostHog Analytics And Monitoring
The first three activate analytics, error tracking, and structured logs. Missing any one disables the integration cleanly. The rest enable optional features (reverse proxy, source-map upload).
| Variable | Required | What it controls |
|---|---|---|
NEXT_PUBLIC_POSTHOG_KEY | Analytics enabled | PostHog project API key. |
NEXT_PUBLIC_POSTHOG_HOST | Analytics enabled | Ingest host (e.g. https://us.i.posthog.com). |
NEXT_PUBLIC_POSTHOG_UI_HOST | Analytics enabled | UI host for session-replay deep links. |
POSTHOG_PROXY_INGEST_HOST | Optional | Reverse-proxy target for /ingest/*. Set both proxy hosts to route browser traffic through your origin. |
POSTHOG_PROXY_ASSET_HOST | Optional | Reverse-proxy target for /ingest/static/*. |
POSTHOG_API_KEY | Source-map upload | Personal API key with project scope. Read at build time by withPostHogConfig. |
POSTHOG_PROJECT_ID | Source-map upload | PostHog project id. Build-time only. |
See Analytics and Monitoring.
Object Storage (S3)
| Variable | Required | What it controls |
|---|---|---|
NEXT_PUBLIC_S3_PUBLIC_URL | Storage enabled | Public base URL used to compose served image URLs. |
NEXT_PUBLIC_S3_BUCKET_NAME_IMAGES | Storage enabled | Bucket the kit reads and writes. |
NEXT_PUBLIC_IMAGE_HOST_ALLOWLIST | Optional | Comma-separated extra hostnames accepted on user-supplied image URLs (org logos, avatars). Hosts from NEXT_PUBLIC_S3_PUBLIC_URL / AWS_ENDPOINT_URL_S3 plus built-in defaults (DiceBear, GitHub/Google/Gravatar) are always allowed; this is the escape hatch for extra CDN domains. |
AWS_ENDPOINT_URL_S3 | Non-AWS providers | Custom endpoint for Cloudflare R2, MinIO, etc. Leave blank for native AWS S3. |
AWS_REGION | Storage enabled | Region passed to the SDK. Defaults to "auto"; required by AWS S3. |
AWS_ACCESS_KEY_ID | Storage enabled | Credential for signing presigned URLs and finalize PUTs. |
AWS_SECRET_ACCESS_KEY | Storage enabled | Paired secret for the access key. |
AWS_S3_FORCE_PATH_STYLE | MinIO and similar | Set to "true" for path-style endpoints. AWS S3 and R2 use the default (false). |
See Storage for the upload pipeline, bucket config, and provider tabs.
Abuse Protection (Upstash Redis)
| Variable | Required | What it controls |
|---|---|---|
UPSTASH_REDIS_REST_URL | Yes in production | Upstash Redis REST URL. Both vars must be set together. |
UPSTASH_REDIS_REST_TOKEN | Yes in production | Upstash Redis REST token. |
Production won't boot without both; assertValidSetupEnv errors out otherwise. Non-production auto-bypasses missing values (one-time dev_no_upstash warning) so pnpm dev just works.
What happens when abuse protection is unavailable in production?
Every protected surface (uploads, chat, auth emails, and the contact form) fails closed with SERVICE_UNAVAILABLE. See Security for the full posture matrix and the DISABLE_ABUSE_PROTECTION non-production escape hatch.
Operational
Things that don't fit a single subsystem.
| Variable | Required | What it controls |
|---|---|---|
BETTER_AUTH_URL | Optional | Override the auto-derived auth origin. Usually unset. Better Auth derives it from NEXT_PUBLIC_APP_URL. |
NEXT_PUBLIC_DOCS_URL | Optional | Public docs URL for marketing footer and dashboard help links. |
TRUST_PROXY_HEADERS | Behind a load balancer | Set to "true" so Better Auth reads the real client IP from forwarded headers. Render sets this by default; Fly's edge-only setup doesn't need it. |
TRUSTED_PROXY_IP_HEADERS | Behind a load balancer | Comma-separated header names to trust (e.g. cf-connecting-ip, x-forwarded-for). |
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY | Multi-instance deploys | 32+ random bytes (openssl rand -base64 32) shared across replicas so server-action signatures decrypt across hosts. |
See Security operational secrets and Deployment for per-host placement.
Test And CI Escapes
Never set these in production. They exist for deterministic test runs only. The Security pre-launch checklist verifies each one before launch.
| Variable | Set in | What it controls |
|---|---|---|
DISABLE_ABUSE_PROTECTION | apps/web/.env.test, Playwright | Bypasses the Upstash abuse policy across every protected surface. Hard-blocked at boot in production. |
DISABLE_CAPTCHA_FOR_TESTS / NEXT_PUBLIC_DISABLE_CAPTCHA_FOR_TESTS | apps/web/.env.test, Playwright | Bypasses server-side Turnstile verification and the client widget so CI never waits on test-key roundtrips. Hard-blocked at boot in production. |
RUN_STRIPE_LIVE | Stripe live test suite | Set to "1" to opt in to pnpm test:stripe against the real Stripe test-mode API. Off ("0") by default. |
Better Auth's per-route rate limits also widen for tests, but with no env var: the widening is gated on NODE_ENV === "test" directly in packages/auth/src/server.ts, so production can never disable them.
There is no env var to bypass server-side validation at runtime. next build is detected via PHASE_PRODUCTION_BUILD and tests via NODE_ENV === "test", so build pipelines and test runs work without a flag, but a production server always boots through full validation and refuses to start on misconfiguration.
Where To Go Next
Setup
The env-driven first-run walkthrough that validates this whole list with pnpm setup:doctor.
Deployment
Build-time vs runtime split per host, plus per-platform setup for the operational vars.
Security
How each env-gated protection fits the abuse, signature, and session stack.
Going To Production
The pre-launch checklist that ticks every env-gated category, including the OAuth callback audit.
Commands And Scripts
setup:doctor and the auth:generate / admin:bootstrap scripts that consume this surface.
