Skip to main content
Receipt is configured entirely through environment variables. Three of them decide whether the process starts at all. Two encryption keys decide whether you can still read your stored credentials tomorrow. The rest switch optional subsystems on and tune what is already running. There is no central environment schema in Receipt — no zod, no t3-env. Most modules validate lazily, so a wrong value usually surfaces as a runtime error on the feature you touched, not at boot. The three variables below are the ones you cannot get past: the two Better Auth values are read at module top level and kill the process at import, and the database URL throws the moment anything opens a connection, which is the first request that touches data.

Three variables the process cannot start without

BETTER_AUTH_URL — an origin, nothing more

BETTER_AUTH_URL must be the exact public origin you browse to. Trailing slashes are stripped. It has to be an absolute HTTP or HTTPS origin with no credentials, path, query or fragment — HTTP is allowed so local development on http://localhost:3000 works — and anything else throws BETTER_AUTH_URL must be an exact HTTP or HTTPS origin. Every extra origin in RECEIPT_LEGACY_PUBLIC_ORIGINS is held to a stricter rule: each must be an absolute HTTPS origin with no credentials, no wildcard host, and no path, query or fragment. An invalid entry throws Invalid RECEIPT_LEGACY_PUBLIC_ORIGINS: <reason>. VITE_BETTER_AUTH_URL is the browser-side counterpart and is inlined at build time. It must share BETTER_AUTH_URL’s origin. A mismatch does not fail loudly — sign-in breaks with CORS errors.

ZERO_UPSTREAM_DB — one name, no aliases

ZERO_UPSTREAM_DB is the single canonical Postgres URL for the app, the Receipt runtime, the sync layer and every script. The resolver deliberately accepts no aliases, and the reason is recorded in the code: accepting multiple aliases made the application and the Receipt runtime paths capable of silently pointing at different databases. Other in-repo documents name RECEIPT_POSTGRES_URL as though it were an alternative. Production code does not read it. When the app opens its pool, an sslmode of prefer, require or verify-ca in the connection string is rewritten to verify-full unless the string also carries uselibpqcompat=true. The rewrite keeps certificate and hostname verification through a pg-connection-string upgrade that would otherwise relax those modes.

Where values come from

Both stack launchers — the day-to-day dev command and the production-like supervisor — load the same four files in the same order: .env and .env.local at the repository root, then apps/start/.env and apps/start/.env.local. In both, a value already exported in your shell always wins, because the loader skips any key that is already set. That single rule explains most “I changed the file and nothing happened” reports. Not every entry point uses that list. The database scripts read only apps/start/.env.local and then apps/start/.env; the in-repo CLI reads the four plus the env file the local wrapper generates. Two templates ship with the repository: apps/start/.env.example for cloud and development, and apps/start/.env.self-host.example for self-hosted installs. Neither is a complete list of what the code reads, and both carry names nothing reads at all — see Variables that look like configuration but are not.
The build runs Turbo in strict environment mode. Only seven names are cache keys — NODE_ENV, VITE_APP_INSTANCE_MODE, VITE_BETTER_AUTH_URL, VITE_SELF_HOST_SOURCE, VITE_ZERO_CACHE_URL, VITE_ENABLE_ORGANIZATION_PROVIDER_KEYS and VITE_DISABLE_REDIS. Any other variable a build step needs has to be in the pass-through list, or it will not reach the build at all.

Encryption keys and shared secrets

