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:
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.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 withBYOK_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.
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:
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.
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_URLand, if your instance needs it,QDRANT_API_KEY.QDRANT_COLLECTION_ATTACHMENTSdefaults toattachment_chunks_v1,QDRANT_TIMEOUT_MSto5000,QDRANT_UPSERT_BATCH_SIZEto128. - To run without one: set
VITE_ENABLE_EMBEDDING=falseat build time, which disables embeddings and vector retrieval globally.
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
Releasedreceipt 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. Inapps/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.
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, sync, and replication
Database, sync, and replication
Database and migrations covers the publication itself, which is the part that silently breaks sync when a table is missing from it.
Receipt Connect and the CLI
Receipt Connect and the CLI
Model access, billing, and analytics
Model access, billing, and analytics
Health and observability covers what these emit.
Runtime, jobs, and the sandbox
Runtime, jobs, and the sandbox
The Factory engine explains what these lanes and leases do.
Routing between processes
Routing between processes
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.Slack and Teams
Slack and Teams
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.Local supervisors and development helpers
Local supervisors and development helpers
These only affect the repository’s own launchers, never a deployed build.
START_ALL_*configures the production-like supervisor: a port per service, plusPOSTGRES,LOCAL_INFRA,OPENSANDBOX,SLACK,TEAMS,MARKDOWN_WORKERandBUILD_WEBswitches,WAIT_TIMEOUT_SECONDS,PUBLIC_GATEWAY_URL,ENV_FILE/ENV_FILESandLOG_ROOT.LOCAL_UP_*configures the wrapper around it:DB_RESET(auto,1or0; anything else fails withUnsupported LOCAL_UP_DB_RESET='<x>'. Use auto, 1, or 0.),BUILD_WEB,ZERO_UPSTREAM_DB,PROVIDER(opensandbox, withcomputeraccepted as an alias for it; anything else fails withUnknown provider '<x>'. Use opensandbox.),LOG_ROOTandRESET_INTEGRATIONS.- The day-to-day dev command reads
PORT,RECEIPT_PORTandSTART_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_FAILURESandRECEIPT_MOCK_LLM_TIMEOUT_MS. - The local integration-provider container reads
LOCAL_NANGO_DATABASE_URL,LOCAL_NANGO_ENCRYPTION_KEY,LOCAL_NANGO_DASHBOARD_USERNAMEandLOCAL_NANGO_DASHBOARD_PASSWORD. The compose file ships development defaults for all four; change them before exposing that container to anything.