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) — it still
parses every value, so numbers and booleans keep their real types; only the cross-field rules below
are skipped, and DATABASE_URL / BETTER_AUTH_SECRET get placeholders.
Four cross-field rules refuse a boot outright — the first three only in production — because each of them used to produce a deployment that looked healthy and was not:
- no
RESEND_API_KEY—@repo/emailwould fall back to the console provider, which writes every verification and password-reset URL, token included, into the log; - an
EMAIL_FROMwhose domain islocalhost,example.*, reserved or dotless — the provider rejects the send and Better Auth swallows the failure, so sign-up looks like it worked; - no
SERVICE_TOKEN— an empty one switches off authentication inservices/aientirely; - a
COOKIE_DOMAINthat is not a bare hostname covering bothWEB_URLandAPI_URL— the browser drops the session cookie without a word. This one is checked 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 scaffold runs with the defaults from the example files.
| 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 |
Never commit .env files; only the .env.example files 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 director,
postgres or change-me… — so generate every secret you fill in. Bun is the one generator that is
already installed on every machine the kit runs on, Windows included:
bun -e "console.log(require('crypto').randomBytes(32).toString('hex'))"(openssl rand -hex 32 does the same where OpenSSL is on PATH; 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 instead.
API and infrastructure — root .env
Section titled “API and infrastructure — root .env”| 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. |
API_URL |
http://localhost:3000 |
Public origin of the API — what browsers and shells call, and the base for OAuth callbacks and the Stripe webhook. |
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. |
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 instead of silently dropping every session cookie. 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, which no OAuth provider and no Stripe Checkout can return to, so those round trips go here instead — unset, the social buttons and the billing actions are hidden rather than dead-ending the user in the system browser. 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 deliberately does not pass it to the web container. |
Database
Section titled “Database”| Variable | Default | Meaning |
|---|---|---|
POSTGRES_PORT |
5433 |
Loopback host port of the Postgres container: bun run db:up in development (5433 so it does not collide with a local Postgres on 5432) and the compose stack in production (default 5432 there — see Self-hosting). |
DATABASE_URL |
postgres://director:director@localhost:5433/director |
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 rather than a host Postgres on 5432. Add ?sslmode=require (or your provider’s equivalent) for a managed database reached over the public internet. |
Both compose files run pgvector/pgvector:pg17 — Postgres 17 plus the vector extension that
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 is reused as is.
bun run db:migrate checks afterwards that nothing is still pending and exits 1 when something
is. Drizzle’s migrator compares each file against the single newest applied timestamp, so a
migration whose journal timestamp is older than one already applied — exactly what merging a branch
produces — would otherwise be skipped for good while the command printed success. Regenerate those
so they sort last, or apply them by hand.
Migration 0007_audit_constraints adds four constraints the application already assumed, and on a
database that has been in production it can legitimately fail rather than delete rows to make
one fit: duplicate member (organization_id, user_id) rows, a duplicate passkey.credential_id,
and 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.
Auth (Better Auth)
Section titled “Auth (Better Auth)”| Variable | Default | Meaning |
|---|---|---|
BETTER_AUTH_SECRET |
— (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 also 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 one 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. |
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. |
The sign-in UI asks the API which providers are live (GET /api/v1/auth-config), so nothing needs
to be mirrored into the frontend’s environment.
Billing (Stripe)
Section titled “Billing (Stripe)”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
explains that.
| 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_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 for ever); this bounds the last case, which would otherwise entitle the paid plan indefinitely. 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, which the API and the
Astro pricing page render, and Paraglide messages (en + de) for the app — so edit both, or they
drift (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 |
Director <noreply@localhost> |
Sender of every email (verification, reset, invitations, billing). The code default is noreply@localhost, which no real provider will accept — .env.example ships Director <noreply@example.com> as a shape to copy, not a working value. 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: the API refuses to boot in production without it, because the console provider would swallow every verification and reset email and write its token to the log). Set it to send through Resend. |
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. |
Internal services
Section titled “Internal services”| 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: the compose stack stops with a message while it is empty (bun -e "console.log(require('crypto').randomBytes(24).toString('hex'))" makes one), and the service itself refuses to start with an empty token unless SERVICE_AUTH_DISABLED=true (development only: no check at all, and /health reports ok: false so the lapse is visible on /admin/system). 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, the limits and OCR settings
below — services/ai/.env.example lists every variable with its default, 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). |
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. |
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 here 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.
Background jobs
Section titled “Background jobs”| 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). |
WORKER_CONCURRENCY |
2 |
Jobs one worker process runs at the same time. |
JOB_TIMEOUT_MS |
120000 |
Per-attempt time budget; a job still running twice as long is presumed lost and requeued. |
Document storage
Section titled “Document storage”| 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, so an oversized body is refused before it is buffered) and a byte counter on the PUT itself, which aborts a chunked upload that lied about its length. services/ai reads the same variable to bound its own request body 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; /data/uploads volume in compose). Used when S3_BUCKET is unset. |
S3_BUCKET |
empty | Setting it switches the driver to S3/R2/MinIO through Bun’s built-in client. |
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. 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 above is server-only, so neither the web app nor the desktop shell can 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.
Operations
Section titled “Operations”| 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 denormalises the actor’s email address on purpose, so retention is the only thing bounding how long a deleted account’s address survives. Enforced by the daily purge (03:17 UTC). |
TRUST_PROXY |
false |
Take the first X-Forwarded-For hop as the client address for the per-IP rate limits and the access log’s ip, and only when the header holds exactly one address (a chain of hops 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. |
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 deliberately 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 director-api, director-worker and director-ai per service; it only matters when you run a process outside compose. |
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 per API and per worker process, 2 for migrate while it runs (its advisory lock plus the migrator), 1 for backup, plus any psql of your own. |
See Operations.
Self-hosting (infra/compose.yml)
Section titled “Self-hosting (infra/compose.yml)”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 |
director |
Prefix of every container, network and volume the stack creates (director_pgdata, director_uploads, director_backups, director_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 filled-in placeholder would have Caddy chase an ACME certificate for a domain you do not own, retrying for ever while docker compose ps shows every container healthy. 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; it is not used at all 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 |
— (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 (the volume 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 → no CORS). |
CADDY_BIND |
empty → 0.0.0.0 |
Host address Caddy’s ports are published on. 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). |
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 director/<name>:local, so docker compose pull fails with “pull access denied” instead of quietly 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. |
PUBLIC_SENTRY_DSN, SENTRY_DSN, PUBLIC_POSTHOG_KEY, PUBLIC_POSTHOG_HOST |
empty | Passed to the web container (same names as apps/web/.env). |
Backups (profile backup)
Section titled “Backups (profile backup)”| 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 |
Independently of the above, 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 silently. |
BACKUP_MAX_AGE |
36h |
list exits 1 when the newest restorable set is older than this, or when there is none — which is what 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 wedge the scheduler. |
BACKUP_LOCK_TIMEOUT |
60s |
lock_timeout for a restore session, so a live restore fails instead of hanging for ever 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. Empty bucket with S3_BUCKET set → the uploads bucket is used under BACKUP_S3_PREFIX. Neither → 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. |
The dump is not encrypted, and what to harden around that — plus every guard a restore applies and every flag that relaxes one — is in Operations → Backups.
Web app — apps/web/.env
Section titled “Web app — apps/web/.env”| 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. |
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). |
PUBLIC_SENTRY_DSN / SENTRY_DSN |
empty | Error tracking in the browser / during SSR. Empty = the Sentry SDK is not even loaded. Optional: PUBLIC_SENTRY_ENVIRONMENT, SENTRY_ENVIRONMENT, PUBLIC_SENTRY_TRACES_SAMPLE_RATE, SENTRY_TRACES_SAMPLE_RATE. |
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 only by a Tauri shell build — 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 — which is also where the SvelteKit configuration lives; there is no
svelte.config.js. Any other third-party origin (a font CDN, map tiles, a remote avatar host) means
editing that function: img-src is 'self' data: blob: on purpose, so a profile picture hosted by
GitHub or Google is blocked until its origin is listed there.
Build-time selection of the deployment target is not an .env value but the ADAPTER variable
(node, cloudflare, static) read by vite build — see Serve anywhere.
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 and 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 — a wrong value here is invisible in the output but points every absolute URL at somebody else’s host. 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, so no repository URL ships in your docs chrome. |
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.