BYOK_ENCRYPTION_KEY_B64 and RECEIPT_CONNECTION_ENCRYPTION_KEY_B64 are the two AES keys, and each must decode to exactly 32 bytes. Generate each one separately:
The other two are shared secrets rather than AES keys: what matters is that the value is long, random, and identical on both sides. The repository’s deploy secrets generator produces 48 random bytes, base64url, for each. RECEIPT_CONNECT_JWT_SECRET must be the same value in the web app and the runtime, and RECEIPT_INTEGRATIONS_WEBHOOK_SECRET must match the HMAC key registered at the integration provider — the provider signs each webhook with it and Receipt verifies against it. Both AES keys use a 12-byte IV and key version 1. RECEIPT_CONNECTION_ENCRYPTION_KEY_B64 must decode to exactly 32 bytes, and a version mismatch throws Unsupported Receipt Connect connection key version: <n>. BYOK_ENCRYPTION_KEY_B64 reports its own problems in the same shape: the BYOK wrapping key lists every message it can produce, and the 12-character fingerprint that stands in for a provider key in logs.
The repository’s local supervisor scripts fill in fallbacks when these are unset: RECEIPT_CONNECTION_ENCRYPTION_KEY_B64 falls back to BYOK_ENCRYPTION_KEY_B64, and RECEIPT_CONNECT_JWT_SECRET falls back to BETTER_AUTH_SECRET. That is a local-development convenience. The deploy secrets bundle lists both as required values in their own right. The two AES _B64 keys are additionally validated to decode to exactly 32 bytes before upload, and anything else is refused with <name> must be a base64-encoded 32 byte key.
Rotating either AES key destroys the data it protects. There is no way back.There is no re-encryption routine anywhere in the codebase, and decryption hard-fails on any other key version: rotating RECEIPT_CONNECTION_ENCRYPTION_KEY_B64 after connections exist makes every stored Receipt Connect connection secret permanently undecryptable, and BYOK_ENCRYPTION_KEY_B64 does the same to every saved provider key — the BYOK wrapping key has the full account. Generate both keys once, before the first user saves anything, and store them where you will not lose them.

Paying for model calls

Every model call Receipt itself makes passes one policy and credential checkpoint, described in the LLM Gateway. Which credential pays is a configuration decision. Normally it is the organization’s own: provider keys are added through the app, encrypted with BYOK_ENCRYPTION_KEY_B64, and stored per organization. See Bring your own key. When an organization has no key of its own, the request is funded by the deployment, using a single server-owned OpenAI credential in OPENAI_API_KEY. Set any of the six limit variables to something that is not a positive integer and the chat request fails with Expected {NAME} to be a positive integer — the value is read per request, not at boot. There are no token budgets, only request counts and the dollar-denominated budgets described in Usage and spend.
Without OPENAI_API_KEY, chat fails for every organization that has not brought its own key. The gateway answers Platform-funded OpenAI access is unavailable: OPENAI_API_KEY is not configured., and background runs fail with Platform-funded OpenAI access is authorized, but OPENAI_API_KEY is unavailable. A model the deployment key cannot route also fails, with Selected model does not support platform OpenAI routing: <id>.Because the variable is in neither template, this is the single most common configuration gap on a fresh install.
VITE_DISABLE_REDIS=true turns the rate limiter off entirely — see Redis below. The cloud template (apps/start/.env.example) ships that value; the self-hosted template ships VITE_DISABLE_REDIS=false, so on a self-hosted install the limiter is on and REDIS_URL is required.

Seats on the free plan

RECEIPT_DEFAULT_FREE_SEAT_COUNT sets how many people may use a workspace on the free plan before a paid subscription’s own seat count takes over. Unset — or set to anything that is not a positive integer — it is 5. Raise it when you are running a pilot that needs more seats before billing is set up. A self-hosted build ignores it. Every organization created on a self-hosted instance gets the self_hosted plan and a subscription seat count of 100,000, and the capacity check reads that subscription, so the variable never applies. Adding a member past the ceiling is refused with 403 and the message Only {n} users can access this workspace. Members and roles covers the invitation flow, and Billing and plans covers what a paid plan changes.

Object storage

UPLOAD_STORAGE_PROVIDER selects the backend and defaults to cloudflare_r2. It accepts cloudflare_r2 or r2, and s3, s3_compatible, railway_s3 or railway. Anything else throws:
Miss one and the configuration throws with the list of what is absent: Cloudflare R2 upload requires env variables: missing {list}, or S3-compatible upload requires env variables: missing {list}. There is no graceful degradation for uploads — with no storage configured, the upload route answers 500 carrying that same message. For R2 the endpoint is derived from the account id and the region is auto. For S3-compatible storage, S3_REGION defaults to auto, unprefixed aliases (ENDPOINT, ACCESS_KEY_ID, SECRET_ACCESS_KEY, BUCKET, REGION) are accepted, and setting S3_AUTH_MODE=aws_default or S3_USE_IAM_ROLE=1 switches from static keys to the ambient credential chain.

