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) — 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/email would fall back to the console provider, which writes every verification and password-reset URL, token included, into the log;
  • an EMAIL_FROM whose domain is localhost, 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 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 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:

Terminal window
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.

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.
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.

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.

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.
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.

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.
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.

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.

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).
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.

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.