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, unlessALLOW_CONSOLE_EMAIL=true: the console provider would write every verification and password-reset URL, token included, into the log; - an
EMAIL_FROMwhose domain islocalhost,example.*, reserved or dotless, unlessALLOW_CONSOLE_EMAIL=truewithout 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 inservices/aientirely; - a
COOKIE_DOMAINthat is not a bare hostname covering bothWEB_URLandAPI_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.
Which .env a command reads
Section titled “Which .env a command reads”- The root
.env, through--env-file:apps/api’sdev,packages/db’s scripts (bun run db:migrate,db:generate,db:studio) andpackages/auth’screate-admin(bun run admin:create). So doapps/api’sworkerandpackages/auth’sseed(bun run seed). - None (
--no-env-file):apps/api’sstart, the entry point as production runs it, and the root tooling that reads what it needs itself:bun run setup,doctor,serveandtest:e2e:full.start:workerandtest:journeyread none either. - The
.envin the working directory, Bun’s default: anything run with a plainbun <file>, such asbun run test:db:all,benchanddb:kit-driftfrom the root, andbun 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 (…).
Local app versus complete build
Section titled “Local app versus complete build”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:
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.
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. |
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. |
Database
Section titled “Database”| 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.
Auth (Better Auth)
Section titled “Auth (Better Auth)”| 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.
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 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. |
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: 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.
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). 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. |
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) 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.
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 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.
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 |
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. |
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 |
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.
Variables of your own
Section titled “Variables of your own”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.
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. 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 page origin
Section titled “The page origin”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.
Tests, tooling and CI
Section titled “Tests, tooling and CI”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. |