The self-hosting stack

This commit is contained in:
2026-08-24 15:36:01 +02:00
commit 96285967b2
10 changed files with 1907 additions and 0 deletions
+759
View File
@@ -0,0 +1,759 @@
<p align="center">
<img src="jarvis.svg" width="76" alt="Jarvis">
</p>
<h1 align="center">Jarvis</h1>
<p align="center"><strong>AI-assisted infrastructure administration for MSPs.</strong></p>
An operator opens a conversation and asks for the work in words. Jarvis does it: a command over SSH
or through an enrolled agent, a Proxmox guest powered on, a Microsoft 365 account blocked, a MikroTik
firewall read back — on the machines of one client organization, with credentials it decrypts from
the vault and shows nobody. Every call is judged before it runs, and the ones that cannot be undone
stop and ask a human in the thread.
The difference from an assistant that writes commands for you to paste is that these run.
This repository runs Jarvis from published container images — no source, no build, no account with
the project.
**And no licence key.** A fresh install comes up on the **community edition**: one organization,
three users, five agents, ten assets, no expiry, nothing to renew. Everything Jarvis does works
inside those numbers — every connector, the agent, the remote terminal, the assistant itself. A
licence from your provider raises the limits; it does not switch anything on.
When you outgrow it, **ask for a licence from inside Jarvis** — Settings → Licence — and the key
arrives on your instance by itself. A community instance reports its version and its counts so that
we can see which builds are in the field; [what it sends](#licence-keys-and-what-your-instance-reports)
is listed in full below.
- [What it does](#what-it-does)
- [What decides whether a tool call runs](#what-decides-whether-a-tool-call-runs)
- [What it looks like](#what-it-looks-like)
- [How it works](#how-it-works)
- [What you need](#what-you-need)
- [Install](#install)
- [Your reverse proxy has two requirements](#your-reverse-proxy-has-two-requirements)
- [Upgrading](#upgrading)
- [The agent](#the-agent)
- [Things worth knowing before you trust it with production](#things-worth-knowing-before-you-trust-it-with-production)
- [When it does not come up](#when-it-does-not-come-up)
- [Backups, and restoring one](#backups-and-restoring-one)
- [Removing it](#removing-it)
- [The community edition](#the-community-edition)
- [Licence keys, and what your instance reports](#licence-keys-and-what-your-instance-reports)
- [Asking for a licence](#asking-for-a-licence)
- [Getting a licence, and getting help](#getting-a-licence-and-getting-help)
## What it does
**Five connectors reach a managed system.** Each one is a set of tools the assistant may call, and
what it may do with them is decided per call — see the next section.
| Connector | Reaches |
| --- | --- |
| **SSH** | Anything with a shell — Linux, Windows, and CLI-driven network gear. An appliance that serves no SFTP is offered the command tool alone, rather than four that would fail at the handshake. |
| **Jarvis agent** | A machine running the enrolled agent, which dials out — so it works behind NAT, on a dynamic address, with no inbound rule and no SSH exposed. Commands, files, services, processes, system facts. |
| **Proxmox VE** | The cluster API. Read and power only — no create, clone, snapshot, backup or migrate. |
| **MikroTik RouterOS** | The native binary API, or the RouterOS 7 REST API. One identical tool surface either way; the connection decides the transport. |
| **Microsoft 365** | Graph, app-only, on any Microsoft cloud, authenticating with a client secret or a certificate. Named tools for identity, licences, groups, admin roles, Exchange Online, Intune and the audit logs, plus one that reaches every remaining Graph endpoint. |
**An asset is the managed thing; a connection is a way of reaching it.** A Linux host commonly
answers on OpenSSH *and* through an enrolled agent; a Proxmox node has a cluster API, a shell and an
agent. Each route carries its own address, its own credentials and its own health — so a vendor API
that stops answering no longer makes a device unmanageable while its console is up.
**Documents.** A conversation accumulates what the assistant writes: reports and runbooks in
Markdown, diagrams in Mermaid, tabular data as a workbook. What it writes is always a *source*, and
Jarvis renders the file on download — PDF and Word from prose, Excel and CSV from a workbook, HTML
from either. Keeping the source is what leaves a document revisable instead of a dead binary.
**And what an MSP has to administer about itself**: organizations and members under a role ceiling,
invitations, an encrypted vault with its own folder tree and its own grants, an audit trail,
passkeys and TOTP, a notification centre, and a console in English, French or German — a property of
the account, not of the browser.
## What decides whether a tool call runs
Three things, resolved on every single call. The most restrictive wins.
**Risk is a property of the operation.** A tool declares a baseline and may escalate per invocation:
a shell command is mutating in general and destructive for `rm -rf`, `mkfs`, `shutdown`,
`iptables -F` and about two dozen other patterns. Escalation is one-way — a per-call assessment can
raise the risk, never lower it.
**Autonomy is a property of the conversation**, chosen by the operator:
| | Safe | Mutating | Destructive |
| --- | --- | --- | --- |
| **Read-only** | run | refuse | refuse |
| **Ask before every change** | run | ask | ask |
| **Ask before destructive changes** (default) | run | run | ask |
| **Full access** | run | run | run |
"Ask" raises an approval request in the conversation, and the run parks until a human answers. The
level is re-read on every call, so lowering it takes effect on the very next tool call of a run
already in flight. **Full access removes the last in-chat gate for every participant** — destructive
operations then run immediately, with no prompt and no second pair of eyes. Make it a deliberate
choice.
**Permission is a property of the person.** A grant says which slice of the asset tree somebody may
operate, through which connectors, up to which risk, and whether the tools whose operation the
assistant *composes* — a shell command, an arbitrary Graph request, the contents of a file — are
admitted at all. Grants resolve by walking outward from the asset: the most specific level that says
anything decides entirely, and if nothing has spoken by the root the answer is no. Absent means
nothing, so a forgotten grant fails closed rather than open.
> The conversation's autonomy is a **floor the operator imposes on themselves**; the grant is the
> **ceiling imposed on them**. What runs is whichever binds.
A conversation also carries a **scope** — any mix of assets and folders, a folder granting its whole
subtree. Scope is checked *before* the asset is resolved, so a machine out of scope never has its
vault secrets decrypted.
## What it looks like
<p align="center">
<img src="docs/img/console.png" alt="The Jarvis console, on a freshly installed instance" width="900">
</p>
The console on a fresh install. The footer names the two builds you are running, which is the first
thing to quote when something is wrong.
<p align="center">
<img src="docs/img/asset.png" alt="One asset, its two routes, its agent and its inventory" width="900">
</p>
One asset, and the distinction the model rests on: **two routes to the same machine**, each with its
own address, its own credentials and its own health. One is preferred and untested; the other is
disabled without being deleted, so its settings and its history survive and no tool may use it.
## How it works
Four containers. **Only `web` publishes a port**: its nginx serves the console and reverse-proxies
`/api` and the websocket to `api` over the internal network, so your TLS terminator has exactly one
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. |
| `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. |
The assistant streams a turn, executes the tool calls the model asked for **strictly after the
stream is fully drained**, then feeds the results back — up to fifty rounds per user turn. That
ordering is what makes retrying a broken stream safe: at the moment a stream fails, no tool of that
round has run, so replaying it re-generates intent and never re-runs an operation.
**A run that was in flight when the api stopped is picked up when it comes back.** A shutdown aborts
each loop and lets it write out what it had streamed with a note saying why the transcript ends
there; the next process finishes that message, closes any tool call whose outcome is unknown saying
in as many words that it is unknown, and resumes the run — instructed to read the current state
before repeating anything that writes. This is why the api asks for a stop grace period, and part of
why it must run as a single replica.
**The agent dials out.** Nothing inbound is opened on a managed machine. It holds a websocket to
your instance, signs each session with a key whose private half never leaves it, and reports its
inventory on every heartbeat.
## What you need
- Docker with Compose v2, on **x86-64 Linux**. The api and web images are published for
`linux/amd64` only, so an arm64 host — a Pi, an Ampere, a Graviton — fails at `docker compose pull`
with no matching manifest. The machines you *administer* have no such limit: the agent ships arm64
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 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.
## Install
```sh
curl -O https://git.luxit.be/Luxit/jarvis-selfhost/raw/branch/main/docker-compose.yml
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
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 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.
Everything it asks is a setting you can change afterwards from an ordinary screen. Nothing it asks
needs a container restart to change.
### Install it when you deploy it
**Until somebody creates the first account, anyone who reaches this instance can.** There is no
deadline on that and no token to find: the first account created owns the instance, and the claim is
open for as long as nobody has taken it.
So the gap that matters is between `docker compose up -d` and you opening a browser. Keep it short.
If you are pointing a public hostname at this, point it after you have installed, or install through
the host's own address first — the setting is editable afterwards.
If you would rather it were not open at all until you say so, do not publish the address yet: the
install screen is served by the same web container as everything else, and a Jarvis nobody can reach
is a Jarvis nobody can claim.
**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 one the claim 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
Both are the kind that produce confusing symptoms rather than clean errors.
- **Forward the WebSocket upgrade.** Two separate sockets ride `JARVIS_PORT`: the chat, on
`/socket.io/`, and enrolled agents, on `/api/agents/ws`. Neither falls back to plain HTTP. Without
the upgrade the chat does not lose streaming — it never connects at all, and since the prompt
itself travels over that socket, nothing sends. Presence and in-chat approvals go with it, and no
agent can connect.
- **Give it a long read timeout** — 300s or so. A reasoning model can go 90+ seconds without emitting
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. 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
```sh
docker compose pull && docker compose up -d
```
That is the whole upgrade: the api, the web, and — with the agent overlay on — the agent release all
track the **`stable`** channel by default. Postgres and Redis are not on a Jarvis channel; they
follow their own upstream tags, `postgres:16-alpine` and `redis:7-alpine`. Schema changes apply
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 force-syncs the database
to the schema its image carries, adding what a release added and dropping what it removed, without
prompting. Pulling an older api image does not undo that — 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.
```sh
docker compose exec -T postgres pg_dump -U jarvis jarvis | gzip > jarvis-$(date +%F).sql.gz
```
### Channels
| Channel | What it means |
| -------- | ---------------------------------------------------------------------------------- |
| `stable` | Promoted after running on the publisher's own instance. **The default, and what you want.** |
| `dev` | Every build, as soon as it is published. Nothing has tried it yet. |
| `latest` | A second name for `stable`, kept so nothing that already used it has to change. |
A channel is a **pointer** and a version number is a **fact**. `0.78.2` means one specific set of
bytes for ever; `stable` means whichever set we currently stand behind, and it moves. A build only
reaches `stable` by being promoted — and promotion copies the manifest of an image that has already
been published and already run. It never rebuilds, so the bytes you receive are the same bytes that
were tested, not a fresh build of the same source.
**Channels do not mean the app forgets which build it is.** The version is stamped into the image
when it is built, so the footer in the app, `/version.json` and the agent manifest keep reporting the
real number whichever name you pulled it under. That is what lets you tell somebody which build you
are on when something goes wrong. Set `JARVIS_CHANNEL` in `.env` and the footer names your channel
beside those numbers; leave it empty if you pin, because then you follow no channel.
Once you are in production, consider pinning: set `JARVIS_IMAGE_API`, `JARVIS_IMAGE_WEB` and
`JARVIS_IMAGE_AGENT` in `.env` to explicit version tags. It makes an upgrade a decision rather than a
side effect of pulling. Pin the version you are *already running* — the footer shows it — rather than
one copied from a document, and remember that moving the api pin backwards runs an old build against
a schema that has already moved forward.
**Pin the agent one first if you pin only one.** The api and the web change what your own server
runs; the agent changes what runs on every machine you administer. Pinning the image decides which
build your instance publishes; a separate **update policy** decides when a machine takes it —
*As soon as available*, *On a schedule*, *Manually only*, *Let the agent decide* (the default, which
means on its next service start, and on a server that can be months) or *Never*. The instance-wide
answer is on **Settings → Platform → General**; an organization overrides it under **Settings →
Organization → Agent updates**, and a single machine overrides that. An enrolled agent also refuses
any version that is not strictly newer, so moving that pin back stops a rollout rather than reversing
it on machines that already took the update.
## The agent
**The Jarvis agent is an optional overlay, off by default.** Enrolling a machine downloads a compiled
binary that the api serves from `AGENT_RELEASE_DIR`, and a compose-only deployment has no way to
produce one. `docker-compose.agent.yml` supplies it as a pullable image instead. Leaving it off is a
supported state rather than a broken one: everything else works, the manifest and download endpoints
answer 503 saying no build is published, and the SSH, Proxmox, Microsoft 365 and MikroTik connectors
all reach machines without it.
Turn it on by adding one line to `.env`, so that every later `docker compose` command picks up both
files with no extra flags:
```sh
COMPOSE_FILE=docker-compose.yml:docker-compose.agent.yml
```
then `docker compose pull && docker compose up -d`. A one-shot `agent-releases` service copies the
release into a volume the api reads, and exits. From there, the **Agents** page in the sidebar issues
the install command: click **New install command**, pick Linux, macOS or Windows, and copy the one
line. (Settings → Organization → Agent updates is a different screen — it schedules how
already-enrolled agents take new builds.)
Four things worth knowing about it:
- **Until that publisher exits cleanly, the api does not start.** That is deliberate — a release that
failed to arrive should stop the deploy loudly rather than leave you handing 404s to every installer
you run this week. The cost is that an unreachable registry blocks the whole stack. The comment in
the file names the three lines to drop if you would rather it degraded quietly.
- **Upgrading it does not restart anything.** The api re-checks the file on disk on every download
request and re-hashes it whenever its size or timestamp has changed, so a new release in the volume
is served immediately and the published checksum always describes the bytes actually being served.
- **The agent version is its own number.** It moves independently of the api and the web, and a Jarvis
release usually does not touch it at all. The app footer does not carry it either — each enrolled
machine reports the build it runs, on the Agents page. Pin it with `JARVIS_IMAGE_AGENT`.
- **It carries an interactive shell.** Beyond what the assistant can do with it, a person gets a real
terminal on an enrolled machine from the browser — Linux, macOS and Windows alike. Sessions are
recorded by default, encrypted under `VAULT_MASTER_KEY` and deleted on a retention policy you set
under **Settings → Organization → Terminal sessions**. Turning recording off stops the transcript,
never the audit entry.
**What an enrolled agent can do.** Inventory the machine, run commands, read, write and fetch files,
list and control services, list processes, update itself, and carry that shell. It runs as root on
Linux and macOS and as LocalSystem on Windows, deliberately — its purpose is to administer the
machine.
What the **assistant** does with that reach is bounded by the autonomy policy and the approval gates
above. **The interactive shell is not.** There is no command to inspect before a shell opens, so the
risk ceiling has nothing to weigh; it is gated instead by a permission and a per-machine switch on
the grant. Decide who holds those before enrolling anything you care about.
## Things worth knowing before you trust it with production
These are deliberate and documented rather than surprises waiting to be found.
- **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
websocket of every enrolled agent live in one process's memory. The Redis in this stack does not
lift that limit: it fans outgoing events out to other replicas, but an incoming one is only ever
handled by the replica holding that connection. So a cancel or an approval answered on the wrong
replica is silently dropped, and an agent tool call can land on a replica that does not hold the
target machine. Worse, a starting replica's recovery sweep claims every run it does not own — so a
second instance re-executes, against your real infrastructure, operations the first is still
running.
- **There is no rate limiting on sign-in.** No throttler, no account lockout, and authentication
events are not audited. Credential stuffing is bounded only by the reverse proxy you put in front,
which this repository does not ship. If your proxy can rate-limit one route, make it that one.
- **An access token dies with its session, with one exception.** Every access token carries the id of
the session that issued it and every request re-checks that the session is live, so revoking a
session, signing other devices out or deactivating an account cuts that token off on its next
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.
- **The api container runs as root**, and so does nginx's master process in the web container,
though its workers drop privileges. Neither image declares a `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.
- **Outbound mail is Microsoft Graph only.** There is no SMTP and no environment variable for any of
it: an app registration with `Mail.Send` and a shared mailbox, set up on the **Mail** tab of
**Settings → Platform → General**. Without it, invitations still work — the link comes back to the
admin who created it instead of being emailed. Nothing else is emailed: there is no password reset
and no address verification.
- The assistant executes real operations on real infrastructure. What stops for a human is the
conversation's autonomy level, and at *Full access* nothing does. See
[What decides whether a tool call runs](#what-decides-whether-a-tool-call-runs).
## When it does not come up
```sh
docker compose ps # who is running, and who is restarting
docker compose logs -f api # the api says why it refused to start
```
**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 |
| --- | --- |
| Every page redirects to an install screen | This instance has not been installed yet. That is the wizard, not an error. |
| 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. |
| An agent installer answers 503 | No agent build is published — the overlay is off. That is a supported state. |
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.** The claim is open only on an instance with
no users and no completed setup, so on a working deployment it refuses — 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. **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 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:
```sh
docker compose stop api web
docker compose exec -T postgres psql -U jarvis -d postgres \
-c 'DROP DATABASE IF EXISTS jarvis;' -c 'CREATE DATABASE jarvis;'
gunzip -c jarvis.sql.gz | docker compose exec -T postgres psql -U jarvis -d jarvis
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
docker compose down # stops everything, keeps the data
docker compose down -v # also deletes the volumes — every conversation, asset and credential
```
`down -v` is not recoverable from anything but a dump you already took.
## The community edition
**Jarvis runs without a licence key, for as long as you like.** An instance with no key runs the
community edition:
| | Community edition |
| --- | --- |
| Organizations | 1 |
| Users | 3 |
| Agents | 5 |
| Assets | 10 |
| Term | Perpetual. No expiry, nothing to renew, no key to lose |
| Reporting | Version, counts and address — the same fields a licensed instance sends. Switchable off |
| Features | All of them — every connector, the agent, the remote terminal, the assistant |
Nothing is disabled, watermarked or time-limited. What a licence buys is a **higher ceiling**, not
the product: the same images, the same code, larger numbers. This is deliberate and it is the reason
the images are public — you should be able to install Jarvis, connect it to something real, and find
out whether it earns a place in your work before there is anybody to talk to.
When a limit is full, Jarvis refuses **the next** thing of that kind and says which limit it was.
Nothing already there is touched: no organization is closed, no user locked out, no agent
disconnected, and the assistant keeps working on everything you have. So an instance that grows past
the community numbers — through an imported estate, say — keeps running in full; it simply cannot
add to that meter until a key raises it.
### What changed on 21 August 2026, and why we are saying it loudly
**Until this release, a community instance contacted nobody at all**, and this file said so in those
words. That is no longer true: an instance without a licence now reports the same fields a licensed
one does — counts, version, contract checksum and its public address — to `checkin.luxit.be`, which
is compiled into the build because an instance with no licence has no address in one to read.
We changed it for two reasons, and neither of them is nicer for you than for us. We could not tell
whether the community edition was reaching anybody, or which builds were running when we needed to
warn people about one. And the licence request below needs the same channel — a form that could not
reach us would just be an email with extra steps.
**Reporting is part of running Jarvis, and there is no switch.** Every instance checks in —
community or licensed, the same fields either way — because it is the only way we see which builds
are in the field when one of them turns out to need a warning, and because the licence request form
below travels the same channel. What leaves is listed field by field in the next section, and
**Settings → Licence** shows you the exact address and the exact contents from the running instance
rather than from this document.
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.
To go further — more organizations for a real client base, more seats, more machines — ask for a
licence from **Settings → Licence** inside your own instance. That form is signed by your
installation, so an approved licence lands on it by itself, with nothing to paste.
## Licence keys, and what your instance reports
**A licence key raises the limits.** Ask for one (see
[below](#getting-a-licence-and-getting-help)) and put it in `.env` as `JARVIS_LICENSE_KEY`, or paste
it under **Settings → Licence**. The key is a signed token your instance verifies **offline** — it
carries your term and your limits, and it needs no network to be checked.
**A licence that stops applying drops back to the community edition**, and never to nothing. Expired,
withdrawn, or a key that will not verify: the instance keeps every allowance in the table above and
keeps running everything already set up. There is no state in which Jarvis stops being usable because
of a billing question, and there never will be.
**Every instance reports**, licensed or not. A licensed one reports to the address written into its
key; a community one to `checkin.luxit.be`, compiled into the build. The interval is your provider's
to set — every ten minutes on the current arrangement, so that a renewal or a revocation reaches you
promptly rather than tomorrow.
This is everything either of them sends, in full:
| Field | What it is |
| ------------------------ | ------------------------------------------------- |
| Product | The literal string `jarvis` |
| Licence id | Which licence this is. **Absent on a community instance** — there is none to quote |
| Instance id + public key | A key pair your instance generated, identifying it |
| Version | Which Jarvis build you are running |
| Contract version | Which set of limits this build understands — a checksum, not a document |
| Counts | How many organizations, users, agents and assets |
| Public URL | Your instance's address — always sent, see below |
| Timestamps | When the process started, and when it reported |
| Signature + nonce | Proof the message came from this instance, and a one-time value so an old one cannot be replayed. Carries nothing about you. |
**Counts, not contents.** No names, no email addresses, no conversation text, no asset inventory, no
credentials, nothing about what you administer.
**The public URL is the one field that names your network rather than measuring something, and it is
sent.** It used to be a switch on the licence screen; it is not any more, because an installation the
publisher can identify only by a fingerprint is one where "which of these is the customer calling
about" has no answer. The screen shows you the exact address that leaves, under **Settings →
Licence**.
**There is no way to switch this off**, on a community instance or a licensed one. It is the same
report either way, and the licence screen states it rather than offering a control that would make
the arrangement a matter of opinion. If that does not suit your deployment, take it up with us before
you deploy anything.
The reply can carry a renewed key, which your instance adopts on its own — so a renewal reaches you
without anybody re-pasting anything.
**Your platform does not stop working because of a licence.** Expiry gives you a grace period — as
long as your key says, which on the current plans is 30 days, and none at all on a trial. After it,
the instance returns to the community edition's allowances: everything already set up keeps running,
the assistant included, and only creating something beyond those numbers is refused. There is no
state in which Jarvis disables, deletes or locks you out of something you are already using. If the
check-in cannot reach the server, nothing changes at all: the key you hold is what governs, and it is
checked without a network.
## Asking for a licence
**From inside your own instance: Settings → Licence → Ask for a licence.** Fill in who you are — a
company name and somebody to answer are the only required fields — roughly what you need, and send.
What makes this worth doing from in there rather than by email: the form is **signed by your
installation**, so when your provider approves it, the licence is issued for that exact deployment
and **lands on it by itself at the next report**. There is no key to copy, nothing to paste, and no
way to paste it into the wrong instance. You get the key by email as well, for your records and in
case you rebuild the host before the answer arrives.
Sent with the form: your instance's id, its version, what it currently counts, and its address. That
is the same information it already reports, so the form adds only what you typed.
While it is pending, the licence screen says so. If your provider declines, their reason appears on
that screen and in your inbox — and you can ask again from the same button whenever something has
changed. You can also withdraw a request you no longer want.
If you have turned reporting off, the form cannot reach anybody and says so. Write to the address
below instead, quoting the instance id from the licence screen.
## Getting a licence, and getting help
**Antoine Cavelier — <antoine@luxit.be>.** Licence keys, pricing, and anything wrong with the
product.
When something is broken, the two facts worth putting in the first message are the build you are on
and what the api said:
```sh
curl -s https://your-jarvis.example.com/version.json # the web and api versions
docker compose logs --tail=100 api
```
`/version.json` is public on purpose, so you can quote it without signing in.
## Licence
The images are provided as-is with no warranty, no support and no commitment to future availability.
The source is not public and no rights to it are granted. Ask before deploying this commercially or
for third parties.