Sync the self-hosting stack

This commit is contained in:
2026-10-21 12:12:12 +02:00
parent e210b4db11
commit 6a2b67364b
13 changed files with 263 additions and 134 deletions
+91 -21
View File
@@ -153,7 +153,7 @@ target and the API is never reachable from outside the compose network.
| Service | What it is |
| --- | --- |
| `web` | nginx serving the React console. The only published port. |
| `web` | nginx serving the console — .NET, running in the browser as WebAssembly. The only published port. |
| `api` | REST, auth, the vault, the connectors, the tool-calling loop, and the websocket. |
| `postgres` | Everything except the vault master key. |
| `redis` | Socket fan-out, nonces and rate-limit counters. |
@@ -217,7 +217,7 @@ docker compose up -d
```
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
secrets, applies 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.
An install screen takes it from there. It welcomes you, creates the first administrator, then asks
@@ -293,6 +293,24 @@ adopted into the volume rather than replaced, your `WEB_ORIGIN` becomes the stor
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 own Postgres.** `DATABASE_URL` in `.env` replaces the stack's: `postgresql://user:password@host:5432/database`,
then any of these parameters, read as earlier releases read them: `schema`; `sslmode` (`disable`,
`allow`, `prefer`, `require`, `verify-ca`, `verify-full`); `sslaccept` (`strict` or
`accept_invalid_certs`); `sslcert` or `sslrootcert`, the certificate to trust, and `sslidentity` with
`sslpassword`, a client certificate in PKCS#12 — each an absolute path inside the container; `host`,
a Unix socket's directory; `connection_limit`, `connect_timeout`, `max_connection_lifetime`,
`max_idle_connection_lifetime`; `application_name`, `channel_binding`, `options`. `pgbouncer`,
`pool_timeout`, `statement_cache_size` and `socket_timeout` are accepted and change nothing. Any other
parameter stops the api with `DATABASE_URL: unsupported parameter "<name>"` rather than being
ignored.
`sslmode=require` encrypts without checking the server's certificate, as it always has here;
`sslaccept=strict` checks it and the host name. `verify-ca` and `verify-full` check it too — before
api 1.0.0 they were read as `prefer` — so a URL that carried one may now refuse a certificate the
container cannot verify: mount the CA and name it with `sslrootcert`. The migration holds a session
lock, so it needs a direct connection to Postgres, not PgBouncer in transaction mode; `pgbouncer=true`
in the URL is warned about.
## Your reverse proxy has three requirements
All three are the kind that produce confusing symptoms rather than clean errors.
@@ -323,6 +341,15 @@ All three are the kind that produce confusing symptoms rather than clean errors.
until somebody attaches a big screenshot or PDF. No other request body Jarvis accepts is larger
than 2 MB, so the higher ceiling can be given to that path alone.
**If your proxy adds a Content-Security-Policy**, it is enforced beside the one the web container
sends on the console's page — a browser applies both. The console is .NET running in WebAssembly, so
your policy's `script-src` must allow `'wasm-unsafe-eval'`, and the page's two inline scripts as well:
the theme, and the import map without which nothing loads. Their hashes change with every build,
which in practice means `'unsafe-inline'` in your policy. Simpler: send no policy of your own on the
console's pages, and let the container's — which allows exactly those two scripts and nothing else
inline — do the work. A policy that misses either leaves a blank page, with the refusal in the
browser's console.
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
@@ -347,19 +374,56 @@ follow their own upstream tags, `postgres:16-alpine` and `redis:7-alpine`. Schem
themselves when the api starts, and the api and the web are versioned independently — their numbers
are not meant to match, because usually only one side changed.
**A schema change is one-way.** Jarvis has no migration history: each boot syncs the database to
the schema its image carries, adding what a release added. A release that would DROP a table or a
column is refused instead: the schema is not touched, the api logs which columns or tables it would
drop and stays down, restarting into the same refusal. If those drops are intended, set
`SCHEMA_ACCEPT_DATA_LOSS=true` in `.env`, `docker compose up -d` once, then remove the line. Pulling
an older api image does not undo a change already applied — unlike the image itself, the schema
stays where the newer build left it. Restoring a dump is the only way back, which is what makes the
one below a prerequisite rather than a precaution.
**A schema change is one-way.** Each api image carries its database migrations, and the database
records which ones it has (the `__EFMigrationsHistory` table). The api applies the ones it has not
seen before it serves, under a Postgres advisory lock, so two containers never migrate at once. A
migration that would DROP a table or a column is refused instead: the schema is not touched, the api
logs which tables or columns it would drop and stays down, restarting into the same refusal. If those
drops are intended, set `SCHEMA_ACCEPT_DATA_LOSS=true` in `.env`, `docker compose up -d` once, then
remove the line. Pulling an older api image does not take a migration back — the schema stays where
the newer build left it. Restoring a dump is the only way back, which is what makes the one below a
prerequisite rather than a precaution.
```sh
docker compose exec -T postgres pg_dump -U jarvis jarvis | gzip > jarvis-$(date +%F).sql.gz
```
### Upgrading from the TypeScript releases (api 0.x)
api 1.0.0 and web 1.0.0 are Jarvis rewritten on .NET. The images keep their names and their channels,
so the upgrade is the usual one, with four things to know first.
- **Be on api 0.151.0 first** — the last 0.x release — and let it start once. A database it has not
migrated is refused by 1.0.0 without a change: the api logs `[migrate] This database was built by
the TypeScript API, but not by its last release`, the release to install and what is missing, and
restarts into the same refusal. Pin `JARVIS_IMAGE_API=git.luxit.be/luxit/jarvis-api:0.151.0` in
`.env`, `docker compose up -d`, wait until it serves, remove the pin and `docker compose up -d`
again.
- **Move the api and the web together** if you pin: `JARVIS_IMAGE_API` and `JARVIS_IMAGE_WEB` both.
A 1.x api behind a 0.x console, or the reverse, works without live updates.
- **Take the new `docker-compose.yml`.** Two lines changed: `init` runs `jarvis init-secrets` and the
api's healthcheck `jarvis healthcheck`. Your current file keeps working — the 1.x api image still
answers the three `node` commands the 0.x files give it, and refuses any other — until a later
release removes them, with `/usr/local/bin/migrate.sh` and `/usr/local/bin/entrypoint.sh`; its
CHANGELOG entry will say so.
- **Nothing to add to `.env`.** Every variable keeps its name and its meaning, `NODE_ENV` included.
The first 1.x start records where the schema stands (it creates `__EFMigrationsHistory` and changes
nothing else) and takes seconds. Open tabs lose live updates until they are reloaded; agents
reconnect by themselves.
**On Kubernetes**, pin both digests in one change, with the new manifests: `01-secret-job.yaml` runs
`jarvis mint-k8s-secret` (and no longer sets `NODE_EXTRA_CA_CERTS`), `03-migrate-job.yaml` runs
`jarvis migrate`, and `04-api.yaml` recreates the api for this release — every old pod stops before
a new one starts, a gap of about thirty seconds, once — and rolls it again from the next. Overlays of
your own that call `/usr/local/bin/migrate.sh` keep working until that release.
**The way back**, until the first release after 1.0.0 whose entry says it changes the schema: pin
both previous images and either set `SCHEMA_ACCEPT_DATA_LOSS=true` for one start — the 0.x api then
drops `__EFMigrationsHistory` and nothing else; its log names what it drops, and anything else in
that list means: stop, and restore — or set `JARVIS_SKIP_MIGRATIONS=true` until you upgrade again.
After that release, the dump is the way back.
### Channels
| Channel | What it means |
@@ -512,8 +576,10 @@ These are deliberate and documented rather than surprises waiting to be found.
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.
- **SSH host keys are trusted on first use.** A connection's first successful SSH session records
the key that answered; every later one must present the same key or is refused before a credential
is sent, naming both fingerprints. Saving the connection again is how you trust a reinstalled
server's new key. The first session itself is unverified, as with OpenSSH's `known_hosts`.
- **This compose file runs one api, and that is the shape it is written for.** It gives you one of
everything and generates the secrets onto a shared volume, which is right here and is exactly what
must not be done per replica anywhere else — each copy would mint its own vault key, and a
@@ -521,8 +587,8 @@ These are deliberate and documented rather than surprises waiting to be found.
The images themselves no longer require a single replica. The web container's upstream and DNS
resolver are configurable, there are separate liveness and readiness endpoints, both containers run
unprivileged, and the schema is a separate entrypoint meant to run once rather than something every
container does at boot. Everything that used to do real damage across replicas is fixed: a starting
unprivileged, and the schema is a separate command (`jarvis migrate`) meant to run once rather than
something every container does at boot. Everything that used to do real damage across replicas is fixed: a starting
replica's recovery sweep once claimed every run it did not own, so a second instance re-executed —
against your real infrastructure — operations the first was still running. A run now holds its
conversation through a unique index in the database, a sweep claims only a run whose heartbeat has
@@ -562,7 +628,7 @@ These are deliberate and documented rather than surprises waiting to be found.
request. The exception is changing your own password: it revokes every *other* session and keeps
the one you are changing it from, so a token stolen from that session stays valid until it expires
— 15 minutes by default (`JWT_ACCESS_TTL`). An admin-forced reset drops every session.
- **Neither container runs as root**: the api runs as `node` (uid 1000) and the web's nginx as
- **Neither container runs as root**: the api runs as `jarvis` (uid 1000) and the web's nginx as
`nginx`, listening on 8080. Secret files mounted into them must be readable by that user.
- **`/api/health` answers 200 with `status: "degraded"`** when the database is unreachable, so the
container healthcheck alone is not a liveness signal for the database.
@@ -630,9 +696,12 @@ wrong on the screen that asks for it.
| 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. |
| An agent installer answers 503 | No agent build is published — the overlay is off. That is a supported state. |
| The remote terminal works and remote desktop never connects | The proxy forwards `/api/agents/ws` but not the paths under it. The media socket is `/api/agents/ws/desktop` and needs the upgrade too. |
| api restarts in a loop, logs `This database was built by the TypeScript API, but not by its last release` | The upgrade skipped a release. Install the one it names, let it start, then this one — see "Upgrading from the TypeScript releases". |
| api restarts in a loop, logs `DATABASE_URL: unsupported parameter` | A parameter the api does not read. The list it does read is under "Your own Postgres". |
| The console stays blank behind your proxy; the browser's console names a Content Security Policy | Your proxy's policy refuses the console's scripts — see "Your reverse proxy has three requirements". |
| A remote desktop session opens and ends immediately | Almost always the notice. It ends by design if the banner cannot be kept on top, if the person refuses, or if nobody answers the request within the minute. The end reason on the session says which. |
The api takes about a minute on first boot, syncing the schema before it listens. `docker compose ps`
The api takes about a minute on first boot, applying 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
@@ -655,15 +724,16 @@ docker compose exec -T postgres psql -U jarvis -d jarvis \
They then reset the locked-out password under **Admin Center → 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:
api image hashes one with the same code that will check it — the password on standard input, never
on the command line:
```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'
printf '%s\n' 'the-new-password-at-least-12-characters' | docker compose exec -T api jarvis hash-password
```
At a terminal, `docker compose exec api jarvis hash-password` asks for it without echoing it. Either
way it prints the `$argon2id$…` string and nothing else.
Then write it, and make sure the account is active and privileged:
```sh