Configuration
Thunderbolt’s backend is configured through environment variables. The schema lives at backend/src/config/settings.ts and is validated with Zod on startup — misconfiguration fails loud, not silent.
Copy the example to a .env file and customize:
cp backend/.env.example backend/.envVariables marked required must be set before the backend will start.
Database
| Variable | Default | Required | Description |
|---|---|---|---|
DATABASE_URL |
dev: postgresql://postgres:postgres@localhost:5433/postgres |
yes | Postgres connection string. Outside NODE_ENV=development the backend throws at import when it is unset and the driver is postgres. |
DATABASE_DRIVER |
postgres |
Set to pglite to run an embedded Postgres for backend-only work without Docker. PowerSync cannot replicate from PGlite, so sync is off. |
|
SKIP_MIGRATIONS |
unset | true skips the startup migration run, for deployments that migrate out of band. |
|
MIGRATIONS_DIR |
<cwd>/drizzle |
Override the Drizzle migrations folder. | |
POSTGRES_ADMIN_URL |
— | Read by deploy/docker/backend-entrypoint.sh, not the backend. When set, the entrypoint creates the logical database named in DATABASE_URL before migrating — the shared-Postgres PR-preview model. |
Under DATABASE_DRIVER=pglite, DATABASE_URL is read as a data directory path rather than a connection string (backend/.env.example uses .pglite/data). A value that still looks like a connection string is detected and ignored, with PGlite falling back to in-memory and logging a warning — otherwise an inherited postgresql://… would be treated as a path and bootstrap a data directory inside backend/. See backend/src/db/client.ts.
Authentication
| Variable | Default | Required | Description |
|---|---|---|---|
AUTH_MODE |
consumer |
consumer for magic-link + Google/Microsoft OAuth, oidc for OIDC SSO, saml for SAML SSO |
|
AUTH_ALLOW_ANONYMOUS |
false |
Registers Better Auth’s anonymous plugin. Off by default, so /v1/api/auth/sign-in/anonymous returns 404 — pair it with the frontend VITE_AUTH_ENABLE_ANONYMOUS overlay or the UI offers a route the server rejects |
|
BETTER_AUTH_SECRET |
— | yes | Non-empty string used to sign sessions. Generate with openssl rand -hex 32. |
BETTER_AUTH_URL |
http://localhost:8000 |
Public URL the backend is served at; used in OAuth redirects | |
TRUSTED_ORIGINS |
http://localhost:1420,tauri://localhost |
Comma-separated origins Better Auth accepts for callbacks and SSO discovery/metadata. Read straight from process.env, outside the Zod schema; tauri://localhost and the BETTER_AUTH_URL origin are always appended |
|
GOOGLE_CLIENT_ID |
— | Google OAuth client ID (consumer mode) | |
GOOGLE_CLIENT_SECRET |
— | Google OAuth client secret | |
MICROSOFT_CLIENT_ID |
— | Microsoft OAuth client ID | |
MICROSOFT_CLIENT_SECRET |
— | Microsoft OAuth client secret | |
OIDC_ISSUER |
— | OIDC issuer URL (required when AUTH_MODE=oidc) |
|
OIDC_DISCOVERY_URL |
${OIDC_ISSUER}/.well-known/openid-configuration |
Optional override for the OIDC discovery endpoint. Use when backend reaches the IdP at an internal hostname (e.g. http://keycloak:8080/...) but tokens are issued with a browser-facing hostname |
|
OIDC_CLIENT_ID |
— | OIDC client ID | |
OIDC_CLIENT_SECRET |
— | OIDC client secret | |
SAML_ENTRY_POINT |
— | SAML IdP SSO URL (required when AUTH_MODE=saml) |
|
SAML_ENTITY_ID |
— | SP entity ID — must match the SAML client ID in the IdP (e.g. thunderbolt-saml-sp) |
|
SAML_IDP_ISSUER |
— | IdP entity ID / issuer (e.g. https://keycloak.example.com/realms/thunderbolt) |
|
SAML_CERT |
— | SAML IdP signing certificate (base64, no PEM headers) | |
DEVICE_AUTH_EXPIRES_IN |
30m |
How long a device/user code from the RFC 8628 grant (used by the thunderbolt CLI) stays valid. Better Auth time string |
|
DEVICE_AUTH_INTERVAL |
5s |
Minimum polling gap the device grant asks clients to respect | |
API_KEY_DEFAULT_EXPIRES_IN |
7776000 |
Default personal-access-token lifetime, in seconds (90 days). Callers may request another supported lifetime at creation |
Consumer mode uses Better Auth’s magic-link flow by default (email-delivered OTP). Hook up a provider by also setting the OAuth credentials above. Enterprise mode delegates entirely to an OIDC or SAML identity provider (Keycloak by default). See OIDC local dev and SAML local dev for setup guides.
Important: When using AUTH_MODE=oidc or saml, the IdP origin must be included in TRUSTED_ORIGINS (the row above — this is a Better Auth setting, distinct from CORS_ORIGINS). The SSO plugin validates discovery/metadata URLs against this list. A containerized deploy needs both hostnames: the browser-facing issuer origin and the internal discovery host. deploy/docker-compose.yml is the worked example, setting TRUSTED_ORIGINS to the app origin plus http://localhost:${KEYCLOAK_PORT} plus http://keycloak:8080.
Desktop OAuth redirect URIs
The desktop app does not use the web redirect. It starts a loopback HTTP server and binds the first free port out of 17421, 17422, 17423 (src-tauri/src/commands.rs), so all three must be registered as redirect URIs in your Google and Microsoft OAuth consoles. There is deliberately no fallback to a random port — providers reject a redirect URI on an unregistered port, so bind_to_port in src-tauri/src/oauth_server.rs errors out instead of failing at the provider with a confusing message. Registering only the web redirect gives desktop users a hard sign-in failure.
AI Provider Keys
Set any subset; the app exposes each provider whose key is present.
| Variable | Default | Description |
|---|---|---|
ANTHROPIC_API_KEY |
— | Anthropic (Claude) |
FIREWORKS_API_KEY |
— | Fireworks |
EXA_API_KEY |
— | Exa search (for web-grounded retrieval) |
TINFOIL_API_KEY |
— | Tinfoil, the confidential (attested enclave) tier |
TINFOIL_ENCLAVE_URL |
https://inference.tinfoil.sh/v1 |
Enclave base URL. Include the /v1 prefix — Tinfoil’s OpenAI-compatible endpoints live under it |
The provider base URLs are otherwise fixed in backend/src/inference/client.ts; there is no backend variable for an OpenAI-compatible endpoint of your own. User-level keys (OpenAI, OpenRouter, and so on) are configured in the app, not as backend env vars, and the app’s Add Model form has a custom provider whose URL field is where a local Ollama or llama.cpp server goes — it prefills http://localhost:11434/v1.
Managed inference quotas
Managed inference is metered against rolling spend windows, in integer cents. Anonymous sessions get a much smaller allowance than registered accounts because an anonymous session costs an attacker nothing to create.
| Variable | Default |
|---|---|
INFERENCE_QUOTA_ANONYMOUS_5H_CENTS |
10 |
INFERENCE_QUOTA_ANONYMOUS_7D_CENTS |
60 |
INFERENCE_QUOTA_REGISTERED_5H_CENTS |
1500 |
INFERENCE_QUOTA_REGISTERED_7D_CENTS |
7500 |
Each must be a positive integer; the ledger that spends against them is backend/src/inference/usage-ledger.ts.
Agents
| Variable | Default | Description |
|---|---|---|
ENABLED_AGENTS |
"" |
Comma-separated agent IDs GET /v1/agents may expose. Empty means no filter — every registered provider is listed |
ALLOW_CUSTOM_AGENTS |
true |
false makes the discovery response report allowCustomAgents: false and the UI hides “Add Custom Agent” |
DISABLE_BUILT_IN_AGENT |
false |
true omits the built-in Thunderbolt agent from the client’s agent list entirely, for deployments shipping only their own agents |
HAYSTACK_BASE_URL |
— | Deepset/Haystack API base URL |
HAYSTACK_API_KEY |
— | Deepset/Haystack API key |
HAYSTACK_WORKSPACE |
— | Deepset workspace slug; request URLs are ${base}/api/v1/workspaces/${workspace}/... |
HAYSTACK_PIPELINES |
— | JSON array of pipeline descriptors: [{id, name, pipelineName, pipelineId, description?, icon?, supportedContent?}], validated on read |
PowerSync
| Variable | Default | Required | Description |
|---|---|---|---|
POWERSYNC_URL |
— | yes (for sync) | URL of the PowerSync service (e.g. http://localhost:8080 for local dev) |
POWERSYNC_JWT_SECRET |
— | yes when URL set | HS256 secret shared with PowerSync; must be ≥ 32 characters |
POWERSYNC_JWT_KID |
— | Key ID for PowerSync to pick among multiple secrets during rotation | |
POWERSYNC_TOKEN_EXPIRY_SECONDS |
3600 |
PowerSync JWT lifetime |
The JWT secret must match the k value the PowerSync service loads at runtime. For self-hosted deploys, deploy/config/powersync-config.yaml reads it from the PS_JWT_KEY_BASE64 env var (base64 of the raw secret); POWERSYNC_JWT_KID on the backend must match PS_JWT_KID set on the PowerSync service. For local dev, both values are baked into powersync-service/config/config.yaml.
End-to-End Encryption
| Variable | Default | Description |
|---|---|---|
E2EE_ENABLED |
false |
Requires each device to complete the trust flow before it may sync, and turns on client-side encryption of the columns in encryptedColumnsMap |
The backend is the only source of truth for this — the frontend reads it from GET /v1/config, so there is no matching frontend variable. See E2E encryption for the key hierarchy, the device-approval flows, and which columns are covered.
CORS
| Variable | Default | Description |
|---|---|---|
CORS_ORIGINS |
http://localhost:1420,tauri://localhost,http://tauri.localhost |
Exact-match allowed origins (comma-separated) |
CORS_ALLOW_CREDENTIALS |
true |
Whether browsers may send cookies |
CORS_ALLOW_METHODS |
GET,POST,PUT,DELETE,PATCH,OPTIONS |
Allowed HTTP methods |
CORS_ALLOW_HEADERS |
"" |
Legacy, unused. No production mount reads it; kept for backward compat and test fixtures |
CORS_EXPOSE_HEADERS |
(see settings.ts) | Response headers the browser makes readable to client code |
Request headers need no configuration. Both mounts — the main backend (backend/src/config/cors.ts) and the PostHog proxy (backend/src/posthog/routes.ts) — pass allowedHeaders: true, which echoes back whatever the browser asked for in Access-Control-Request-Headers. That is required by the universal proxy at /v1/proxy, which forwards arbitrary upstream headers as X-Proxy-Passthrough-*: a static allowlist would break preflight every time a new provider header appeared. So adding a new X-* header to a client request is not a CORS change.
The reverse is not true. A browser can only read a response header that is named in CORS_EXPOSE_HEADERS, so anything cross-origin client code needs to inspect must be added there.
Analytics
| Variable | Default | Description |
|---|---|---|
POSTHOG_HOST |
https://us.i.posthog.com |
PostHog instance hostname |
POSTHOG_API_KEY |
— | Leave unset to disable server-side analytics |
See TELEMETRY.md in the repo for the full list of events the client emits.
Debug Transcripts
Users can share a chat’s debug transcript with the Thunderbolt team from the chat view. The deployment never stores transcripts; it forwards them to the Thunderbolt intake with a key that identifies your deployment. The button is shown only when the relay is configured.
| Variable | Default | Description |
|---|---|---|
DEBUG_TRANSCRIPT_UPSTREAM_URL |
empty | Base URL of the Thunderbolt API that receives transcripts. Set together with the key. |
DEBUG_TRANSCRIPT_UPSTREAM_KEY |
empty | Your deployment’s client key, issued by the Thunderbolt team. Keep it server-side. |
DEBUG_TRANSCRIPT_INTAKE_ENABLED |
false |
Mounts the intake endpoint. Only the Thunderbolt-hosted deployment enables this. |
To obtain a key, contact the Thunderbolt team with a name for your deployment. Transcripts are identified (user id and email as known by your deployment; blank for anonymous users) and are kept by the Thunderbolt team; deleting the submitting account does not remove them.
Rate Limiting and Proxy Trust
| Variable | Default | Description |
|---|---|---|
RATE_LIMIT_ENABLED |
true |
Set to false to disable rate limiting (local dev only) |
TRUSTED_PROXY |
"" |
cloudflare trusts CF-Connecting-IP, akamai trusts True-Client-IP, empty trusts only the socket IP |
Trusting the wrong proxy header lets a client spoof its IP for rate-limit bypass. Leave this empty unless you know your edge.
The limits themselves are not configurable — they are hardcoded per tier in backend/src/middleware/rate-limit.ts:
| Tier | Limit |
|---|---|
inference |
60 per minute |
receipt |
100 per minute |
pro |
100 per minute |
auth |
10 per minute |
debug-transcript |
10 per hour |
debug-transcript-intake |
600 per hour |
Authenticated routes are keyed on user:<id>; unauthenticated ones on the client IP. IP keying fails closed: traffic whose IP cannot be resolved shares one ip:unknown bucket rather than skipping the limit, so the control guarding the abuse-prone unauthenticated endpoints (OTP send, waitlist join) cannot silently disable itself. Identifiable clients keep their own bucket and are unaffected.
Every limited response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset; a rejection is 429 with Retry-After. None of those are in the default CORS_EXPOSE_HEADERS, so cross-origin client code cannot read them unless you add them.
Rate limiting is also forced off in the backend test preload (backend/src/test-utils/test-setup.ts), because the limiter’s own transactions bypass PGlite’s test isolation and break cleanup. The middleware itself is still covered: backend/src/middleware/rate-limit.test.ts builds limiters with enabled: true against a separate isolated PGlite connection.
App Version Gate
| Variable | Default | Description |
|---|---|---|
MIN_APP_VERSION |
"" |
Minimum client version (semver). Empty disables the gate. When set, requests from older clients get 426 Upgrade Required, except on the exempt prefixes below. The value is semver-validated at startup, so a typo fails fast instead of reaching every client. |
The gate fails closed: a request with no X-App-Version header, or an unparseable one, is rejected exactly like an outdated client. Only the prefixes in appVersionExemptPrefixes (backend/src/middleware/app-version.ts) are waved through, because their callers cannot attach the header — /v1/config, /v1/health, /static, /v1/api/auth/sso, /v1/api/auth/device, /v1/posthog, /v1/proxy/ws (browsers cannot set headers on a WebSocket handshake) and /v1/debug-transcripts/intake (server-to-server). OPTIONS preflights are always exempt. Matching is on segment boundaries, so /v1/config never exempts a future /v1/configuration.
Non-browser API-key clients are not exempt by auth scheme: a personal access token hitting a gated route must send X-App-Version once you enable this.
Settings are read once at startup, so restart the backend after changing MIN_APP_VERSION — it is not picked up live.
CLI Device Rollout
| Variable | Default | Description |
|---|---|---|
MIN_APP_VERSION |
"" |
Minimum compatible app semver; empty disables client blocking |
CLI_DEVICE_REGISTRATION_ENABLED |
false |
Enables server-owned CLI device registration when set to true |
CONFIDENTIAL_API_KEYS_ENABLED |
false |
Lets a personal access token reach the confidential (Tinfoil) routes when set to true |
Rollout has three mandatory, old-client-safe stages:
- Existing clients first: ship web, desktop, and mobile schema/UI support
that can safely parse and display
deviceType: cli. If the compatible installed base cannot be guaranteed, enforce a minimum-version gate before enabling CLI registration on the backend. - Backend second: deploy the public catalog, CLI registration/logout, revocation enforcement, and managed inference routes. Changes to shared default-model or usage-receipt inputs must rebuild the backend image.
- CLI last: publish the native CLI artifacts only after the compatible existing clients and backend are live.
This remains backend-first relative to the CLI binary while preventing an older web, desktop, or mobile client from receiving a device type its schema or UI cannot handle. It also prevents a new CLI from discovering a catalog whose required auth or inference contracts the deployed backend does not yet implement.
Waitlist
| Variable | Default | Description |
|---|---|---|
WAITLIST_ENABLED |
false |
Inert. Parsed and validated, but nothing reads it — see below |
WAITLIST_AUTO_APPROVE_DOMAINS |
— | Comma-separated email domains that skip the queue; a matching address is approved on the spot |
WAITLIST_ENABLED does not switch the waitlist on or off. The gate in backend/src/auth/auth.ts runs unconditionally on the email-OTP path: an address with no user row and no approved waitlist entry gets a “joined” or “not ready” email instead of a code, and is rejected again at sign-in. settings.waitlistEnabled has no reader outside test fixtures. Several deployment configs still set it; the value has no effect either way.
WAITLIST_AUTO_APPROVE_DOMAINS is therefore the real escape hatch for a self-hosted consumer-mode deployment. On the client side the modal can be skipped at build time with VITE_BYPASS_WAITLIST=true (src/lib/auth-mode.ts), but that is a UI bypass baked into the bundle — the backend still gates the sign-in.
OpenTelemetry (Optional)
OpenTelemetry traces are enabled automatically when these are set. Not part of the Zod schema — the backend reads them from process.env directly.
| Variable | Description |
|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT |
OTLP HTTP endpoint (e.g. http://localhost:4318/v1/traces) |
OTEL_EXPORTER_OTLP_TOKEN |
Bearer token for authenticated collectors |
Tested with BetterStack, Jaeger, Zipkin, New Relic, Grafana Cloud, and any OTLP-compatible collector.
General
| Variable | Default | Description |
|---|---|---|
PORT |
8000 |
HTTP port the backend listens on |
HOST |
0.0.0.0 in production, localhost otherwise |
Interface the backend binds to |
WEB_CONCURRENCY |
CPU count in production, 1 otherwise |
Worker processes forked by backend/src/cluster.ts |
APP_URL |
http://localhost:1420 |
Public URL where the frontend is served |
LOG_LEVEL |
INFO |
One of DEBUG, INFO, WARN, ERROR |
SWAGGER_ENABLED |
false |
Expose /v1/swagger with the full OpenAPI spec (don’t in production) |
MONITORING_TOKEN |
— | Bearer token for deep health routes under /v1/health/ |
RESEND_API_KEY |
— | Resend key used to send transactional email (sign-in codes, waitlist). Unset logs a warning at boot and skips sends, which is the usual local-dev setup — but under NODE_ENV=production an unset key makes the send path throw Email service not configured |
RESEND_MONITORING_API_KEY |
— | Full access Resend key used only by /v1/health/email; the sending key RESEND_API_KEY may stay sending-only |
Deep health
Send Authorization: Bearer <MONITORING_TOKEN> to these GET routes:
| Route | Dependency exercised |
|---|---|
/v1/health/database |
A trivial database query (5-second deadline) |
/v1/health/powersync |
PowerSync’s /probes/liveness endpoint (5 seconds) |
/v1/health/email |
Resend’s authenticated domains read (10 seconds; sends no email) |
/v1/health/models |
Every catalog model, including attested, encrypted Tinfoil completions (20 seconds per model, concurrency 3) |
Success returns 200 {"status":"ok"}. Dependency failure returns 503 {"status":"failed","reason":"<code>"}; the models route instead returns {"status":"failed","failures":[{"model":"<catalog model>","reason":"no-text"}]}. Model failure reasons are no-text, timeout, upstream-error, missing-price, or not-configured; reasons never contain upstream bodies or credentials.
An unset token returns 403 {"error":"Monitoring token not configured"}; a missing or incorrect bearer returns 401 {"error":"Unauthorized"}. Rejected calls run no probes. The unconditional, unauthenticated /v1/health remains available for load balancers and liveness probes.
The email probe uses a separate Resend Full access key in RESEND_MONITORING_API_KEY to read the domain list and requires the sending domain from emailFrom to be verified. The sending key RESEND_API_KEY may stay sending-only. A missing monitoring key returns 503 with reason not-configured; an invalid, sending-only, or forbidden monitoring key returns rejected (upstream HTTP 400/401/403); a missing or unverified sending domain, including a malformed response, returns domain-unverified. Missing PowerSync configuration also returns 503 with reason not-configured.
Each models call costs one tiny completion per catalog model with a price row, without retries. BetterStack polls this route every 15 minutes in production.
The GET /v1/config Boot Contract
Several of the variables above are not read by the backend alone — they are published unauthenticated at GET /v1/config, which every client fetches at boot and caches in localStorage so it keeps working offline. The full payload (backend/src/api/config.ts):
| Field | Source |
|---|---|
e2eeEnabled |
E2EE_ENABLED |
debugTranscriptsEnabled |
Derived: true when DEBUG_TRANSCRIPT_UPSTREAM_URL is set |
builtInAgentEnabled |
Inverse of DISABLE_BUILT_IN_AGENT — the env var reads as an opt-in switch, the wire field as a positive capability |
allowCustomAgents |
ALLOW_CUSTOM_AGENTS |
minAppVersion |
MIN_APP_VERSION, omitted when unset so the client never parses '' as semver |
defaults.models |
{ version, defaultModelId, data } from the shipped default model set |
defaults is an over-the-air channel: the client compares the server’s version against its own bundled copy, so a change to the shipped defaults reaches existing installs without a client release. Because the payload is baked into the backend image, changing it means rebuilding and redeploying the backend.
Frontend Build Args
deploy/docker/frontend.Dockerfile exposes two Vite env vars as build args:
| Arg | Default | Purpose |
|---|---|---|
VITE_THUNDERBOLT_CLOUD_URL |
/v1 |
Backend API URL (relative path, proxied by nginx or ALB) |
VITE_AUTH_MODE |
sso |
Auth mode — sso for enterprise SSO (OIDC or SAML), omit for consumer |
Two further Vite variables are read by the app but are not wired as Dockerfile build args, so a custom build must pass them itself: VITE_AUTH_ENABLE_ANONYMOUS (the client half of AUTH_ALLOW_ANONYMOUS) and VITE_BYPASS_WAITLIST. Both are read through src/lib/auth-mode.ts and baked into the bundle at build time.
Validating Your Config
The backend validates every variable on startup. Common hits:
BETTER_AUTH_SECRET: String must contain at least 1 character(s)— set it.powersyncJwtSecret must be at least 32 characters when powersyncUrl is set— regenerate withopenssl rand -hex 32.AUTH_MODE: Invalid enum value— must beconsumer,oidc, orsaml(case-insensitive).