# Install (/docs/self-hosting/install)



<AlphaNotice />

This page installs Chelabase from its release images with Docker Compose. It is not the workbench: `just workbench up` is the development loop for people who change Chelabase itself (it builds on the host and keeps its own stack per checkout). To run Chelabase, use this page.

## Requirements [#requirements]

* Docker Engine 26.0 or newer and Compose 2.30 or newer: the stack mounts volume sub-paths, which both need. Check with `docker version` and `docker compose version`.
* A machine that can run about 26 containers. The worker starts each database's compute as a further container, so it needs the Docker socket.
* The repository checkout, for `infra/compose`.

<Callout type="warn">
  **Do not expose the host before you claim the instance.** A new instance has no admin. The first account that signs up with the setup code becomes the admin of the whole instance, and the code is only a guard against strangers: anyone who can read it, or who can reach the sign-up page and guess it, could claim the instance before you do. By default the gateway is published on `127.0.0.1` only. Keep it that way, claim the instance, and only then put it on a public address.
</Callout>

## Install in three commands [#install-in-three-commands]

```bash
cd infra/compose
cp .env.example .env
docker compose up -d --wait
```

`.env` is required. Every setting has a default, so the copy runs as it is on one machine and serves Studio at `http://studio.chelabase.localhost:8000`, and databases on `127.0.0.1:5432` (see [Connect to databases](#connect-to-databases)). A fresh project is healthy in under a minute once the images are pulled. The compute image each database runs on isn't a Compose service, so the worker pulls it when it creates the first database, which then takes longer.

Two one-shot containers do the setup, and both must exit 0:

* `init-secrets` makes every secret in the `secrets` volume (database passwords, tokens, keys), never overwriting one, and prints the setup code.
* `init-bootstrap` sets up the identity service: its database role and the `members` pool where Studio's accounts live. It is safe to run again.

## The services [#the-services]

| Service                                                        | What it does                                                                                                                                                |
| -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `gateway`                                                      | The only public entry: Studio, the APIs and the optional hosts, by host name                                                                                |
| `studio`                                                       | The web app                                                                                                                                                 |
| `orchestrator`, `worker`                                       | The Management API and the jobs that create, branch and delete databases                                                                                    |
| `auth`                                                         | The login API: sign-in, sign-up, invites, password reset, and member and app-user tokens                                                                    |
| `identity`                                                     | Every account (Studio's members and each project's app users) and the emails with codes; internal only, see [Identity service](/docs/architecture/identity) |
| `rest`, `realtime`                                             | The data API and live rows                                                                                                                                  |
| `metadata-db`, `nats`, `valkey`                                | The control plane's database (the identity service's schemas live there too), jobs, and caches                                                              |
| `storage-*`, `pageserver`, `safekeeper*`, `proxy`, `seaweedfs` | Neon's storage and the Postgres proxy                                                                                                                       |

Running `docker compose up` again with nothing changed recreates nothing and prints no new code. `docker compose down` followed by `up` keeps the secrets.

To try Chelabase with a demo organisation already set up, see [Example organisation](/docs/self-hosting/example).

## Read the setup code [#read-the-setup-code]

The code is in the logs of `init-secrets`:

```bash
docker compose logs init-secrets
```

Look for `Setup code: XXXX-XXXX-XXXX-XXXX-XXXX`. A recreated `init-secrets` container has no log of the code, so print it again with this command, which has to run as user 10001 (as root, `chelabase-init` refuses the secrets directory):

```bash
docker compose run --rm --no-deps --user 10001:10001 \
  --entrypoint chelabase-init init-secrets setup-code \
  --dir /var/lib/chelabase/secrets
```

## Claim the instance [#claim-the-instance]

<Callout type="warn">
  Still local only? Check before you go on: the claim is the moment the instance is open to whoever has the code. Do it on the machine itself, or over a network you trust, before the host is reachable from anywhere else.
</Callout>

1. Open `http://studio.chelabase.localhost:8000` and choose **Sign in**, then **Create account**.
2. Fill in the form and enter the setup code. An instance that is still unclaimed shows the code field (`GET /auth/v1/instance` answers `{"setupRequired":true}`).
3. You become the instance's admin. The code stops mattering: from now on sign-up follows the normal rules.

After the claim, a second sign-up is refused with `sign_up_closed` unless you switch open sign-up on in Studio; people who then sign up wait as pending members for your approval. A sign-up with a missing or wrong code on an unclaimed instance is refused with `setup_code_invalid` and creates no account. If two people claim at the same moment, one becomes the admin and the other is admitted as pending. See [Members and invites](/docs/using/members-and-invites).

## Choose your own secrets (optional) [#choose-your-own-secrets-optional]

By default `init-secrets` generates every secret. To pick values yourself, uncomment the `CHELABASE_SEED_*` lines in `.env` and fill them in **before the first `docker compose up`**. The full list is the commented `CHELABASE_SEED_*` block in `.env.example` (database passwords, the S3 keys, the NATS and Valkey passwords, and the internal tokens).

* **First run only.** A seed is written as its secret file when that file does not exist yet. Once the file exists, a different value in `.env` makes `init-secrets` fail and name the key. Changing a secret later is not supported yet (rotation is [chelabase/chelabase#6](https://github.com/chelabase/chelabase/issues/6)). To keep a value you chose, leave the line as it is.
* **Allowed values.** Printable ASCII with no spaces, quotes (`"` and `'`), backslash, backtick or `$`; at most 1024 characters. Passwords and tokens need at least 16 characters. Three third-party services receive a secret inside a quoted string in their own config, which is why the quote characters are excluded.
* **Keep `.env` at mode 600** (`chmod 600 .env`) once it holds a seed or an SMTP setting. Don't commit it.
* Seeds never reach a container's environment. They do not cover key pairs, certificates or signing keys, the identity service's secret, database password and tokens, or the cookie key; those are always generated.

### The secrets volume [#the-secrets-volume]

Everything `init-secrets` makes lives in the `secrets` volume, each file mounted only into the services that read it. The ones that sign or protect accounts:

| File                                                                                                                                  | Read by                                                         | What it is                                                             |
| ------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------- |
| `auth/member-jwt.key`, `auth/member-jwt.pub`                                                                                          | `auth` signs; the gateway, the orchestrator and realtime verify | The member access token pair (EdDSA)                                   |
| `auth/app-jwt.key`, `auth/app-jwt.pub`                                                                                                | `auth` signs; the gateway verifies                              | The app-user token pair                                                |
| `auth/cookie.key`                                                                                                                     | `auth`                                                          | Encrypts the sign-in session and step cookies                          |
| `identity/secret`                                                                                                                     | `identity`                                                      | Better Auth's signing secret; it also keys the hashes of emailed codes |
| `identity/db.password`                                                                                                                | `identity`, `init-bootstrap`                                    | The identity service's database password                               |
| `tokens/auth-identity.token`, `tokens/orchestrator-identity.token`, `tokens/worker-identity.token`, `tokens/bootstrap-identity.token` | `identity` and each caller                                      | One bearer per caller of the identity service                          |
| `setup/setup.code`                                                                                                                    | `init-secrets`, the orchestrator                                | The setup code                                                         |

None of them is ever rewritten once it exists.

Never delete the `secrets` volume while the database volumes exist: the databases keep the old passwords, and new ones would not match.

## Settings people change [#settings-people-change]

Every setting is in `.env.example` with a comment. The ones you are most likely to touch:

| Setting                                                                             | Default                                                            | What it does                                                                                                                                                                                                                             |
| ----------------------------------------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `CHELABASE_STUDIO_HOST`, `CHELABASE_API_HOST`, `CHELABASE_AUTH_HOST`                | `*.chelabase.localhost:8000`                                       | The three public host names (see [Hosts](/docs/self-hosting/hosts))                                                                                                                                                                      |
| `CHELABASE_STUDIO_HOST_NAME`, `CHELABASE_API_HOST_NAME`, `CHELABASE_AUTH_HOST_NAME` | the localhost names                                                | The same three names without the port                                                                                                                                                                                                    |
| `CHELABASE_PUBLIC_PORT`                                                             | `8000`                                                             | The port in the hosts (80 for `http`, 443 for `https` when the hosts carry none)                                                                                                                                                         |
| `CHELABASE_PUBLIC_SCHEME`                                                           | `http`                                                             | `http` or `https`: the scheme of every public URL                                                                                                                                                                                        |
| `CHELABASE_PUBLIC_SECURE`                                                           | `false`                                                            | `true` when the scheme is `https`                                                                                                                                                                                                        |
| `CHELABASE_HTTP_ADDR`                                                               | `127.0.0.1`                                                        | The host address the gateway is published on (`0.0.0.0` for every interface)                                                                                                                                                             |
| `CHELABASE_HTTP_PORT`                                                               | `CHELABASE_PUBLIC_PORT`                                            | The host port the gateway is published on                                                                                                                                                                                                |
| `CHELABASE_PROXY_DOMAIN`                                                            | `localtest.me`                                                     | The domain of database hosts, `<endpoint>.<domain>` (see [Connect to databases](#connect-to-databases))                                                                                                                                  |
| `CHELABASE_PROXY_ADDR`                                                              | `127.0.0.1`                                                        | The host address the proxy (Postgres and SQL over HTTP) is published on                                                                                                                                                                  |
| `CHELABASE_PROXY_PORT`, `CHELABASE_PROXY_HTTP_PORT`                                 | `5432`, `4444`                                                     | The host ports of the proxy's Postgres and SQL-over-HTTP listeners                                                                                                                                                                       |
| `CHELABASE_IMAGE_REPO`, `CHELABASE_IMAGE_TAG`                                       | `ghcr.io/chelabase`, a pinned `<commit>@sha256:<digest>` per image | Where the images come from. The compose file pins each release image by digest. Setting either variable replaces every pin: one tag for all images, with no digest check. For testing or images you built only (see [Upgrade](#upgrade)) |
| `CHELABASE_CONTROL_SUBNET`                                                          | `10.214.0.0/22`                                                    | The control network; change it if it overlaps a network of the host                                                                                                                                                                      |
| `CHELABASE_SMTP_*`                                                                  | unset                                                              | The identity service's email (below)                                                                                                                                                                                                     |
| `CHELABASE_MEMBER_ACCESS_TTL_SECS`                                                  | `300`                                                              | How long a member access token lives, 60 to 900 seconds                                                                                                                                                                                  |
| `CHELABASE_MEMBER_SESSION_CHECK_SECS`                                               | `15`                                                               | How long the orchestrator reuses the identity service's answer that a member token's session is live, 0 to 30 seconds (0 asks every time). A token stops working at most this long after logout or a password reset                      |

Compose cannot split a host into its name and port, so when you change the hosts, `.env` has to repeat what Compose can't derive: the three `*_HOST_NAME` values, `CHELABASE_PUBLIC_PORT` and `CHELABASE_PUBLIC_SECURE`. `init-secrets` checks that they match the hosts and, if not, fails and names the exact line to fix. For example, `http` on port 9000:

```bash
CHELABASE_STUDIO_HOST=studio.chelabase.localhost:9000
CHELABASE_API_HOST=api.chelabase.localhost:9000
CHELABASE_AUTH_HOST=auth.chelabase.localhost:9000
CHELABASE_STUDIO_HOST_NAME=studio.chelabase.localhost
CHELABASE_API_HOST_NAME=api.chelabase.localhost
CHELABASE_AUTH_HOST_NAME=auth.chelabase.localhost
CHELABASE_PUBLIC_PORT=9000
```

Change the hosts before the first `up`. The member tokens' issuer is derived from the auth host and the scheme and is signed into every token.

For a real domain with TLS terminated by your proxy in front of the gateway, the hosts carry no port and the public port is 443:

```bash
CHELABASE_PUBLIC_SCHEME=https
CHELABASE_STUDIO_HOST=studio.acme.com
CHELABASE_API_HOST=api.acme.com
CHELABASE_AUTH_HOST=auth.acme.com
CHELABASE_STUDIO_HOST_NAME=studio.acme.com
CHELABASE_API_HOST_NAME=api.acme.com
CHELABASE_AUTH_HOST_NAME=auth.acme.com
CHELABASE_PUBLIC_PORT=443
CHELABASE_PUBLIC_SECURE=true
CHELABASE_HTTP_ADDR=127.0.0.1
CHELABASE_HTTP_PORT=8000
```

Point the proxy at the gateway's published port (`8000` here) in plain HTTP, passing the `Host` header through. `CHELABASE_HTTP_ADDR=127.0.0.1` keeps that plain-HTTP port off your network when the proxy runs on the same host; set it to an address the proxy can reach (or `0.0.0.0` behind a firewall) only when the proxy runs elsewhere. The gateway listens on 443 inside its container, but in plain HTTP: the services never call the public `https://` URLs from inside the network (see [Hosts](/docs/self-hosting/hosts#tls), including what to set when a reverse proxy is in front).

Set `CHELABASE_GATEWAY_TRUSTED_PROXIES` to your proxy's address too, so per-IP sign-in and sign-up limits see each client's own address ([Behind a reverse proxy](/docs/self-hosting/hosts#behind-a-reverse-proxy)).

### Email [#email]

The identity service sends the emails: invite codes, password-reset codes and, when it is on, sign-up verification codes. Without SMTP nothing is mailed: invites are handed over as links, which carry the code, and password reset is unavailable. To send mail, set these lines in `.env`:

```bash
CHELABASE_SMTP_HOST=smtp.example.com
CHELABASE_SMTP_PORT=587
CHELABASE_SMTP_TLS=starttls
CHELABASE_SMTP_FROM=chelabase@example.com
CHELABASE_SMTP_USER=chelabase
CHELABASE_SMTP_PASSWORD_PATH=/etc/chelabase/smtp.password
```

`CHELABASE_SMTP_TLS` says how the connection is secured: `starttls` (the default; the server must offer STARTTLS), `tls` (implicit TLS, the default on port 465) or `none` (plain text, only for a relay on the same host or network). `CHELABASE_SMTP_PORT` defaults to 587. `CHELABASE_SMTP_USER` and the password go together: set both or neither.

The password is never put in `.env`: it is read from the host file at `CHELABASE_SMTP_PASSWORD_PATH`, which is mounted into the `identity` service only. The service runs as uid 10001 and treats the file as a secret: it must be a regular file (not a symlink) owned by uid 10001 at mode 0600. A file its group or others can read or write is refused.

```bash
sudo install -d /etc/chelabase
sudo install -m 0600 -o 10001 /dev/null /etc/chelabase/smtp.password
sudo sh -c 'cat > /etc/chelabase/smtp.password'   # paste the password, then Ctrl-D
```

A password file uid 10001 can't read, one with a looser mode than 0600, a symlink, `CHELABASE_SMTP_HOST` without `CHELABASE_SMTP_FROM`, or an unknown `CHELABASE_SMTP_TLS` stops the identity service at start with an error naming the setting (`docker compose logs identity`), and `docker compose up --wait` fails. A changed `CHELABASE_SMTP_*` setting in `.env` is applied by the next `docker compose up -d --wait`. The password file is read only at start, so after changing it run `docker compose restart identity`.

## Connect to databases [#connect-to-databases]

Apps use the [data API](/docs/using/data-api) through the gateway. Postgres clients (migrations, ORMs, admin tools, `psql`) connect through Neon's proxy, which the install publishes on `CHELABASE_PROXY_ADDR` (default `127.0.0.1`): Postgres on `CHELABASE_PROXY_PORT` (default `5432`, the Postgres default, so clients need no port; change it if the host runs its own Postgres) and SQL over HTTP on `CHELABASE_PROXY_HTTP_PORT` (default `4444`). Connection details in Studio and the API name the same host and ports.

The proxy picks the database by the host name a client connects to (its TLS server name): `<endpoint>.CHELABASE_PROXY_DOMAIN`. So every such name must reach the proxy:

* **On one machine**, keep the default `localtest.me`: every `*.localtest.me` name resolves to `127.0.0.1` in public DNS, so `ep-….localtest.me:5432` reaches the published port with nothing to set up.
* **On a server**, use a domain of yours with a wildcard DNS record pointing at the server, and publish the proxy on its address. For `*.db.example.com`:

  ```bash
  CHELABASE_PROXY_DOMAIN=db.example.com
  CHELABASE_PROXY_ADDR=0.0.0.0
  ```

  Connection details then name `ep-….db.example.com:5432`. The proxy's port is not behind the gateway or your HTTPS proxy: open it in your firewall only to the clients that need it. Every connection is TLS and needs the database's password.

`proxy-tls` issues the proxy's certificate for `*.<CHELABASE_PROXY_DOMAIN>` from a CA the install makes once and keeps in the `neon_proxy_tls` volume. A changed domain gets a new certificate from the same CA at the next `docker compose up` (then `docker compose restart proxy`). Clients verify the certificate against that CA. Fetch it once, in `infra/compose`, under the name the connection details give as `ca_file` (`proxy-ca.crt`; `CHELABASE_PROXY_CA_FILE` changes the name they report):

```bash
docker compose cp proxy:/tls/ca.crt proxy-ca.crt
psql "postgresql://postgres:…@ep-….localtest.me:5432/<db>?sslmode=verify-full&sslrootcert=proxy-ca.crt"
```

With `sslmode=verify-full` the client checks both the CA and the host name. The CA is a public certificate, not a secret; its key never leaves the volume.

## Build from source [#build-from-source]

To run images built from your clone instead of the release images:

```bash
cd infra/compose
docker compose -f docker-compose.yml -f docker-compose.build.yml up --build --wait
```

This builds one all-in-one image, `chelabase/server:local`, that runs every Rust service and the one-shots, and builds Studio and the identity service from their Dockerfiles. The wiring, secrets and health checks are the same.

## Upgrade [#upgrade]

A release is a commit of the repository: its `docker-compose.yml` pins each image by digest. Update the checkout to get the new pins, then pull those images and recreate what changed:

```bash
cd infra/compose
git pull
docker compose pull
docker compose up -d --wait
```

`docker compose pull` alone fetches the same pinned digests again and upgrades nothing. `up` recreates the containers, not the volumes: `init-secrets` creates only the secrets that are missing and never replaces one, the orchestrator applies any new metadata migrations when it starts, and `init-bootstrap` can safely run again. Back up the volumes before an upgrade you can't afford to lose. If you built from source, run the build command again after `git pull`.

`CHELABASE_IMAGE_TAG` (and `CHELABASE_IMAGE_REPO`) is not how you pick a release. Setting it replaces every pin with one tag for all the images, which Compose pulls by name with no digest check, so what runs is whatever that tag points to at pull time. Use it only to test images or to run ones you built yourself (`just images` makes `chelabase/<image>:latest`; set `CHELABASE_IMAGE_REPO=chelabase` and `CHELABASE_IMAGE_TAG=latest`).

## Stop, tear down and uninstall [#stop-tear-down-and-uninstall]

Each database runs in a compute container that the worker starts. Computes are not Compose services, so Compose neither stops nor removes them, and while one runs, the Docker networks it is attached to stay.

* **Stop and keep everything:** `docker compose stop`, later `docker compose start` (or `up -d --wait`). Computes keep running while the rest is stopped; that is harmless. `docker compose down` without `-v` keeps the data too: it reports the networks the computes hold as still in use, and the next `up` reuses them.
* **Uninstall, deleting every database and every secret:**

  ```bash
  cd infra/compose
  docker compose stop
  docker ps -aq --filter label=chelabase.dev/stack=compose | xargs -r docker rm -f
  docker compose down -v
  ```

  `compose` in the label is the Compose project name: the directory's name, unless you set another with `-p` or `COMPOSE_PROJECT_NAME` (`docker compose ls -a` lists it). The stack is stopped first so the worker starts no compute in the meantime. A plain `docker compose down -v` without the middle line removes the services and volumes but leaves the computes and their networks behind (it reports the networks as still in use); run the middle line and `down -v` again to finish. `--remove-orphans` does not help: Compose does not count the computes as its own.

  Deleting the databases in Studio first removes their computes too, but the commands above don't depend on it.

## Known limitations [#known-limitations]

* **No secret rotation** ([chelabase/chelabase#6](https://github.com/chelabase/chelabase/issues/6)).
* The install is Docker Compose only. Kubernetes is not covered.
