Skip to main content
A self-hosted Receipt instance runs on your own infrastructure: you claim it once with a setup token, create the first admin account yourself, and from then on it accepts only the people you invite. No cloud billing runs against it, and it sends nothing to the analytics provider.
Self-hosted mode is a build-time choice, not a runtime setting.VITE_APP_INSTANCE_MODE is resolved once at module load and inlined into the browser bundle by Vite. Anything other than the exact string self_hosted means cloud. You cannot change it on a running deployment — flipping the variable on a live container does nothing, because the value is already compiled into the JavaScript the browser downloads.No deploy path in this repository sets it to self_hosted. The infrastructure configuration, the single-host generator and the image build script all pass the literal cloud, and the web Dockerfile defaults its build argument to cloud. A deployment produced by following the repository’s deploy instructions is a cloud-mode deployment.

Getting a self-hosted build

You have to build the web image yourself. The web Dockerfile, deploy/Dockerfile.receipt-web, accepts and re-exports the build argument, so add it to whatever build invocation you already use:
Then supply SELF_HOSTED_SETUP_TOKEN to the running container, so the first admin account can be created. For a local checkout, set VITE_APP_INSTANCE_MODE=self_hosted in apps/start/.env.local and rebuild the web app. Turbo’s build task lists .env.local among its inputs and tracks VITE_APP_INSTANCE_MODE in its cache key, so the rebuild is not skipped.
Turbo runs in strict env mode, and VITE_APP_INSTANCE_MODE is one of the seven variables in its globalEnv list, alongside NODE_ENV, VITE_BETTER_AUTH_URL, VITE_SELF_HOST_SOURCE, VITE_ZERO_CACHE_URL, VITE_ENABLE_ORGANIZATION_PROVIDER_KEYS and VITE_DISABLE_REDIS.

Four things that do not exist until the build is self-hosted

Until the bundle is built with VITE_APP_INSTANCE_MODE=self_hosted, these are unreachable, no matter what you put in your environment:
  • The /setup claim wizard. On a cloud build /setup redirects to /chat.
  • The setup token. SELF_HOSTED_SETUP_TOKEN is only consulted by the self-hosted claim path.
  • The invite_only signup policy, and the two other self-hosted signup policies.
  • The public-app lock, which forces every application route to require a non-anonymous session.

What changes in a self-hosted build

Because the /, /auth/sign-up and /auth/sign-in redirects stack, a fresh self-hosted deployment sends every visitor at / to /setup until the instance is claimed, and to /auth/sign-in afterwards.
RECEIPT_DEFAULT_FREE_SEAT_COUNT is a cloud-plan knob. It raises the free-plan seat ceiling that active members and pending invitations are counted against, and it is a positive integer that falls back to 5. It does not apply to a self-hosted instance, which is provisioned on the self_hosted plan with 100000 seats and usage metering off.
/pricing on a self-hosted build renders a static card instead of the pricing UI:
Self-hosted instance Cloud billing and plan upgrades are disabled in self-hosted mode. This deployment already runs with the self-hosted capability profile.
The self_hosted plan id is only valid when the instance is self-hosted.
A self-hosted user cannot recover a lost password. Password reset and email verification endpoints are blocked, email change is disabled, and no admin-driven reset exists. Treat the first admin credentials accordingly.

Telemetry

A self-hosted build returns no PostHog API key, so a self-hosted instance never sends anything to PostHog. The setup snapshot reports PostHog, Stripe billing, auth email and social sign-in with the status not_available_in_self_host.

Claiming the instance: the /setup wizard

/setup is a two-step wizard.
1

Hello Human

Subtitle: Enter the setup token to claim this instance. The field is labelled Setup token, with placeholder Paste the token here and help text Use the token from your self-hosted setup.The token can also arrive in the URL as ?setupToken=, ?setup-token= or ?token=. It is read and then stripped from the URL.
2

Create your admin account

Subtitle: Finish setup by creating the first admin account. Fields: Name, Email, Password, Confirm password. The button reads Create account.Completing this step takes a Postgres advisory lock, creates the first admin user, marks the instance claimed, signs the admin in, and hard-navigates to /chat. If that final sign-in returns no session cookie, the wizard reports Failed to finish setup. and stays on the form.
The token is compared against SELF_HOSTED_SETUP_TOKEN with a timing-safe comparison, and the claim runs under a named advisory lock, so two people racing the wizard cannot both create an admin. When the instance is claimed, it is stamped with the signup policy invite_only and the public app locked.

Setup refusals

  • Setup token is required.
  • The setup token is invalid.
  • The setup token is not configured yet.
  • Unable to verify the setup token.
  • Self-hosted setup has already been completed.
  • Setup is only available in self-hosted mode.
  • Name is required.
  • Enter a valid email address.
  • Password must be at least 8 characters long.
  • Passwords do not match.
  • Failed to finish setup.
When VITE_SELF_HOST_SOURCE=railway is set at build time, step one shows a distribution-specific subtitle and help text instead of the generic ones. VITE_SELF_HOST_SOURCE is otherwise just a label.

Signup policies

Signup policy lives on a singleton instance-settings row. Three values exist: invite_only (the default set at claim time), shared_secret and open. The guard runs before signup: The guard reads the setup token from the x-receipt-setup-token header or a selfHostedSetupToken body field, and the shared secret from x-receipt-signup-secret or selfHostedSignupSecret. It checks the email address first, then the four states above in order. Tokens and secrets are compared with a timing-safe comparison, and the shared secret is stored scrypt-hashed. Pending invitations are matched case-insensitively and must not have expired.
An invited person has no interface path to create an account on a self-hosted instance. The invite link points at /auth/sign-up, and on a self-hosted build that route always redirects to /auth/sign-in once setup is complete — the invitation id is dropped, and the sign-in page hides the create-account toggle. The signup guard itself accepts a pending invitation, so the only route that works today is a direct POST /api/auth/sign-up/email carrying the invited email address. See Members and roles for how invitations are issued.
There is no interface for changing the signup policy or the public-app lock after setup. A function to update the signup policy exists in the codebase, but no route or server function calls it, and nothing exposes the public-app lock. Once an instance is claimed it is invite_only and locked, and the only way to change that is to update the instance-settings row in Postgres directly.

The readiness checklist

The setup wizard renders a checklist built from the environment the server actually sees. Required: Optional: the markdown worker. Reported as not_available_in_self_host: PostHog, Stripe billing, auth email, social sign-in.
The checklist accepts any of three names for Postgres, but the application and the Receipt runtime read ZERO_UPSTREAM_DB and nothing else. A green Postgres tick from DATABASE_URL alone does not mean the app can connect.
The Auth email row is misleading. It is hard-coded to not_available_in_self_host, but a self-hosted instance with a mail transport configured does deliver invitation email. The copyable signup link is shown in the invite dialog either way.

The self-host environment template

The repository ships a second, self-hosting-specific environment template at apps/start/.env.self-host.example, separate from the general apps/start/.env.example. Alongside VITE_APP_INSTANCE_MODE=self_hosted and SELF_HOSTED_SETUP_TOKEN it presets the choices a self-hosted deployment usually wants — RECEIPT_REPO_KEY, VITE_ENABLE_EMBEDDING=false, ALLOW_USER_COST_DISPLAY=true, VITE_DISABLE_REDIS=false and UPLOAD_STORAGE_PROVIDER=s3_compatible — plus the storage, Redis and model-access blocks. Configuration lists the variables the code actually reads. No other document in the repository references the template — not the README, not the development guide, not the local setup guide. If you were looking for it, that is why you did not find it. Next step: Set up Postgres, run the migrations, and check the sync publication.