Skip to content

Configuration

Configuration is plain environment files, one per process. The API validates its variables once at start-up (packages/env, t3-env + Zod) and refuses to boot on a bad value. Set SKIP_ENV_VALIDATION=1 only for steps that never start the app (type checks, CI builds). Values are still parsed, so numbers and booleans keep their types. Only the cross-field rules below are skipped, DATABASE_URL / BETTER_AUTH_SECRET get placeholders, and a variable of your product’s with neither a default nor .optional() is left undefined.

Four cross-field rules refuse a boot outright. The first three apply in production only:

  • no RESEND_API_KEY, unless ALLOW_CONSOLE_EMAIL=true: the console provider would write every verification and password-reset URL, token included, into the log;
  • an EMAIL_FROM whose domain is localhost, example.*, reserved or dotless, unless ALLOW_CONSOLE_EMAIL=true without a provider leaves nothing to send: the provider rejects the send and sign-up looks like it worked;
  • no SERVICE_TOKEN: an empty one switches off authentication in services/ai entirely;
  • a COOKIE_DOMAIN that is not a bare hostname covering both WEB_URL and API_URL: the browser drops the session cookie silently. This rule applies in every environment.

Optional subsystems switch on when their variables are present and stay off otherwise: billing, social sign-in, a real email provider, error tracking, analytics. The kit runs with the defaults from the example files.

bun run setup creates each file from the .env.example next to it, generates the secrets and fills the local origins. It completes an existing file without replacing a value that is set, and adds your product’s rows from the .env.product.example next to it (Variables of your own).

File Read by
.env (repository root, from .env.example) the API, database tooling, Docker Compose
apps/web/.env (from apps/web/.env.example) the SvelteKit app (Vite; only PUBLIC_* values reach the browser)
apps/site/.env, apps/docs/.env the Astro sites, at build time
services/ai/.env the AI service when you run it by hand; compose passes it the root values instead

Check the app configuration with bun run doctor; add -- --build to check both Astro sites before a full build. Use -- --database=external when DATABASE_URL points to an existing database. The checker covers local development/test setup and refuses NODE_ENV=production, unless -- --served checks a bun run serve setup; full environment validation belongs to the API at startup. It never applies migrations, and it reads the database only to report on it, in a read-only transaction.

  • The root .env, through --env-file: apps/api’s dev, packages/db’s scripts (bun run db:migrate, db:generate, db:studio) and packages/auth’s create-admin (bun run admin:create). So do apps/api’s worker and packages/auth’s seed (bun run seed).
  • None (--no-env-file): apps/api’s start, the entry point as production runs it, and the root tooling that reads what it needs itself: bun run setup, doctor, serve and test:e2e:full. start:worker and test:journey read none either.
  • The .env in the working directory, Bun’s default: anything run with a plain bun <file>, such as bun run test:db:all, bench and db:kit-drift from the root, and bun test.

A variable already set in the environment wins over every file, so DATABASE_URL=postgres://…/other bun run db:migrate migrates other, and the line it ends on names it: ✓ migrations applied to 127.0.0.1:5432/other (…).

dev:app needs the root and web settings. build also requires each site’s own canonical URL. The templates, and so bun run setup, give apps/site/.env SITE_URL=http://localhost:4321 and apps/docs/.env SITE_URL=http://localhost:4322, which is right for local builds. Keep their public API/app targets consistent with your ports. Before deployment, replace local origins with your public URLs: deploy:cloudflare refuses a localhost SITE_URL.

Astro’s canonical setting uses a nonempty explicit process SITE_URL first, then its own .env.local, then .env. Blank canonical entries fall through to the next source. A nonempty shell export applies to both sites, so leave it unset when using distinct per-site files. The root DOCS_URL belongs to container builds; a direct docs build uses apps/docs/.env’s SITE_URL. The build still rejects an unset canonical URL or an example.com, example.org or example.net placeholder.

Never commit .env files. Only the .env.example files and .env.product.example are tracked. In production the API refuses

the example placeholders: any change-me… value (BETTER_AUTH_SECRET, SERVICE_TOKEN, the OAuth and Stripe secrets, RESEND_API_KEY, the S3 keys) and a DATABASE_URL whose password is starterdough,

postgres or change-me…. bun run setup generates the development secrets; generate every production secret you fill in:

Terminal window
bun -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

openssl rand -hex 32 does the same where OpenSSL is installed. PowerShell has no openssl. POSTGRES_PASSWORD is not one of the API’s own variables; the compose stack refuses to start while it is empty.

