> ## Documentation Index
> Fetch the complete documentation index at: https://docs.kentron.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# What self-hosted mode actually is

> Self-hosted mode is a build-time decision baked into the browser bundle, and it changes identity, billing and telemetry — read this before you configure anything else.

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.

<Warning>
  **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.
</Warning>

## 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:

```bash theme={null}
--build-arg VITE_APP_INSTANCE_MODE=self_hosted
```

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.

<Note>
  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`.
</Note>

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

| Behaviour                                          | `cloud`                                                                                                                                            | `self_hosted`                                                                                              |
| -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| Email OTP plugin                                   | registered                                                                                                                                         | not registered                                                                                             |
| Email verification required at signup              | true, unless OTP is disabled                                                                                                                       | false                                                                                                      |
| Google / social auth                               | allowed if credentials are set                                                                                                                     | blocked — `Social auth is disabled for self-hosted instances.`                                             |
| Email verification and password recovery endpoints | allowed                                                                                                                                            | blocked — `Email verification and password recovery are disabled for self-hosted instances.`               |
| Anonymous sessions                                 | plugin registered                                                                                                                                  | not registered; the client's anonymous sign-in also returns early                                          |
| Stripe plugin and client                           | registered when keys are set                                                                                                                       | never registered                                                                                           |
| Email change                                       | enabled                                                                                                                                            | disabled                                                                                                   |
| Root `/`                                           | renders the marketing landing page only when the request host is one of the two marketing hostnames; every other host redirects to `/auth/sign-up` | always redirects to `/auth/sign-up`                                                                        |
| `/auth/sign-up`                                    | serves the form                                                                                                                                    | always redirects — to `/auth/sign-in` when setup is complete, otherwise to `/setup`                        |
| `/auth/sign-in`                                    | serves the form                                                                                                                                    | redirects to `/setup` while setup is incomplete                                                            |
| "Don't have an account? Create Account" footer     | shown                                                                                                                                              | hidden                                                                                                     |
| Default organization plan and seats                | `free`, `RECEIPT_DEFAULT_FREE_SEAT_COUNT` seats (default 5)                                                                                        | `self_hosted`, 100000 seats                                                                                |
| Platform signup credit                             | \$5.00                                                                                                                                             | \$0                                                                                                        |
| Usage metering                                     | enabled per plan                                                                                                                                   | disabled                                                                                                   |
| Invitations                                        | emailed                                                                                                                                            | emailed as well when a mail transport is configured; the invite dialog always shows a copyable signup link |
| Zero sync auth                                     | access token, decided from the resolved Zero cache URL                                                                                             | always an access token, whatever the cache URL                                                             |

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.

<Note>
  `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.
</Note>

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

<Warning>
  **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.
</Warning>

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

<Steps>
  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

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

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

## 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:

| State                                                            | Result                                                                            |
| ---------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| Missing email address                                            | 400 — `A valid email address is required to sign up.`                             |
| Setup not complete, missing or bad setup token                   | 401 — `This self-hosted instance has not been claimed yet. Complete setup first.` |
| `open`                                                           | Signup allowed                                                                    |
| `shared_secret`, with a missing or wrong secret                  | 403 — `A valid shared signup secret is required for this instance.`               |
| `invite_only`, with no pending invitation for that email address | 403 — `This self-hosted instance currently accepts invite-only signups.`          |

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.

<Warning>
  **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](/core/members-and-roles) for how invitations are issued.
</Warning>

<Warning>
  **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.
</Warning>

## The readiness checklist

The setup wizard renders a checklist built from the environment the server actually sees.

**Required:**

| Item           | Read from                                                             |
| -------------- | --------------------------------------------------------------------- |
| App URL        | `BETTER_AUTH_URL`                                                     |
| Auth secret    | `BETTER_AUTH_SECRET`                                                  |
| Postgres       | `ZERO_UPSTREAM_DB`, `DATABASE_URL` or `DATABASE_PUBLIC_URL`           |
| Redis          | `REDIS_URL`, or reported as `disabled` when `VITE_DISABLE_REDIS=true` |
| Object storage | `UPLOAD_STORAGE_PROVIDER` and the matching `R2_*` or `S3_*` variables |
| Setup token    | `SELF_HOSTED_SETUP_TOKEN`                                             |

**Optional:** the markdown worker.

**Reported as `not_available_in_self_host`:** PostHog, Stripe billing, auth email, social sign-in.

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

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

## 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](/core/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](/core/database-and-migrations).
