# Self-host the platform

Run the whole product on your own machines: a host that serves your team's Worlds, and the
platform in front of it, where people sign in with the identity provider you already use, make
orgs, invite each other and open Worlds as themselves.

This page is executed as written by `packages/cli/src/journeys/tutorials.test.ts`.

<!-- needs: issuer http://127.0.0.1:4700 -->
<!-- journey -->

Three processes, each usable without the next: the [World](../concepts/the-model.md) your app
runs against, a **host** (`volter-host`) that serves many Worlds under one address, and the
**platform** (`volter-platform`), which knows the people. The platform reaches Worlds only through
the host's own endpoints, and the host trusts the platform's short-lived passes, so no World's
token ever reaches a browser. Nothing here bills: a self-hosted platform limits and prices nothing.

## Your identity provider

The platform signs people in through one provider. Register a web application for it in your
provider's console, with this callback:

```text
http://127.0.0.1:4700/-/sign-in/callback
```

The provider gives back an issuer, a client id and a client secret. Hand them to the platform in
its environment:

| Provider | Names | Issuer |
|---|---|---|
| Google, Okta, Microsoft Entra, Keycloak, any OpenID Connect issuer | `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, and `OIDC_NAME` for the sign-in button | `https://accounts.google.com`, `https://<you>.okta.com`, `https://login.microsoftonline.com/<tenant>/v2.0` |
| GitHub, or GitHub Enterprise Server | `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, with `--provider github`; `GITHUB_URL` for an Enterprise Server | — |
| Volter Identity | `VOLTER_CLIENT_ID`, `VOLTER_CLIENT_SECRET`, with `--provider volter` | `https://id.volter.ai` |

The platform reads them from its environment, not from a file. In the shell that starts it:

```console
export OIDC_ISSUER=https://accounts.google.com
export OIDC_CLIENT_ID=1234567890-abc.apps.googleusercontent.com
export OIDC_CLIENT_SECRET=GOCSPX-…
```

(This page's runner sets these three to a stand-in provider of its own before the steps below.)

A person joins an org they were invited to when they sign in with the invited address, and only
an address the provider says it verified counts. Microsoft Entra sends no `email_verified`, and a
tenant's admins can give a user any address, an outside one included: name the domains your tenant
has verified, `OIDC_TRUST_EMAIL_DOMAINS=acme.com,acme.co.uk`, and only addresses in them count. Leave
it unset for an issuer that says which addresses it verified.

With an OpenID Connect provider or GitHub, the platform keeps its own directory of people, orgs,
members and invitations in its database, `platform.db` in its state directory. With Volter Identity they live at id.volter.ai,
which mails each invitation; a person who signs in to the platform without opening the mail sees
the invitation on their first page and joins with **Join**.

## The host

The host's directory holds the host, the dashboard (a World's pages) and the twins its Worlds use,
and one World per `<org>/<world>`. Start it trusting the platform's passes: a member the platform
signs in opens a World on its own address, as themselves.

```bash
npm install -g @volter/world
mkdir -p ../worlds && cd ../worlds
```

Give this directory its own package manifest so npm installs the host and its binary here.

```json file=../worlds/package.json
{ "name": "shared-worlds", "private": true, "type": "module" }
```

```bash
npm install @volter/world-host @volter/world-console @volter/twin-github
volter world init --bare acme/team --twins github --world acme/team
./node_modules/.bin/volter-host serve --dir . --port 4701 --trust http://127.0.0.1:4700 &
```

```text
host ready  http://127.0.0.1:4701  1 world
admin token  tok_a_
```

## The platform

The platform keeps its state (its sessions, its directory, the key it signs passes with) in one
directory. It prints its address, which provider people sign in with, and its operator token,
which opens its operator endpoints and nothing a person does.

```bash
mkdir -p ../platform && cd ../platform
```

```json file=../platform/package.json
{ "name": "world-platform", "private": true, "type": "module" }
```

```bash
npm install @volter/world-platform
export OIDC_NAME="Acme SSO"
./node_modules/.bin/volter-platform serve --dir ./state --port 4700 --provider oidc &
```

```text
platform ready  http://127.0.0.1:4700  (0 hosts enrolled; sign-in oidc; no billing)
operator token  tok_op_
```

Enroll the host: the platform checks that the host's admin endpoints answer the token it is given,
and makes every new World there.