Variable Default Meaning
NODE_ENV development development, test or production. Production turns on email verification and Better Auth’s rate limits by default.
PORT 3000 Port the API listens on.
HOST 127.0.0.1 outside production, 0.0.0.0 in production Address the API listens on: an IP address, or localhost. Unset, a development API is reachable from this machine only; production listens on every interface, which a container needs. The compose stack and the API image pin 0.0.0.0 for the container whatever this row says. A development API you reach from a phone, an emulator or another computer (the Tauri mobile dev scripts among them) takes HOST=0.0.0.0. Any other value, such as a machine name a shell exported as HOST, stops the API at start-up.
API_URL http://localhost:3000 Public origin of the API: what browsers and shells call, and the base for OAuth callbacks and webhooks. The web app’s server has an API_URL of its own, the API’s internal address (below); a process that runs both, such as bun run serve, gives each its own.
WEB_URL http://localhost:5173 Public origin of the web app. Used for CORS, auth redirects, trusted origins and the passkey rpID.
TRUSTED_ORIGINS tauri://localhost,http://tauri.localhost Extra origins allowed to call the API (comma-separated): Tauri shells, LAN devices, tailnet hosts, the public site if its contact form should reach the API. null is refused at start-up: sandboxed iframes, data: URLs and file: pages send it, so trusting it would trust any website.
COOKIE_DOMAIN empty Parent domain the session cookie is scoped to (e.g. .example.com) when web and API live on sibling subdomains. A bare hostname, never a URL. The value goes verbatim into Set-Cookie’s Domain, and both WEB_URL and API_URL must sit under it; anything else is refused at start-up. Single-origin deployments leave it empty.
PUBLIC_WEB_URL empty Deployed origin of the web app as the browser sees it. Only the Tauri shells need it: their own origin is tauri://localhost or http://tauri.localhost, which no browser outside the shell can open, so emailed links and payment return addresses go here instead. build:desktop refuses to start without it; in a dev shell without it the actions that need a return address are disabled. The shells offer no OAuth sign-in (see apps/native/README.md). Set it where the shell is built (a repository variable for the Desktop workflow, apps/web/.env for a hand build). A browser deployment leaves it empty, and the compose stack does not pass it to the web container.
PUBLIC_DEMO_MODE empty (off) Public demo deployment. true shows a banner in the app, adds noindex to every page, and switches off uploads, AI jobs and checkout: the API refuses them and the app disables the controls. The web app reads the same variable (apps/web/.env, or the web container’s environment). It also enables “Enter the demo” on the sign-in and sign-up pages: POST /api/demo/sandbox builds each visitor a private, seeded sample organization and signs them in (404 when this is not true).
PUBLIC_DEMO_CTA_URL empty (no link) Where the demo banner’s link goes, usually your pricing page on the marketing site’s origin, so an absolute URL. Empty, the banner says it is a demo and links nowhere. The web container reads it from this file; the public demo next to production (infra/demo/compose.yml) defaults it to the demo’s own marketing site.
Variable Default Meaning
POSTGRES_PORT 5433 Loopback host port of the Postgres container. bun run db:up uses it in development (5433 keeps clear of a local Postgres on 5432). The compose stack uses it in production (default 5432 there, see Self-hosting).
DATABASE_URL postgres://starterdough:starterdough@localhost:5433/starterdough The only Postgres client is the API (Bun’s native Bun.SQL through Drizzle). packages/db/drizzle.config.ts falls back to the same URL, so db:generate and db:studio reach the db:up container on 5433. Add ?sslmode=require (or your provider’s equivalent) for a managed database reached over the public internet.

Both compose files run pgvector/pgvector:0.8.7-pg17: Postgres 17 plus the vector extension document search uses (document_chunk.embedding). Migration 0006 runs CREATE EXTENSION IF NOT EXISTS vector, so a DATABASE_URL pointing at a Postgres without pgvector fails at bun run db:migrate. Install the extension (or use the image) first. The image is the same major as postgres:17, so an existing data volume works as is.

bun run db:migrate applies the kit’s migrations, then yours, checks afterwards that nothing is still pending and exits 1 when something is. Drizzle’s migrator compares each file against the newest applied timestamp of the same history and skips a migration that sorts before it, which is what merging a branch can produce. The message names the history, and UPGRADING.md (“Migrations”) says what to do for each.

Migration 0007_audit_constraints adds four constraints the application already assumes. On a database that has been in production it can fail rather than delete rows: duplicate member (organization_id, user_id) rows, a duplicate passkey.credential_id, or duplicate or orphaned subscription rows. Postgres names the offending key, and the migration’s header comment carries the query that finds each case. De-duplicate by hand, then re-run db:migrate.

Variable Default Meaning
BETTER_AUTH_SECRET none (required, 32+ characters) Signs sessions and tokens. .env.example ships it empty. Generate one with bun -e "console.log(require('crypto').randomBytes(32).toString('hex'))". In production the API refuses a change-me… placeholder, and the compose stack refuses to start while it is empty. Keep it with your backups: a restored database without it cannot validate any session.
REQUIRE_EMAIL_VERIFICATION empty → true in production, false otherwise Whether a verified email is required before a session is issued. The console email provider prints the verification link in development.
ALLOW_SIGN_UP true Who may create an account. false closes sign-up: the sign-up page offers no form, sign-in no “Create one”, and the API refuses a new account with 403 SIGN_UP_CLOSED unless an administrator creates it (bun run admin:create, or POST /api/auth/admin/create-user) or a pending invitation names its address (not on a public demo, where every visitor may invite); the invitee signs up from the invitation’s link, with that address. Anyone who may invite people to an organization can therefore let a new address in, and every signed-in user may create an organization of their own. Accounts that exist are unaffected. Keep REQUIRE_EMAIL_VERIFICATION on with it: otherwise whoever knows an invitee’s address can take their invitation.
SESSION_COOKIE_CACHE_SECONDS 0 Seconds a browser’s session is answered from a signed cookie (better-auth.session_data) instead of the database; 0 reads the database on every request. A value saves a query per request, at a price: a sign-out elsewhere, a revoked session, a ban or a changed role reaches that browser only once its copy expires. Platform administrator procedures and the job streams’ re-checks always read the database, and so do the shells’ bearer tokens. 60 to 300 is the usual range; at most 3600.
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET empty GitHub sign-in is enabled when both are present. Callback URL: ${API_URL}/api/auth/callback/github.
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET empty Google sign-in, same rule. Callback URL: ${API_URL}/api/auth/callback/google.
MAX_TEAMS 20 Teams one organization may have; 0 lifts the cap. A new organization has none. Creating one more answers 400 YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_TEAMS. A team groups the people who share workspaces; a project is a workspace. See Organizations.

The sign-in UI asks the API which providers are live and whether sign-up is open (GET /api/v1/auth-config), so nothing needs to be mirrored into the frontend’s environment.

Leave all of these empty until you enable billing. With the key and webhook secret present the Stripe plugin turns on: organizations become Stripe customers, plan limits from the catalog are enforced and /app/billing sells plans. Without them nothing is capped and the billing page says so.

Variable Meaning
STRIPE_SECRET_KEY Stripe API key.
STRIPE_WEBHOOK_SECRET Signing secret of the webhook endpoint ${API_URL}/api/auth/stripe/webhook. Locally, stripe listen --forward-to localhost:3000/api/auth/stripe/webhook prints one.
STRIPE_PRICE_PRO_MONTHLY Recurring Price id (price_…) for the Pro plan.
STRIPE_PRICE_PRO_ANNUAL Recurring yearly Price id for the Pro plan. Set it only together with annual on Pro in the catalog; without both, Pro is sold monthly only. See Billing.
STRIPE_PRICE_TEAM_MONTHLY Flat monthly Price id for the Team plan.
STRIPE_PRICE_TEAM_SEAT Per-seat Price id for the Team plan. The plugin keeps its quantity equal to the member count. Point both Team prices at the same seat price for a seat-only plan.
BILLING_DUNNING_DAYS (default 14) How long a past_due subscription keeps its paid plan after the period it failed to renew. Stripe retries the card for a while and then applies your dashboard’s setting (cancel, mark unpaid, or leave past_due). This value bounds the last case. 0 revokes as soon as the period ends. See Billing.