The signed object proxy

S3_PUBLIC_BASE_URL defaults to {BETTER_AUTH_URL}/api/files/object — Receipt’s own first-party proxy for private buckets. When the public base URL ends in that path, returned URLs are signed: {publicBaseUrl}?key=<key>&sig=<hmac>, where the signature is an HMAC-SHA256 of the object key using BETTER_AUTH_SECRET. Without that secret, the service throws BETTER_AUTH_SECRET is required to generate signed proxy file URLs. The proxy rejects a bad key with {"error":"Invalid storage key"} (400) and a bad signature with {"error":"Invalid file signature"} (403).

Optional subsystems, and exactly what you lose

Redis

REDIS_URL powers chat stream resume and rate limiting. Without it the stream-resume layer throws REDIS_URL is not configured.
VITE_DISABLE_REDIS=true swaps in disabled layers for both stream resume and rate limiting. Stream resume always returns nothing, so GET /api/chat always answers 204 and a page reload during generation shows no live stream — the answer still lands through the synced projection. Less obviously, rate limiting is turned off entirely: the check always returns allowed. The in-repo local setup guide describes this flag as merely using an in-memory stream-resume fallback. It does not.

Embeddings and the vector store

VITE_ENABLE_EMBEDDING defaults to true when unset, so the stack expects a vector store unless you explicitly turn embeddings off. The cloud template ships true; the self-hosted template ships false.
  • To run embeddings: set QDRANT_URL and, if your instance needs it, QDRANT_API_KEY. QDRANT_COLLECTION_ATTACHMENTS defaults to attachment_chunks_v1, QDRANT_TIMEOUT_MS to 5000, QDRANT_UPSERT_BATCH_SIZE to 128.
  • To run without one: set VITE_ENABLE_EMBEDDING=false at build time, which disables embeddings and vector retrieval globally.
Leaving embeddings on with no vector store starts the stack but leaves attachment indexing and retrieval unavailable, with the warning: VITE_ENABLE_EMBEDDING=true but QDRANT_URL is empty. The core stack will start, but attachment vector indexing/retrieval remains unavailable until Qdrant is configured.

The markdown worker

CF_MARKDOWN_WORKER_URL and CF_MARKDOWN_WORKER_TOKEN are both required for converting non-text files to markdown; the worker is considered available only when both are non-empty. CF_MARKDOWN_WORKER_TIMEOUT_MS defaults to 20000 (floor 1000) and CF_MARKDOWN_MAX_CHARS to 120000 (floor 1000). Without it, conversion answers 503 with Markdown conversion is disabled because CF_MARKDOWN_WORKER_URL or CF_MARKDOWN_WORKER_TOKEN is missing., and the upload route refuses any chat attachment that is not directly text-extractable with a 400:
This self-hosted instance only accepts direct text attachments until the markdown worker is configured.
The message names a self-hosted instance, but the check is on the two worker variables alone. A cloud deployment that has not set them refuses attachments the same way and shows the same wording.

Outbound email

AUTH_EMAIL_PROVIDER is one of disabled, resend, ses or smtp. Unset, it is auto-detected: resend when RESEND_API_KEY is present, else ses when any one of SES_FROM_EMAIL, AUTH_EMAIL_FROM or an SST-provided sender identity is present, else disabled. AUTH_EMAIL_FROM is the generic fallback from-address for all three. Receipt does not operate an email service. Every transport sends through an account you own, and needs a sender identity verified in that account. The repository’s AWS deploy path enforces this for the production stage: its auth-email guard requires AUTH_EMAIL_PROVIDER=ses and VITE_DISABLE_EMAIL_VERIFICATION_OTP=false, and checks both SES production access and a verified identity before it will deploy — so a SES account still in the provider’s sandbox fails the guard. On a self-hosted instance, organization invitations always produce copyable signup links in the invite dialog rather than sending mail. That is keyed to the self-hosted build, not to which email transport you configured.