```bash
curl -s -X POST http://127.0.0.1:4700/-/hosts -H "x-volter-token: $(cat state/admin)" -H 'content-type: application/json' -d "{\"id\":\"local\",\"base\":\"http://127.0.0.1:4701\",\"adminToken\":\"$(cat ../worlds/.volter-host/admin)\"}"
```

```text
"id":"local"
```

The platform says who people continue with, and sends a sign-in to your provider:

```bash
curl -s http://127.0.0.1:4700/-/platform
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://127.0.0.1:4700/-/sign-in
```

```text
"provider":{"kind":"oidc","name":"Acme SSO"}
"billing":false
302 http://127.0.0.1:
```

## Sign in, make an org, invite your team

Open `http://127.0.0.1:4700/` and choose **Continue with Acme SSO**. Your provider signs you in and
sends you back; with no org yet, the platform opens on **Create an org**. Name it `acme`: an org's
name is its address (`http://127.0.0.1:4700/acme`) and the first part of its Worlds' names.

Under **Members**, add a teammate by email. Someone who has signed in here before is added at
once; anyone else is invited, and joins the moment they sign in with that address, once your
provider has verified it. The platform emails the invitation only when it can send mail: a Resend
key in `RESEND_API_KEY` and a sender that account has verified in `--mail-from`. Without them, as
on this page, the invitation is written to the platform's log instead, and you tell your teammate
yourself.

## Make a World and open it

On the org's **Worlds**, choose **Create World**, name it `staging` with the vendors your app uses,
and the platform makes it on the host. **Open** sends you to the World's own address,
`http://staging--acme.localhost:4701`, signed in as yourself with a two-minute pass: its vendors'
screens, its branches, what your app did. Its one link out, **← acme's Worlds**, comes back here.

The World `acme/team` the host already served has no org on the platform yet. Once the `acme` org
exists, its operator claims it, having checked that the org owns it:

```console
curl -s -X POST http://127.0.0.1:4700/-/operator/claim -H "x-volter-token: $(cat state/admin)" -H 'content-type: application/json' -d '{"world":"acme/team","org":"acme","confirm":true}'
```

Its apps keep the token they already hold.

## On a real address

Behind a proxy, give each process the address people reach it at: `volter-platform serve --url
https://app.example.com`, and `volter-host serve --url https://worlds.example-worlds.com
--world-origins worlds.example-worlds.com --trust https://app.example.com`, with a wildcard DNS
record and certificate for `*.worlds.example-worlds.com` so each World has an origin of its own. Give
the Worlds a registrable domain of their own, as github.com gives its users' content
githubusercontent.com: a World's page runs the vendors' screens and whatever the app stored, and on
the platform's own domain it would be same-site with the platform. The host says so when they share
one, and refuses World origins over plain http except on this machine. Register the provider's callback
at the platform's real address. The [CLI reference](../reference/cli.md#the-hosting-product) has
every flag and environment name of both commands (mail, backups, a Help form's address); the
[platform API](../reference/platform-api.md) has the platform's endpoints.

## With Docker Compose

`deploy/self-hosted` runs the same two
processes from one image built from the repository: the platform at `localhost:4000`, which enrolls
the host on its first start, and the host at `localhost:4001`, each World on
`<world>--<org>.localhost:4001`. Copy its `.env.example` to `.env`, fill in your provider's three
values, register `http://localhost:4000/-/sign-in/callback` with the provider, and from that
directory:

```text
docker compose up --build
```

The platform's log prints its operator token on the first start. State lives in two named volumes:
the platform's (its database, signing key and operator token) and the host's (every World, and
the admin token the platform enrolled it with).

To upgrade, pull the repository and run `docker compose up -d --build` again: both volumes stay,
the platform applies its database's new migrations as it starts, and the host keeps its token, so
the platform still reaches it and enrolls nothing twice. Back up first, with the platform stopped so
its database is consistent (and the host's volume the same way, from `host` and `/app/worlds`):

```text
docker compose stop platform
docker compose run --rm --no-deps -T --entrypoint tar platform czf - -C /app/state . > platform-state.tgz
docker compose start platform
```

On a real address the platform also backs itself up nightly to an S3 bucket (`--backup-bucket`, in
the compose file's `command`).

## Stop both

Both were started from their package's own command rather than through `npx`, so stopping the job
stops the server itself (on Windows, `npx` leaves the server running when its job ends). Their
state stays in `../worlds` and `./state`, and starting them again brings everything back.

```bash
kill %2 %1
```