Amounts must match the advertised ones in packages/billing/src/catalog.ts, which also holds the limits. Plan copy lives in two places: the catalogue’s English strings (rendered by the API and the Astro pricing page) and Paraglide messages (en + de) for the app. Edit both (Billing). Webhook events to subscribe to: checkout.session.completed, customer.subscription.created|updated|deleted|trial_will_end, invoice.payment_failed.

Variable Default Meaning
EMAIL_FROM Starterdough <noreply@localhost> Sender of every email (verification, reset, invitations, billing). The code default is noreply@localhost, which no real provider accepts. .env.example ships Starterdough <noreply@example.com> as a shape to copy. Set it to an address on a domain you have verified with your provider before turning RESEND_API_KEY on. In production the API refuses to boot while the domain is localhost, example.*, reserved or dotless.
RESEND_API_KEY empty Empty = emails are printed to the API console (development only, unless ALLOW_CONSOLE_EMAIL=true: the API refuses to boot in production without it). Set it to send through Resend.
ALLOW_CONSOLE_EMAIL false true lets production run without an email provider: no email is sent, and each one, its verification, reset and invitation links included, is written to the API’s log, with a warning at start-up. For a single-user or private install whose owner reads that log, never one with strangers’ accounts: whoever reads the log can use the links. EMAIL_FROM is not checked while nothing is sent. A public demo still needs REQUIRE_EMAIL_VERIFICATION=false without a provider. Ignored while RESEND_API_KEY is set.
CONTACT_EMAIL empty Recipient of the public site’s contact / waitlist form (POST /api/v1/contact). Empty is fine while emails go to the console. With Resend configured the form answers 412 until this is filled.
CONTACT_RATE_LIMIT_PER_DAY 50 Messages the contact form sends in a day from every sender together, on top of each client network’s five an hour; past it the form answers 503 until the day’s window ends. The form spends the email provider’s quota that verification and password-reset mail need, so keep it well inside that quota. A filled honeypot spends one of its network’s five an hour, never the day’s ceiling. Counted in each API process.
Variable Default Meaning
AI_SERVICE_URL http://localhost:8000 Where the API reaches the FastAPI service.
SERVICE_TOKEN empty Shared secret between the API and services/ai, sent as X-Service-Token. The same value goes to both sides: bun run setup writes one generated value to the root .env and services/ai/.env, and bun run doctor checks that they match. The compose stack stops with a message while it is empty (bun -e "console.log(require('crypto').randomBytes(24).toString('hex'))" makes one). The service refuses to start with an empty token unless SERVICE_AUTH_DISABLED=true (development only: no check at all, and /health reports ok: false so /admin/system shows it). The service does no user auth of its own. The API owns identity, limits and metering.

The service reads its own environment: OPENAI_API_KEY (empty = deterministic local provider), OPENAI_BASE_URL, OPENAI_CHAT_MODEL, OPENAI_EMBEDDING_MODEL, and the limits and OCR settings below. services/ai/.env.example lists every variable with its default, and services/ai/README.md explains them. The compose stack passes them through from the root .env (all but OCR_TESSERACT_BINARY: the image has Tesseract on PATH).

Variable Default Meaning
SERVICE_AUTH_DISABLED false Development opt-out for an empty SERVICE_TOKEN. Never in production.
LOG_FORMAT / LOG_LEVEL json / info One JSON object per request (method, path, status, durationMs, requestId = the API’s X-Request-Id), or pretty for a terminal.
MAX_UPLOAD_BYTES 26214400 Largest document /v1/extract accepts. Keep it equal to the API’s value. Raising it means raising the ai container’s 1 GB memory limit too.
MAX_TEXT_CHARACTERS 200000 Extraction truncates at this length (truncated: true). /v1/summarize rejects longer input with 413.
MAX_PDF_PAGES / EXTRACT_TIMEOUT_SECONDS 500 / 75 Pages read from one PDF (413 above) and the wall-clock budget for one extraction, OCR included (408 above; final, the job does not retry).
MAX_EXTRACT_THREADS 2 Extractions that run at once, one still running past its 408 or its caller’s disconnect included. More wait for a thread, and answer 503 (the job retries) when their budget ends, whether or not they had started. One extraction can take about 350 MiB, so two fit the ai container’s 1 GB: raise the memory limit with it. Two is what one API process sends at once with the default WORKER_CONCURRENCY; more processes that run jobs queue the rest.
OPENAI_MAX_RETRIES / OPENAI_RETRY_BUDGET_SECONDS 2 / 10 Retries for a 429/5xx/connection error against the model provider, and the total seconds they may sleep.
OPENAI_TIMEOUT_SECONDS 60 Wall-clock budget for one call to the model provider (a summary, a batch of embeddings, an image read), every attempt and sleep included. Past it the call answers 408 and the job fails for good rather than paying for it again. The API tells the service how long it waits (the job attempt’s JOB_TIMEOUT_MS, or a search’s 10 s), and the service stops by then too: for a slow model, raise both.
OCR_PROVIDER auto How /v1/extract reads images: tesseract (the CLI as a subprocess), openai (a vision chat model, needs OPENAI_API_KEY), none (images answer 415), or auto (Tesseract if its binary is on PATH, else openai if a key is set, else none). The Docker image installs Tesseract. A backend named explicitly that cannot run degrades /health at start-up.
OCR_TESSERACT_BINARY tesseract Path of (or name on PATH for) the Tesseract CLI.
OCR_LANGUAGES eng Tesseract language pack(s), +-separated (eng+deu). Each must be installed.
OCR_TIMEOUT_SECONDS 60 Wall-clock limit for one Tesseract run. Exceeding it is a 502 and the job retries. Keep it below EXTRACT_TIMEOUT_SECONDS.
OPENAI_VISION_MODEL gpt-4o-mini Vision-capable chat model for the openai OCR backend.