Runtime knobs that are actually read

These are read by the Receipt runtime. Defaults are the values in code when the variable is unset. The three model variables each fall back to a model identifier carried in code, and the Factory task and objective-supervisor models are set separately from OPENAI_MODEL. Those identifiers are not reproduced here — set each variable explicitly to the model you intend to run.

Pointing the CLI at your own deployment

Released receipt binaries have the hosted origin app.kentron.ai compiled in, so nothing here is needed to use the hosted app — see Sign in. Two variables exist for the CLI’s local target, and matter when you point it at a development stack or a self-hosted install: The full set, including the session file layout and precedence between flags, environment and saved session, is in Environment and exit codes.

Variables that look like configuration but are not

Every name below appears in an environment template, in the build configuration, in a deploy configuration, or in the repository’s own API config document — and is read by no code that runs. Setting them changes nothing. In apps/start/.env.example, with no reader anywhere: ANTHROPIC_API_KEY (referenced only by test stubs), AI_GATEWAY_API_KEY (production code only removes it from projected sandbox environments), AUTH_DEV_EMAIL_OTP_TO_CONSOLE (there are no console OTPs), RECEIPT_POSTGRES_MIRROR_URL, SLACK_PRIMARY_TEAM_ID, SLACK_PRIMARY_RECEIPT_ORG_ID, SLACK_ALLOWED_TEAM_IDS, STRIPE_PRODUCT_ENTERPRISE, VITE_STRIPE_PUBLISHABLE_KEY.
AI_GATEWAY_API_KEY and ANTHROPIC_API_KEY sit in the template under a heading marked required, and neither does anything. The template’s comment above them reads:
Hosted requests without a workspace BYOK key use AI Gateway and consume the workspace’s platform-credit balance. Workspace BYOK keys take precedence.
That is no longer how the product works. Requests not covered by an organization key go directly to OpenAI with OPENAI_API_KEY, which the template does not mention at all. Never install an OpenAI key as AI_GATEWAY_API_KEY — it will be ignored, and the deployment will behave as though it has no funding key.
SLACK_BOT_TOKEN is a near miss: the deploy configurations inject it into containers, but no application code reads it. In the build configuration, with no reader: VITE_ENABLE_ORGANIZATION_PROVIDER_KEYS is listed in turbo.json and set by CI and the image builds, but no application code reads it. ENABLE_EMBEDDING is declared in the Vite environment types and passed through Turbo, but no runtime code reads the unprefixed name — only VITE_ENABLE_EMBEDDING has an effect. Turbo’s pass-through list also still carries a block of variables inherited from an upstream template that this codebase never reads: WORKOS_*, AUTUMN_*, VALYU_API_KEY, SUPERMEMORY_API_KEY, KV_REST_API_*, NEXT_PUBLIC_*, DUB_API_KEY, ADMIN_EMAILS, XAI_API_KEY, MISTRAL_API_KEY, MOONSHOTAI_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY. In the in-repo API config document, with no reader: JOB_POLL_MS, JOB_CONCURRENCY, JOB_LEASE_GRACE_MS, PLANNER_STEP_TIMEOUT_MS, IMPROVEMENT_VALIDATE_CMD and IMPROVEMENT_HARNESS_CMD. That document also states JOB_LEASE_MS defaults to 30000; the code default is 300000. JOB_BACKEND is a second near miss: the AWS deploy configurations set it to resonate, and no code reads it.

Full reference, by area

