Sync the self-hosting stack

This commit is contained in:
2026-08-24 13:02:29 +02:00
parent 4fa9cabefb
commit 0674e550f1
4 changed files with 818 additions and 108 deletions
+242 -33
View File
@@ -165,7 +165,8 @@ inventory on every heartbeat.
builds for Linux, macOS and Windows.
- A hostname and a TLS terminator in front of it. Jarvis speaks plain HTTP and does not manage
certificates.
- An API key for an OpenAI-compatible endpoint. The api will not start without one.
- An API key for an OpenAI-compatible endpoint. The install screen asks for it and tests it before
saving; you do not need it in hand before you start.
- **No licence key.** Jarvis runs the [community edition](#the-community-edition) out of the box; a
key raises the limits when you outgrow them.
- Roughly 2 GB of RAM for the stack and room for Postgres to grow.
@@ -173,25 +174,78 @@ inventory on every heartbeat.
## Install
```sh
git clone https://git.luxit.be/Luxit/jarvis-selfhost.git
cd jarvis-selfhost
cp .env.example .env
$EDITOR .env # every comment in it is load-bearing; the secrets note especially
docker compose pull
curl -O https://git.luxit.be/Luxit/jarvis-selfhost/raw/branch/main/docker-compose.yml
docker compose up -d
```
First boot syncs the database schema and runs its data backfills before the API listens, so give it
about a minute. Then point your reverse proxy at `JARVIS_PORT` and open the app.
That is the whole first deployment. **There is no `.env` step.** First boot generates the stack's
secrets, syncs the database schema and runs its data backfills before the API listens, so give it
about a minute. Then point your reverse proxy at port 8080 and open the address in a browser.
**Give Jarvis its public address with the right scheme.** `WEB_ORIGIN` is the CORS decision and it
accepts exactly one origin; `PUBLIC_URL` is what gets baked into agent install commands, invitation
links and share links. A wrong `WEB_ORIGIN` presents as a sign-in page that cannot sign in. A wrong
`PUBLIC_URL` does not fail at deploy time — it fails weeks later, on somebody else's server.
An install screen takes it from there. It welcomes you, creates the first administrator inside a
bounded window, then asks for the address this instance answers on and who may create an account on
it, the model, your first organization, and — every one of them skippable — mail, a licence and
whether this instance reports anything about itself. It finishes by showing you what the deployment
actually looks like from inside, and by making you take a backup of the one value nothing can
reconstruct. Ten screens, about five minutes, and **only the address really needs you**: everything
else has a "later" that costs nothing.
**The first account created becomes super-admin.** Self-registration is open by default, so sign up
immediately after the stack is up and then close registration under **Settings → Platform →
General**. Leaving it open means anyone who reaches the sign-in page gets an account.
Everything it asks is a setting you can change afterwards from an ordinary screen. Nothing it asks
needs a container restart to change.
### The claim window
**A brand-new instance can be claimed for 30 minutes after it starts, and the first account created
owns it.** That is why the install screen shows a countdown.
This replaces something worse. Jarvis used to accept exactly one registration on an instance with no
users and make that account super-admin — so a freshly deployed instance reachable from the internet
belonged to whoever found it first, on a product that holds SSH keys and opens shells on your
clients' servers. The window closes that.
If you miss it, nothing is lost. Restart the api container to open another:
```sh
docker compose restart api
```
**Losing your database does not put your instance up for grabs.** An instance whose database has gone
missing looks exactly like a brand-new one from the outside — no accounts, no completed setup — while
sitting on a hostname the world already knows. On that instance the window still opens, so you can
get back in, but creating the first administrator costs the last characters of your vault master key.
You have it; nobody else does. A genuine first installation is never asked for it.
### The one thing to do afterwards
**Back up the `jarvis_secrets` volume, and not to the same place as your database.**
It holds this instance's vault master key, which is generated on first boot and exists nowhere else.
Every credential in the vault, the licence identity, every authenticator secret and every terminal
recording is encrypted under it. **A database backup does not save you** — the backup holds the
ciphertext. An instance whose key is gone keeps looking configured, with every row in place, and
fails on the first reveal.
The install screen shows you the key, offers it as a file to download, and will not let you finish
until you have typed its last characters back. **Take the download while it is on screen** — that is
the easiest moment this value will ever be available to you.
Afterwards, and any time later, read it from the host:
```sh
docker compose exec api cat /var/lib/jarvis/secrets/vault-master-key
```
### If you prefer to configure it in writing
`.env.example` is still there and every value in it is optional — image pinning, the host port,
the agent overlay. The ones that overlap with the install screen SEED it: they are read once, when
nothing is stored yet, and never again. Change them afterwards and nothing happens; change the
setting instead.
**Upgrading from a stack that already has a `.env`?** Leave it exactly as it is. Your secrets are
adopted into the volume rather than replaced, your `WEB_ORIGIN` becomes the stored public address,
your model settings are adopted the same way, and an instance that already has an administrator is
marked installed by a backfill — so you will never see the install screen. Nothing to do.
## Your reverse proxy has two requirements
@@ -206,9 +260,12 @@ Both are the kind that produce confusing symptoms rather than clean errors.
a byte, and a 60s default cuts the response mid-stream. The client sees a connection reset rather
than a timeout, which reads like a bug in Jarvis.
Forward `X-Forwarded-For` too, and set `TRUST_PROXY_HOPS` to match how many proxies rewrite it —
that is what puts real client addresses in the audit trail and the session list instead of your
proxy's.
Forward `X-Forwarded-For` too. How many proxies rewrite it is a setting, and the install screen is
where you answer it — **it shows you the chain your own request actually carried and the address the
API resolved from it**, so you confirm what you see rather than counting hops from memory. That is
what puts real client addresses in the audit trail and the session list instead of your proxy's, and
setting it too high is the dangerous direction: the API would then believe that many hops of a
header a caller can forge. Change it later under **Settings → Platform**.
## Upgrading
@@ -322,10 +379,19 @@ the grant. Decide who holds those before enrolling anything you care about.
These are deliberate and documented rather than surprises waiting to be found.
- **`VAULT_MASTER_KEY` has no recovery.** Read the note in `.env.example`. A database backup does not
protect what it seals — the backup holds ciphertext encrypted under that key, and that covers more
than the vault: the outbound-mail client secret, this instance's licence identity key, every TOTP
secret and every terminal recording go with it.
- **The vault key has no recovery.** It is generated on first boot into the `jarvis_secrets` volume
and exists nowhere else. A database backup does not protect what it seals — the backup holds
ciphertext encrypted under that key, and that covers more than the vault: the outbound-mail client
secret, this instance's licence identity key, every TOTP secret and every terminal recording go
with it. The install screen makes you read it and type its last characters back before it will
finish, which is the only reason anybody would. See [Backups](#backups-and-restoring-one).
**It also cannot be rotated.** There is no procedure that re-wraps existing data under a new key,
so if this value is disclosed — read out on a screen share, pasted into a ticket, on a laptop that
walked — the answer is a new instance and a fresh set of credentials, not a rotation. The other
three generated secrets are ordinary: delete the file from `jarvis_secrets` and restart, and the
init service writes a new one. Doing that to `jwt-access-secret` or `jwt-refresh-secret` signs
everybody out, which is usually the point; doing it to `postgres-password` needs the database's own
password changed to match, so plan that one.
- **SSH host keys are not verified.** Every SSH connection trusts whatever key answers. This is the
one gap in the execution path with no compensating control.
- **The api must run as a single replica.** In-flight runs, pending approvals, presence and the
@@ -365,14 +431,18 @@ docker compose ps # who is running, and who is restarting
docker compose logs -f api # the api says why it refused to start
```
The api validates its configuration at boot and **fails fast rather than starting half-configured**,
so a first-boot failure is almost always one line of `.env`:
**An unconfigured instance now starts and says so** rather than refusing to boot. That is the
change: a wrong model key used to make the api exit 1, and the restart policy turned it into a crash
loop with the reason buried in a log. It comes up, shows the install screen, and tells you what is
wrong on the screen that asks for it.
| Symptom | Cause |
| --- | --- |
| `docker compose up` exits complaining about a variable | That variable is empty in `.env`. Compose refuses before any container starts. |
| api restarts in a loop, logs `Invalid environment configuration` | A value is present but wrong. `VAULT_MASTER_KEY` must base64-decode to exactly 32 bytes. |
| The sign-in page loads but cannot sign in | `WEB_ORIGIN` is not the address the browser used, scheme included. It accepts one origin, not a list. |
| Every page redirects to an install screen | This instance has not been installed yet. That is the wizard, not an error. |
| The install screen says the claim window has closed | Nobody claimed it within 30 minutes of boot. `docker compose restart api` opens another. |
| The assistant answers "this instance has no model configured" | Exactly that. **Settings → Platform → Model**, where the test button will tell you what the provider thinks. |
| api restarts in a loop, logs `Invalid environment configuration` | A value you set yourself is wrong. Only the database, Redis and the vault key are validated at boot now. |
| The sign-in page loads but cannot sign in | The stored public address is not the one the browser used, scheme included. Change it under **Settings → Platform**. |
| Chat never answers and nothing streams | The WebSocket upgrade is not being forwarded. |
| An answer dies part-way through, every time | The proxy's read timeout is too short. |
| Everything works but one kind of thing cannot be created | A limit is full — the community edition's, or your licence's. The message names which. See below. |
@@ -381,20 +451,110 @@ so a first-boot failure is almost always one line of `.env`:
The api takes about a minute on first boot, syncing the schema before it listens. `docker compose ps`
showing `health: starting` for that long is expected, not a fault.
### When you are locked out of the only administrator account
There is no password reset in this product — no email flow, no "forgot password" link. That is
deliberate, and it means the single super-admin the wizard creates is a single point of failure until
you do something about it.
**Do this now, not later: make a second super-admin.** **Settings → Users → New user**, platform role
*Super admin*, and set their password on the same screen. Outbound mail is not required for it. Two
minutes, and it turns every case below into somebody else clicking a button.
**If another account can still sign in**, promote it and let it fix the first:
```sh
docker compose exec -T postgres psql -U jarvis -d jarvis \
-c "UPDATE \"User\" SET \"platformRole\" = 'SUPER_ADMIN' WHERE email = 'colleague@example.com';"
```
They then reset the locked-out password under **Settings → Users**.
**If no account can sign in at all**, set a password hash directly. Jarvis stores argon2id, and the
api image carries the library that makes one — so the hash is generated by the same code that will
check it:
```sh
docker compose exec api node -e \
"const a=require('argon2');a.hash(process.argv[1],{type:a.argon2id}).then(h=>console.log(h))" \
'the-new-password-at-least-12-characters'
```
Then write it, and make sure the account is active and privileged:
```sh
docker compose exec -T postgres psql -U jarvis -d jarvis -c \
"UPDATE \"User\" SET \"passwordHash\" = '<the \$argon2id\$… string>', \"isActive\" = true,
\"platformRole\" = 'SUPER_ADMIN' WHERE email = 'you@example.com';"
```
Quote the hash in single quotes — it contains `$` characters your shell would otherwise eat.
If the account also holds a second factor you no longer have: when another administrator exists, they
do it properly from **Settings → Users**, which strips the factors, ends every open session and files
an audit entry. With nobody left to click it, clear all three parts by hand — the passkeys, the
authenticator secret, and the flag that says a factor is expected — or the account will keep
demanding one:
```sh
docker compose exec -T postgres psql -U jarvis -d jarvis <<'SQL'
DELETE FROM "WebAuthnCredential" WHERE "userId" = (SELECT id FROM "User" WHERE email = 'you@example.com');
DELETE FROM "UserTotpCredential" WHERE "userId" = (SELECT id FROM "User" WHERE email = 'you@example.com');
UPDATE "User" SET "mfaEnabled" = false, "mfaEnabledAt" = NULL WHERE email = 'you@example.com';
SQL
```
Enrol a new factor as soon as you are back in, and note that this leaves no audit trail of its own —
the trail is your shell history.
**Re-running the install wizard is not a recovery route.** Its claim window only opens on an instance
with no users and no completed setup, so on a working deployment it will refuse — and making it
refuse less by clearing those columns by hand would hand your live instance to whoever reaches it
first.
### Moving the instance to a new address
Changing the public address under **Settings → Platform → Instance** is supported, and it has two
consequences that are not obvious and not reversible by changing it back.
**Enrolled agents keep dialling the old one.** Each machine stores the address it was enrolled with
and does not learn a new one from the server. After a move they go OFFLINE and stay there. Every
agent has to be re-enrolled against the new address, so a move is a job scheduled with whoever
administers those machines rather than a settings change made on a Friday.
**Every passkey stops being offered.** A passkey is bound to the hostname it was enrolled under. They
do not fail loudly — the browser simply stops presenting them — so accounts quietly fall back to
passwords, and anyone who set up a passkey and never learned their password is locked out. Make sure
the people who use passkeys know their passwords, or enrol them again afterwards, before you move.
Nothing else moves: conversations, credentials, assets and recordings are rows and do not know the
address. Update your reverse proxy and the `WEB_ORIGIN`-shaped world outside first, then the setting,
then the agents.
## Backups, and restoring one
Postgres holds everything except the vault key — avatars, documents, exports and terminal recordings
are all rows, not files on disk. **Of the stack's volumes, only `postgres_data` is worth backing
up.** `redis_data` carries nonces, rate-limit counters and the socket fan-out, all of which rebuild
themselves; `agent_releases` is refilled by the next `docker compose pull`.
are all rows, not files on disk. **Two volumes are worth backing up, and they must go to different
places:** `postgres_data` and `jarvis_secrets`. `redis_data` carries nonces, rate-limit counters and the
socket fan-out, all of which rebuild themselves; `agent_releases` is refilled by the next
`docker compose pull`.
They are separate volumes precisely so that they can be, and must be, backed up separately. A dump
that travelled with the key that opens it is a dump that opens itself.
```sh
docker compose exec -T postgres pg_dump -U jarvis jarvis | gzip > jarvis.sql.gz
```
Plus `VAULT_MASTER_KEY`, stored somewhere that is not this host. A dump without the key is a database
whose credentials cannot be read — and an instance restored under a *different* key keeps **looking**
configured, because nothing on a settings page decrypts anything. It fails on every reveal instead.
Plus the vault key, stored somewhere that is not this host and not beside the dump:
```sh
docker compose exec api cat /var/lib/jarvis/secrets/vault-master-key
```
A dump without the key is a database whose credentials cannot be read — and an instance restored
under a *different* key keeps **looking** configured, because nothing on a settings page decrypts
anything. It fails on every reveal instead.
To restore, stop the api so nothing writes while you work, then load the dump into an empty database:
@@ -409,6 +569,40 @@ docker compose start api web
Restore under the **same `VAULT_MASTER_KEY`** the dump was taken with. That value is not in the dump,
and no part of the restore will warn you that it differs.
### Putting the key back on a new host
**Do this before the first `docker compose up -d`, not after.** The generator writes each secret with
`O_EXCL` and never overwrites one that exists, which is what stops an upgrade quietly replacing the
key your vault is sealed under. On a fresh host it cuts the other way: bring the stack up first and
a brand-new key is written, after which anything you put in `.env` is ignored for good.
If you kept the `jarvis_secrets` volume, restore it and nothing else is needed. If all you have is
the base64 string, put it in `.env` before the first start:
```sh
# On the NEW host, in the directory holding docker-compose.yml — before any `up -d`.
echo 'VAULT_MASTER_KEY=<the base64 string you saved>' >> .env
docker compose up -d
```
The `init` service adopts that value into the volume on the first run and the api reads it from
there afterwards, so the line in `.env` is a seed rather than a permanent setting — you may remove it
once the stack is up.
If you have already started the stack and a wrong key was generated, delete the file and let the init
service run again. **Only ever do this on a host whose vault you are deliberately re-keying** — on a
working instance it destroys every credential, the mail client secret, this instance's licence
identity, every TOTP secret and every terminal recording:
```sh
docker compose down
docker run --rm -v jarvis_secrets:/s alpine rm -f /s/vault-master-key
# then the `.env` line above, then `docker compose up -d`
```
To check you restored under the right one, reveal a stored credential in **Settings → Vault**. The
rows are all there under a wrong key; only a reveal tells you the truth.
## Removing it
```sh
@@ -460,6 +654,21 @@ reach us would just be an email with extra steps.
feature and every machine; what you lose is the request form, and our ability to know you exist. That
is a fair trade and we would rather you took it than felt watched.
> **This did not work before 24 August 2026, and we are sorry.** The line was read from `.env` by
> compose — which uses that file to fill in placeholders inside the compose file, not to hand values
> to the services — and the `api` service never listed the variable, so the API saw nothing and
> applied its default of "on". An instance that had opted out reported anyway. Pull this repository
> again and `docker compose up -d`; the fix is in the compose file, not in the images.
>
> **Do not trust a file to tell you.** Ask the instance:
>
> ```sh
> docker compose exec api printenv JARVIS_TELEMETRY
> ```
>
> No output means it still has not landed. **Settings → Licence** answers the same question on
> screen, from what the API actually resolved — which is why that screen exists.
Everything in the next section applies to a community instance as well: the field list is the whole
field list, and it is the same one.