Environment variables
What each one does, which are required, and which fail quietly when wrong.
Everything lives in one .env at the repo root. Bun and Next only
auto-load a .env from the process's own working directory, and Turbo runs
each app with its cwd set to that app, so every entrypoint imports
@workspace/env/load-root-env first. That helper walks up to eight levels
looking for the nearest .env and never overwrites a variable that is already
set, so real environment variables and CI-passed values always win.
Validation is Zod, in packages/env/src/server.ts and client.ts. Anything
required is checked at import time and fails the process with a readable error
rather than a runtime undefined.
Required
Three, and nothing starts without them.
| Variable | Notes |
|---|---|
BETTER_AUTH_SECRET | openssl rand -base64 32. Changing it invalidates every existing session. |
NEXT_PUBLIC_APP_URL | The origin, with no /api suffix. |
NEXT_PUBLIC_API_URL | The origin plus /api. |
The two URLs are the single most expensive thing to get wrong. Better Auth
appends /api/auth itself, and the Eden treaty client already carries
/api/v1. Point NEXT_PUBLIC_APP_URL at a URL that ends in /api and every
request goes to /api/api/auth/... and 404s, with nothing in the logs saying
why.
In production, web, admin and api sit behind one proxy: APP_URL is the single
origin, API_URL is that origin plus /api. In local development the apps run
on separate ports, and APP_URL points at the API origin so auth and treaty
calls reach the handler.
Fails quietly when wrong
These validate fine and change behaviour. Each one has cost somebody an afternoon.
| Variable | Symptom when wrong |
|---|---|
TRUSTED_ORIGINS | Feeds Better Auth's trusted origins. Disagreeing with the real host turns a correct password into "invalid credentials". |
CORS_ORIGINS | Feeds @elysiajs/cors. Unset falls back to the localhost dev ports, which looks fine locally and blocks everything in production. |
USE_SECURE_COOKIES | Must be true behind HTTPS. Read by both packages/auth and the admin's cookie-name lookup, so a mismatch means the admin cannot find the session it was just issued. |
REDIS_DB | Shared Redis with a shared index means one app's FLUSHDB empties another's queues. |
S3_ENDPOINT | Wins over the R2_* values. Leftover R2 credentials plus a set endpoint writes test objects locally, which is the intended safety, but the reverse surprises people. |
Data stores
| Variable | Default | Notes |
|---|---|---|
POSTGRES_HOST | localhost | |
POSTGRES_PORT | 5432 | |
POSTGRES_USER | postgres | |
POSTGRES_PASSWORD | postgres | |
POSTGRES_DB | shipkit | |
REDIS_HOST | localhost | |
REDIS_PORT | 6379 | |
REDIS_PASSWORD | none | |
REDIS_DB | 0 | Give each app its own index |
REDIS_TLS | false | |
REDIS_MAX_RETRIES | 3 | |
REDIS_CONNECT_TIMEOUT | 10000 | ms |
REDIS_KEEPALIVE | 30000 | ms |
Pool tuning is optional and read only where it is used: DB_POOL_MAX,
DB_POOL_MIN, DB_IDLE_TIMEOUT, DB_CONNECT_TIMEOUT, DB_MAX_LIFETIME,
DB_STATEMENT_TIMEOUT, DB_IDLE_IN_TRANSACTION_TIMEOUT, DB_QUERY_TIMEOUT.
Optional integrations
Each block is inert until its variables are set. Nothing here blocks a first run.
| Feature | Variables | Behaviour when unset |
|---|---|---|
| Google sign-in | GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET | Button hidden. Both are needed; one alone does nothing. |
| GitHub sign-in | GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET | Same |
| Payments | POLAR_ACCESS_TOKEN, POLAR_WEBHOOK_SECRET, POLAR_ORGANIZATION_ID, POLAR_PRODUCT_ID_* | Checkout unavailable |
| Grace period | ENTITLEMENTS_GRACE_DAYS | Defaults to 7 days after a payment lapses |
RESEND_API_KEY, EMAIL_FROM | No mail sent; magic-link sign-in stays off | |
| Files | S3_* or R2_* | getStorage() throws a named error listing the variables |
| AI | ANTHROPIC_API_KEY or GOOGLE_GENERATIVE_AI_API_KEY, AI_CHAT_MODEL, AI_EMBEDDING_MODEL | AI routes unavailable |
| Errors | SENTRY_DSN, NEXT_PUBLIC_SENTRY_DSN, SENTRY_RELEASE, SENTRY_TRACES_SAMPLE_RATE | Nothing reported |
| Analytics | NEXT_PUBLIC_POSTHOG_KEY, NEXT_PUBLIC_POSTHOG_HOST, POSTHOG_API_KEY, POSTHOG_HOST | Nothing tracked. The server key falls back to the public one. |
| Logging | LOG_LEVEL | info in production, debug otherwise |
| Seeding | SEED_ADMIN_EMAIL, SEED_ADMIN_PASSWORD, SEED_ADMIN_NAME | Demo defaults, and the seed refuses to run them in production |
Declared but not wired
OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_SERVICE_NAME validate and can be
passed through, but no exporter ships and nothing emits spans. They are a
reservation, not a feature. See observability.
Build-time against run-time
NEXT_PUBLIC_* variables are inlined into the client bundle at build time.
Changing one in the environment of a running process does nothing; the app has
to be rebuilt. The same applies to NODE_ENV, which Bun inlines, and which is
why the deployment guide insists it be exported
before the first build command rather than only in the service file.