The sections above cover the variables that stop the product working. This appendix lists the remainder, grouped by the process that reads them. A blank default means the variable is unset in code.
Database and migrations covers the publication itself, which is the part that silently breaks sync when a table is missing from it.
Health and observability covers what these emit.
The Factory engine explains what these lanes and leases do.
The service gateway fronts every process behind one origin and routes by path prefix: /runtime, /zero, /integrations, /integrations-connect, /connect, /slack, /teams, /resonate, /computer. Everything else goes to the web app. The prefixes are the same locally and in a cloud deployment; Processes, roles, and routing has the full table, including each service’s internal port, exposure and health path.Backend targets are RECEIPT_WEB_INTERNAL_URL, RECEIPT_RUNTIME_INTERNAL_URL, RECEIPT_ZERO_INTERNAL_URL, RECEIPT_INTEGRATIONS_INTERNAL_URL, RECEIPT_INTEGRATIONS_CONNECT_INTERNAL_URL, RECEIPT_SLACK_INTERNAL_URL, RECEIPT_TEAMS_INTERNAL_URL, RECEIPT_RESONATE_INTERNAL_URL, RECEIPT_COMPUTER_INTERNAL_URL and RECEIPT_OPENSANDBOX_INTERNAL_URL. Public origins are RECEIPT_SERVICE_GATEWAY_URL, RECEIPT_SERVICE_RUNTIME_URL, RECEIPT_PROXY_SERVER_URL, RECEIPT_SERVER_URL, RECEIPT_RUNTIME_URL, RECEIPT_APP_URL, RECEIPT_WEB_URL and RECEIPT_PUBLIC_BASE_URL.
/runtime, /resonate and /computer are private prefixes: unless the request arrives on the host name localhost, 127.0.0.1 or ::1, the gateway answers Not found with 404.RECEIPT_SERVICE_GATEWAY_EXPOSURE=private or RECEIPT_SERVICE_GATEWAY_ALLOW_PRIVATE=1 lifts that restriction. The value reads backwards until you know what it describes: it declares that the gateway itself is only reachable on a private network, so the private prefixes no longer need their own guard. The AWS deploy sets the exposure to public and keeps the guard on. Do not lift it on an internet-facing gateway.
Both also read BETTER_AUTH_URL, RECEIPT_WEB_URL and ZERO_UPSTREAM_DB. See Receipt in Slack and Receipt in Teams for what each adapter can do.
These only affect the repository’s own launchers, never a deployed build.
  • START_ALL_* configures the production-like supervisor: a port per service, plus POSTGRES, LOCAL_INFRA, OPENSANDBOX, SLACK, TEAMS, MARKDOWN_WORKER and BUILD_WEB switches, WAIT_TIMEOUT_SECONDS, PUBLIC_GATEWAY_URL, ENV_FILE/ENV_FILES and LOG_ROOT.
  • LOCAL_UP_* configures the wrapper around it: DB_RESET (auto, 1 or 0; anything else fails with Unsupported LOCAL_UP_DB_RESET='<x>'. Use auto, 1, or 0.), BUILD_WEB, ZERO_UPSTREAM_DB, PROVIDER (opensandbox, with computer accepted as an alias for it; anything else fails with Unknown provider '<x>'. Use opensandbox.), LOG_ROOT and RESET_INTEGRATIONS.
  • The day-to-day dev command reads PORT, RECEIPT_PORT and START_DEV_POSTGRES.
  • The offline mock model proxy reads RECEIPT_MOCK_LLM_PORT, RECEIPT_MOCK_LLM_HOST, RECEIPT_MOCK_LLM_TEXT, RECEIPT_MOCK_LLM_SCRIPT, RECEIPT_MOCK_LLM_FAILURES and RECEIPT_MOCK_LLM_TIMEOUT_MS.
  • The local integration-provider container reads LOCAL_NANGO_DATABASE_URL, LOCAL_NANGO_ENCRYPTION_KEY, LOCAL_NANGO_DASHBOARD_USERNAME and LOCAL_NANGO_DASHBOARD_PASSWORD. The compose file ships development defaults for all four; change them before exposing that container to anything.
Local development puts these in the order you actually use them.
Deploy-time variables — stage and account selection, image references, instance sizes, task counts and the guard overrides — are read by the deploy scripts only and never by a running process. Deploying covers the secrets bundle and the preflight checks that gate a production deploy. Next step: Run the stack on your own machine walks through the prerequisites, the bring-up order and the three run modes.