Sync the self-hosting stack
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user