Switching OPENAI_EMBEDDING_MODEL (or turning the OpenAI provider on or off) changes the vectors document search compares. Documents indexed with the previous model stay invisible until they are re-indexed. See Search.

Variable Default Meaning
WORKER_ENABLED true Run the job worker inside the API process. Set false when a dedicated worker runs (bun run --cwd apps/api worker, compose profile worker). Either way, a process takes up to 14 s to stop after SIGTERM (SHUTDOWN_BUDGET_MS in apps/api/src/lifecycle.ts: a 10 s drain, a 3 s telemetry flush, 1 s to close the pool), and compose waits 15 s (stop_grace_period). A supervisor outside compose must wait at least as long before SIGKILL: launchd’s ExitTimeOut, systemd’s TimeoutStopSec. See Operations → Stopping.
WORKER_CONCURRENCY 2 Jobs one worker process runs at the same time. Above 1, the first slot runs only the kinds customers see, so internal work of yours gets the other WORKER_CONCURRENCY − 1. Webhook deliveries take none of them: they run on four lanes of their own per process, one per organization.
JOB_TIMEOUT_MS 120000 Per-attempt time budget of a kind without a timeoutMs of its own. The worker renews a running job’s lease until twice its budget has passed; once the lease runs out, the minute sweep takes the job back.
Variable Default Meaning
MAX_UPLOAD_BYTES 26214400 Cap on one upload (25 MiB), enforced in three places: the presign check, Bun.serve’s maxRequestBodySize (this value plus 1 MiB) and a byte counter on the PUT itself, which aborts a chunked upload that lied about its length. services/ai reads the same variable and must hold the same number. The cap on a workspace’s total is the plan’s storageBytes (packages/billing/src/catalog.ts), enforced only while billing is on.
STORAGE_DIR ./data/uploads Local driver: where uploaded files live (relative to apps/api; the /data/uploads volume in compose). Used when S3_BUCKET is unset.
S3_BUCKET empty Setting it switches to S3-compatible storage. Bun signs browser URLs; the AWS S3 SDK provides bounded server reads, conditional finalization and cleanup. Validate the provider with the storage integration suite before deployment.
S3_ENDPOINT empty https://<account-id>.r2.cloudflarestorage.com for R2. Omit for AWS.
S3_REGION auto Region (auto for R2).
S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY empty Bucket credentials, for this bucket alone: on R2 an Object Read & Write token scoped to it, never an Admin token; on AWS a policy on its ARNs only. The API holds them, so a key that reaches every bucket reaches the backups too. The bucket’s CORS rules must allow the web origin to PUT/GET with Content-Type.
PUBLIC_STORAGE_ORIGIN empty Browser-visible origin of the presigned upload URLs, scheme and host only (e.g. https://<account-id>.r2.cloudflarestorage.com). Every S3_* value is server-only, so the web app and the desktop shell cannot derive it, and without it their Content-Security-Policy blocks the PUT. Leave it empty for the local disk driver, whose URLs are already on the API origin. Read at build time by apps/web and by the shell’s CSP generator.

See Documents, jobs and the AI service.

Variable Default Meaning
LOG_FORMAT empty → json in production, pretty otherwise Log format of the API and worker. JSON lines carry requestId (Caddy’s X-Request-Id) and, with tracing on, traceId.
LOG_LEVEL empty → info in production, debug otherwise Lowest level the API and worker log: debug, info, warn or error. A failed database query is logged with its root cause only. query, params and sql are redacted, because Drizzle attaches the statement and every bound parameter (session tokens included) to the error it throws.
AUDIT_LOG_RETENTION_DAYS 365 How long an audit entry is kept, in days. 0 keeps them for ever. audit_log stores the actor’s email address, so retention also bounds how long a deleted account’s address survives. Enforced by the daily purge (03:17 UTC).
API_KEY_RATE_LIMIT 120 Requests per minute one organization API key may make; the 121st in a minute answers 429. Counted in each API process, so N replicas allow N times as many. A key’s requests also count against PROCEDURE_RATE_LIMIT_PER_MINUTE, in a bucket of the key’s own once it is resolved. See API keys.
WEBHOOK_ALLOW_PRIVATE_URLS false true lets a webhook endpoint be http:// and resolve to loopback, private and link-local addresses, so a receiver on your machine gets deliveries while you develop. The API refuses to start with it in production: it would let any organization admin make the API call Postgres, the AI service or a cloud metadata endpoint. See Webhooks.
WEBHOOKS_ENABLED true false switches outbound webhooks off for the whole deployment. No event is queued, deliveries already queued end without being sent, and webhooks.create, update, test, redeliver and rotateSecret answer WEBHOOKS_DISABLED (403). Endpoints can still be listed and deleted, so you can clean up, and the Webhooks page says webhooks are off where it offered Add endpoint. See Webhooks.
TRUST_PROXY false Take the first X-Forwarded-For hop as the client address the rate limits and the access log’s ip read, and only when the header holds exactly one address (a chain falls back to the socket address). Turn it on only behind a reverse proxy that sets the header itself (infra/compose.yml sets it on the api service, which sits behind Caddy). Where the API port is reachable directly, it would let any client choose the address it is limited by.
PROCEDURE_RATE_LIMIT_PER_MINUTE 300 Requests a minute to /rpc and /api/v1, per address (an IPv6 one by its /64) while signed out and per user or API key while signed in. A signed-in request still counts against its address while it runs, so an address over the limit refuses everyone behind it until the minute ends. Counted in each API process: N replicas allow N times as many.
FORWARDED_CLIENT_SECRET empty Shared with the web server (apps/web/.env; compose passes the root row to the web container). The web server signs each visitor’s address with it on the calls it makes while rendering a page, and the API believes that address only with a valid signature, never from a browser. At least 32 characters; bun run setup writes one value to both files. Production refuses a change-me… placeholder. Without it the web app’s server-side calls all count as the web server’s address, and so does Better Auth’s limit on get-session; a production API without it says so once at start-up.
OTEL_EXPORTER_OTLP_ENDPOINT empty (off) OTLP/HTTP collector for traces from the API, worker and AI service: http://lgtm:4318 with the observability compose profile, or Grafana Cloud / Axiom / Honeycomb. Nothing is loaded while empty.
OTEL_EXPORTER_OTLP_HEADERS empty Headers for hosted collectors, key=value pairs comma-separated (e.g. Authorization=Bearer …).
OTEL_SERVICE_NAME set per service by compose Service name on the spans. There is no row for it in .env.example: one env_file feeds api, worker and migrate, so a value there would label all of them the same. infra/compose.yml sets starterdough-api, starterdough-worker and starterdough-ai per service. It only matters when you run a process outside compose.
SENTRY_DSN / SENTRY_ENVIRONMENT empty (off) / empty → NODE_ENV Sentry error tracking for the API and the worker: unexpected request and procedure errors, fatal process errors, a job’s last failed attempt, and a job outcome or claim that could not reach the database. The web container’s server side reads the same row (it sits in the Self-hosting part of .env.example), so one Sentry project receives every server error. Errors only: traces stay with OTEL_*, and SENTRY_TRACES_SAMPLE_RATE changes nothing here. A failed query’s statement and bound values are withheld, and what Postgres said is sent. Nothing is loaded while SENTRY_DSN is empty. SENTRY_ENVIRONMENT is the environment events are filed under.
DATABASE_POOL_MAX 10 Postgres connections held by one API or worker process. Postgres allows 100 by default. Keep the total below that: DATABASE_POOL_MAX plus one listen connection per API and per worker process, 2 for migrate while it runs, 1 for backup, plus any psql of your own.
DATABASE_LISTEN true Whether the API listens for Postgres notifications, on one connection beyond DATABASE_POOL_MAX (apps/api/src/pg-listen.ts). false behind a PgBouncer in transaction mode, which cannot carry them; what listens then reads on a timer instead.

See Operations.

Run docker compose from the repository root. COMPOSE_FILE in .env points it at the stack, and the stack reads the same .env.

Variable Default Meaning
COMPOSE_FILE infra/compose.yml Lets docker compose … from the root find the production stack (and read this file for its values).
COMPOSE_PROFILES empty Optional services: backup (nightly pg_dump), worker (jobs in their own container), monitoring (Uptime Kuma), observability (Grafana + Tempo + Loki + Prometheus). Set them here, not with --profile on the command line: infra/scripts/deploy.sh runs up --remove-orphans, which removes the containers of every profile that is not active in that invocation.
COMPOSE_PROJECT_NAME starterdough Prefix of every container, network and volume the stack creates (starterdough_pgdata, starterdough_uploads, starterdough_backups, starterdough_caddy_data). Renaming it after the first up does not rename the volumes: Compose creates a fresh empty set under the new prefix, and the database and every uploaded document look as though they vanished. Copy each volume across first. bun run rename prints the exact commands in its residue checklist.
DOMAIN empty Public domain. In subdomains mode Caddy serves DOMAIN, docs.DOMAIN, app.DOMAIN, api.DOMAIN with certificates from Let’s Encrypt. Ships empty on purpose: a placeholder would have Caddy request an ACME certificate for a domain you do not own and retry for ever. Empty falls back to localhost and Caddy’s internal CA: the stack comes up, the browser warns about the certificate, and nothing is attempted against Let’s Encrypt. Fill it in (and point the four DNS records here) when you go public. Not used with CADDY_MODE=single-origin.
ACME_EMAIL empty Contact address for Let’s Encrypt: expiry warnings and account recovery. Optional, but it is the only notice you get before a certificate that stopped renewing runs out.
POSTGRES_PASSWORD none (required) Password of the compose Postgres, used for the container and the API’s connection string. Ships empty: docker compose stops with set POSTGRES_PASSWORD in .env rather than starting the database on a guessable one (bun -e "console.log(require('crypto').randomBytes(16).toString('hex'))"). Changing it later does not change the database’s password, because the volume already exists.
CADDY_MODE subdomains subdomains (public, automatic HTTPS, set COOKIE_DOMAIN=.DOMAIN) or single-origin (tailnet/LAN, path routing; set WEB_URL and API_URL to that one origin, so there is no CORS).
CADDY_BIND empty → 0.0.0.0 Host address Caddy’s ports are published on. Use 127.0.0.1 behind tailscale serve/funnel (they proxy to loopback, and Docker-published ports bypass ufw). provision.sh sets it from EXPOSE.
CADDY_HTTP_PORT / CADDY_HTTPS_PORT 80 / 443 Host ports Caddy binds. Behind Tailscale only 80 is used.
POSTGRES_PORT 5432 The compose Postgres is published on loopback only (127.0.0.1:${POSTGRES_PORT:-5432}) for bun run admin:create and psql from a host checkout. The shipped .env.example sets 5433 (the db:up port), so a provisioned box listens on 5433 unless you change it.
UPTIME_KUMA_PORT 3001 Loopback port of Uptime Kuma (profile monitoring).
GRAFANA_PORT 3030 Loopback port of Grafana (profile observability).
DEPLOY_WRITER_SERVICES empty Compose services of your own that write to the database, comma- or space-separated (an ingest daemon from an override file in COMPOSE_FILE, say). infra/scripts/deploy.sh stops them with the API before it migrates, and its recovery restores them. A name the stack does not define, one of the kit’s own services, or one that does not wait for migrate to complete (depends_on: migrate: condition: service_completed_successfully, or on api) stops the deploy before anything changes. Keep a name here while a recovery journal that records it exists: recovery reads this row and refuses a journal whose writer it no longer names.
IMAGE_REGISTRY / IMAGE_TAG empty / latest Where docker compose pull gets images: your fork’s GHCR namespace, ghcr.io/<owner>/<repo> lowercased. The Deploy workflow pushes there, infra/scripts/provision.sh derives it from REPO_URL, and deploy.sh pins whatever the workflow passed. It ships empty on purpose: compose then falls back to the local namespace starterdough/<name>:local, so docker compose pull fails with “pull access denied” instead of fetching a stranger’s build, while docker compose up -d --build works unchanged. IMAGE_TAG is set per deploy, so a rollback is an older sha-… tag. up -d --build ignores both.
SITE_URL / DOCS_URL empty → derived from DOMAIN Canonical URLs baked into the static sites (Caddy image). Single-origin: DOCS_URL=https://<host>/docs. Empty resolves to https://localhost, which is fine for a local up, but the Astro builds refuse an unset or example.com value. A published deploy needs the real domains here, or in DOMAIN.
ALLOW_LEGAL_PLACEHOLDERS empty The Caddy image’s site build refuses legal pages that still hold the templates’ [placeholders] or their “review with counsel” note when SITE_URL is a real origin, and names each file. 1 publishes them anyway, for a staging box; leave it empty for production. The Deploy workflow takes the repository variable of the same name instead.
PUBLIC_SENTRY_DSN, SENTRY_DSN, PUBLIC_POSTHOG_KEY, PUBLIC_POSTHOG_HOST empty Passed to the web container (same names as apps/web/.env). SENTRY_DSN (server errors) and SENTRY_ENVIRONMENT also reach every service built from apps/api through env_file, so the API reports to the same project; see Operations above. PUBLIC_SENTRY_DSN is the browser’s.
PROTOCOL_HEADER / HOST_HEADER empty Passed to the web container, whose server (adapter-node) reads them to work out a page’s origin. Behind Caddy over https, leave both unset. A box the browser reaches over plain http, with nothing in front of Caddy, sets PROTOCOL_HEADER=x-forwarded-proto. See The page origin.
Variable Default Meaning
BACKUP_SCHEDULE 30 2 * * * Cron expression, UTC.
BACKUP_RETENTION_DAYS 14 Backups older than this are pruned, on disk and in the bucket.
BACKUP_DIR ./backups Where dumps are written. infra/compose.yml pins the container to /backups (the backups volume), so this row only matters when you run the tool from a checkout on the host.
BACKUP_ON_START false Run one backup as soon as the scheduler container starts, instead of waiting for the next BACKUP_SCHEDULE. The quickest way to prove a fresh box can back up.
BACKUP_CATCHUP true Run one backup at start when the newest set is older than one schedule interval. Bun.cron has no catch-up, so a reboot past BACKUP_SCHEDULE would otherwise skip the night.
BACKUP_MAX_AGE 36h list exits 1 when the newest restorable set is older than this, or when there is none. That makes it usable from cron or a health check. Duration syntax: 90s, 45m, 36h, 2d (a bare number is rejected). Pass --no-max-age when browsing a recovery box.
BACKUP_COMMAND_TIMEOUT_MINUTES 360 Ceiling on any spawned pg_dump, pg_restore or tar, so a hung command cannot block the scheduler.
BACKUP_LOCK_TIMEOUT 60s lock_timeout for a restore session, so a live restore fails instead of hanging on a writer’s lock.
BACKUP_S3_BUCKET, BACKUP_S3_ENDPOINT, BACKUP_S3_REGION, BACKUP_S3_ACCESS_KEY_ID, BACKUP_S3_SECRET_ACCESS_KEY empty Off-box copy, in a bucket of its own: the key id and secret are required with it. The tool refuses the uploads bucket and its key, which the API and the worker hold, so with S3_BUCKET set this one is required; a blank endpoint or region takes S3_ENDPOINT or S3_REGION. Neither bucket: backups stay on the machine (the tool warns). BACKUP_S3_REGION must be a real region (us-east-1, eu-central-1, …). auto is an R2/MinIO convention and is only accepted together with a BACKUP_S3_ENDPOINT.
BACKUP_S3_PREFIX backups/ Key prefix inside the bucket.
BACKUP_HEARTBEAT_URL empty GET after every successful backup. A Healthchecks.io check, Uptime Kuma push monitor or Better Stack heartbeat alerts you when backups stop. A failed scheduled run pings ${BACKUP_HEARTBEAT_URL}/fail.
RELEASE_BACKUP empty → on The release backup infra/scripts/deploy.sh takes before it stops anything: a dump of the database only, under release/ in the backups volume, the newest four kept, never copied to the bucket or reported to the heartbeat. It needs the backup service but not its profile. off skips it, for a box with provider snapshots or a database too large to dump on every deploy; any other value stops the deploy.

The off-site credentials and the heartbeat URL reach the backup service only: infra/compose.yml blanks them for every service built from the API image.

The dump is not encrypted. What to harden around that, every guard a restore applies and every flag that relaxes one are in Operations → Backups.

Your product’s variables are declared in packages/env/src/product.ts (productServer, the rules across them in productConfigurationIssues, the secrets in productSecretNames) and explained in .env.product.example at the repository root. bun run setup appends that file’s rows to the root .env, each value as written there, and generates each one that ships as change-me…, so write a value that is safe in any .env (empty for the default, an example in the comment); infra/scripts/provision.sh does the same on a new server, and bun run doctor names rows .env still lacks (and what setup would append) and secrets still holding the placeholder. Give each variable a default or .optional(): under SKIP_ENV_VALIDATION one with neither is left undefined. parseList (./parse) reads a comma-separated row, and ./testing loads env in a process of its own for a test. A name the kit already uses stops bun run check and the API, naming it.

productTemplateRows() from ./testing reads .env.product.example as bun run setup does, so a test of yours holds the template to the schema in a few lines: every row is a variable of yours, the rows load as a new .env would, and an empty row reads as its default. The env package’s tests run again when the template changes.

import { expect, it } from 'bun:test';
import { productServer } from './product';
import { importEnv, parsedValue, productTemplateRows } from './testing';
it('explains each variable, and loads as setup appends it', () => {
const rows = productTemplateRows();
expect(Object.keys(rows).sort()).toEqual(Object.keys(productServer).sort());
expect(importEnv(rows)).toMatchObject({ code: 0, stderr: '' });
for (const [name, value] of Object.entries(rows)) {
if (value === '') expect(parsedValue(name, rows)).toEqual(parsedValue(name));
}
});

importEnv loads them in production, which refuses a secret still holding its change-me placeholder: give such a row a value first, as setup does.

Variable Default Meaning
PUBLIC_API_URL http://localhost:3000 Where the browser and the Tauri shells reach the API. Must be absolute. Baked into static builds. Ignored by a shell build with PUBLIC_SERVER_CHOICE=true.
API_URL empty → PUBLIC_API_URL Internal API origin used by server-side load functions during SSR (e.g. http://api:3000 inside Docker Compose). Not the root .env’s API_URL, which is the API’s public origin.
FORWARDED_CLIENT_SECRET empty The root .env’s FORWARDED_CLIENT_SECRET, the same value: the server signs each visitor’s address with it on the calls it makes to the API while rendering a page (hooks.server.ts), and only on those. At least 32 characters, or the server does not start. Compose passes the root row; bun run setup writes both. Empty: those calls count as this server’s address.
PROTOCOL_HEADER empty → https The request header that carries the scheme the browser used, e.g. x-forwarded-proto. Read at run time by the node build’s server (adapter-node), from its own environment. Empty: every page’s origin is https://. Set it when the app is served over plain http, and only behind a proxy that sets the header to the browser’s own scheme. See The page origin below.
HOST_HEADER empty → Host The request header that carries the host the browser asked for, e.g. x-forwarded-host, for a proxy that rewrites Host. Read like PROTOCOL_HEADER.
ADDRESS_HEADER / XFF_DEPTH empty → the socket’s address adapter-node’s own: where getClientAddress() reads the visitor, the address the server vouches for to the API. Set x-forwarded-for and 1 only behind a proxy that replaces X-Forwarded-For with the one address it saw, as the compose stack’s Caddy does (compose pins both). Where a browser reaches the server directly they would let it choose the address.
PUBLIC_SENTRY_DSN / SENTRY_DSN empty Error tracking in the browser / during SSR. Empty = the Sentry SDK is not loaded. Optional: PUBLIC_SENTRY_ENVIRONMENT, SENTRY_ENVIRONMENT, PUBLIC_SENTRY_TRACES_SAMPLE_RATE, SENTRY_TRACES_SAMPLE_RATE. The API’s own errors go to the root .env’s SENTRY_DSN (see Operations above); in the compose stack that one row feeds both.
PUBLIC_POSTHOG_KEY empty Product analytics. Empty = no consent banner, nothing loaded. Visitors must accept the banner before anything is sent. Optional PUBLIC_POSTHOG_HOST (default https://us.i.posthog.com).
PUBLIC_WEB_URL empty Read by a Tauri shell build. See the root .env row above. The node build’s start-up check reads it too: see The page origin. Ignored by a shell build with PUBLIC_SERVER_CHOICE=true.
PUBLIC_SERVER_CHOICE empty true: a desktop or mobile shell built with it asks which server to use (/connect) instead of carrying PUBLIC_API_URL; read when the shell is built, by apps/native/scripts/tauri-config.ts and by vite.config.ts under the Tauri CLI; a web or static-host build ignores it, so .env.production can hold it for both. Shells only (apps/native/README.md, “One installer for many servers”).
PUBLIC_DEMO_MODE empty The web app’s half of demo mode. See the root .env row above.
PUBLIC_DEMO_CTA_URL empty Where the demo banner links. See the root .env row above.
PUBLIC_STORAGE_ORIGIN empty Origin of the presigned upload URLs. Needed in connect-src when uploads go to an S3/R2 bucket. See the root .env row above.

The app’s Content-Security-Policy is derived from these values at build time by csp() in apps/web/vite.config.ts. That file also holds the SvelteKit configuration; there is no svelte.config.js. To allow another third-party origin (a font CDN, map tiles, a remote avatar host), return it from productCsp() in apps/web/product-csp.ts, which the desktop and mobile shells’ policy merges too. img-src is 'self' data: blob: plus the API origin, so a profile picture hosted by GitHub or Google is blocked until its origin is listed there.

The deployment target is not an .env value. It is the ADAPTER variable (node, cloudflare, static) read by vite build. See Serve anywhere.

The node build’s server reads its environment when it starts. It reads the rows src/env.ts declares and adapter-node’s own (HOST, PORT, PROTOCOL_HEADER, ADDRESS_HEADER, XFF_DEPTH, BODY_SIZE_LIMIT, SHUTDOWN_TIMEOUT, …), and libraries bundled into it read process.env directly: Better Auth’s code reads BETTER_AUTH_SECRET. Bun loads a .env from the working directory unless given --no-env-file, so run it with an environment of its own, as the compose web service and bun run serve do (apps/web/README.md, “Run the node build yourself”; Serve from a laptop over Tailscale).

The node build (adapter-node 6) works out each request’s origin from the request itself: <PROTOCOL_HEADER's value, or https>://<HOST_HEADER's value, or Host>, plus PORT_HEADER’s value when that is set. It does not read ORIGIN, which adapter-node 6 dropped. SvelteKit’s server-side fetch sends that origin to the API and refuses an answer that does not allow it, so a wrong origin (the default https on a plain-http deployment, say) fails every API call a load makes during SSR, and /app answers 503, “Can’t reach the server”. The web server logs why once, as Session lookup failed, so the API counts as unreachable: … naming load_fetch_cors. The fix is PROTOCOL_HEADER or HOST_HEADER in the web server’s environment, so that the origin it derives is the one the browser sees and the API trusts (WEB_URL, TRUSTED_ORIGINS).

The compose stack passes both from the root .env to the web service. Empty, as they ship, is right behind its Caddy: Caddy passes Host through, and the browser reaches it over https, with Caddy’s certificates in subdomains mode or through tailscale serve in single-origin mode. A box the browser reaches over plain http, with nothing in front of Caddy, sets PROTOCOL_HEADER=x-forwarded-proto in .env (.env.example carries the row commented out): Caddy sets that header to the scheme it was reached with.

The node build also warns once as it starts, naming PROTOCOL_HEADER, when PUBLIC_WEB_URL or PUBLIC_API_URL is a plain http:// URL and PROTOCOL_HEADER is not set. A page served over https could not call an http:// API at all, so either one means the browser uses http. A loopback host (localhost, 127.0.0.1) does not count: vite preview and the end-to-end suite run there, and take the scheme from the request.

Public sites: apps/site/.env, apps/docs/.env

Section titled “Public sites: apps/site/.env, apps/docs/.env”
Variable App Meaning
SITE_URL both Canonical origin: <link rel="canonical">, sitemap, RSS, absolute og:image URLs, and the Sitemap: line of each site’s generated robots.txt. Each site has its own value (the marketing origin, the docs origin). astro build fails when it is unset or still an example.com placeholder. astro dev and astro check fall back to http://localhost:4321 / :4322. For the docs a path is allowed and becomes the base every link is served under (https://example.com/docs in the single-origin Caddy mode).
PUBLIC_APP_URL site Target of “Sign in”, “Open the app” and the pricing CTAs.
PUBLIC_DOCS_URL site Where “Docs” points.
PUBLIC_API_URL both The site’s contact form POSTs to ${PUBLIC_API_URL}/api/v1/contact. The docs’ interactive API reference loads ${PUBLIC_API_URL}/api/v1/openapi.json. Both are browser requests, so the API’s TRUSTED_ORIGINS must include the sites’ origins.
PUBLIC_REPO_URL site Optional. Your public repository: the footer link, “View on GitHub”, the changelog’s commit history, the contact page’s “found a bug”. Unset (the default) renders none of them.
PUBLIC_CONTACT_EMAIL site Optional. Address shown to visitors whose browser cannot run the contact form (no JavaScript). Not where the form delivers: that is the API’s own CONTACT_EMAIL, which never reaches the browser.
DOCS_REPO_URL docs Optional. Repository the docs are edited in: adds the header’s GitHub link and “Edit this page”. Unset (the default) renders neither.
ALLOW_LEGAL_PLACEHOLDERS site Optional. A production build whose SITE_URL is a real origin (not localhost, loopback or a reserved .invalid, .test, .localhost or .example name) fails while a page in src/content/legal still holds a **[placeholder]** or the “Template: review with counsel” note, and names each file. 1 publishes them anyway, for a staging deployment.

DOCS_CONTENT chooses what the docs build serves: product (the default, also when unset or blank) is your product’s documentation in apps/docs/src/content/product, and kit is the kit’s developer manual in apps/docs/src/content/docs. Any other value fails the build. It belongs in the build environment only (bun run dev:manual, a CI step, the DOCS_CONTENT build arg of infra/docker/Dockerfile.static), never in apps/docs/.env: every row of that file reaches every build, so kit there would make each local build and each deploy:cloudflare publish the manual.

All of them are read at build time. Rebuild after changing them. Each Astro config loads the .env in its own app directory (process.loadEnvFile), and a value already in the environment wins over the file, so SITE_URL=… bun run --cwd apps/docs build overrides apps/docs/.env without editing it.

The repository’s own scripts read these, never the API, so no .env row holds them: set one in the shell for one command (E2E_WEB_PORT=4180 bun run test:e2e), or, for the last four, as a repository variable in GitHub (Settings → Secrets and variables → Actions → Variables). Commands says what each command does with them.

Variable Default Meaning
RUN_DB_TESTS empty 1, beside DATABASE_URL, runs a *.db.test.ts; bun run test:db:all sets it for every suite it runs.
RUN_JOURNEY_TESTS empty 1 runs a *.journey.db.test.ts; bun run test:journey sets it, each journey on a database of its own.
JOURNEY_POSTGRES_URL none (test:journey needs it) The /postgres database of a server on this machine that test:journey creates its databases on.
TEST_DATA_<NAME> empty Where a test reads data kept outside the repository. A test that reads one skips without it; bun run test passes every TEST_DATA_* to the suites (and caches a run with one apart), test:e2e:full to the processes it starts, and test:db:all reports a database suite that reads one (not a comment that names it) as not run while it is unset.
VITEST_BROWSERS chromium The browsers apps/web’s component specs run in: chromium, webkit and firefox, separated by commas. Read by a test run only: vite dev and vite build ignore it. A browser Playwright has not downloaded stops the run once, with the command that downloads it.
VITEST_BROWSER_PORT 63315 The port the component specs are served on. Unset, Vitest moves to another port when 63315 is taken; set, the run fails rather than move off it, for a run kept to a port of its own.
E2E_WEB_PORT 4173 for test:e2e, a free one for test:e2e:full The port the web app’s preview listens on. test:e2e stops when it is taken.
E2E_REUSE empty 1: test:e2e tests a server already listening on its port instead of stopping.
E2E_API_PORT a free one The port test:e2e:full runs the API on.
E2E_POSTGRES_URL DATABASE_URL’s server The /postgres database of a server on this machine that test:e2e:full creates its database on.
E2E_PASS_ENV empty Variables of yours, comma-separated, that test:e2e:full passes to every process it starts; never one it sets itself.
E2E_BASE_URL, E2E_API_URL, E2E_USER_EMAIL, E2E_USER_PASSWORD set by the run What test:e2e:full hands its suites: the web app, the API and the account apps/web/e2e/full-fixtures.ts signs in as. Never set them yourself.
BENCH_SMOKE empty 1: a benchmark runs each case once and exits within seconds. bun run bench -- --smoke, and so bun run test, sets it; a full bun run bench clears it.
SMOKE_TIMEOUT_MS 60000 How long bun run smoke:desktop waits for the shell’s page to reach the API and load the sign-in page.
NODE_OUT_DIR build Where ADAPTER=node writes, inside apps/web, a folder whose name starts with build. bun run serve build sets build-serve.next.
SHELL_SMOKE off Repository variable. true runs .github/workflows/shells.yml’s Linux shell on every pull request and push to main. Set it once a run started by hand (Actions → Shells → Run workflow) is green.
SHELL_SMOKE_ALL_OS off Repository variable. true runs the shell on macOS and Windows too.
WEBKIT_SPECS off Repository variable. true runs apps/web’s component specs in WebKit on Linux, in the same workflow and on the same events.
WINDOWS_CI off Repository variable. true runs bun run lint, check and test on Windows too, in .github/workflows/ci.yml, on every pull request and push to main. Deploy releases only what CI passed, so set it once the job is green for your product: a test of yours that runs a Linux script through bash needs a skip on Windows first, as the kit’s in infra/scripts have. A private repository pays twice the Linux rate for Windows minutes.