The self-hosting stack

This commit is contained in:
2026-08-24 16:05:14 +02:00
commit 8d28604625
10 changed files with 1920 additions and 0 deletions
+237
View File
@@ -0,0 +1,237 @@
# Jarvis — optional configuration.
#
# THERE IS NOTHING YOU HAVE TO FILL IN. You do not need this file at all:
#
# docker compose up -d
#
# then open the address in a browser and the install screen asks the questions. It creates the first
# administrator, checks the deployment, and stores the address, the model and everything else as
# settings you can change later from a screen.
#
# THIS FILE USED TO BE MANDATORY, and that is the change. It asked for six values before the
# container would start, four of them secrets to be generated with `openssl` — and it failed in the
# worst available way, because an invalid one made the API exit 1 and the restart policy turned a
# typo into a crash loop, with the reason on line 40 of a log nobody had a reason to open.
#
# What is left below is for deployments that prefer to state things in writing: pinning image
# versions, running the agent overlay, choosing a host port. Every value here is optional, and 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.
#
# cp .env.example .env # only if you want any of this
# ---------------------------------------------------------------------------
# Where your instance lives
# ---------------------------------------------------------------------------
# Host port the web container publishes. Put your TLS terminator in front of it.
#
# The one value on this page that genuinely cannot move to a screen: it is a Docker fact, decided
# before anything in the application is running.
JARVIS_PORT=8080
# The address a browser reaches Jarvis on. ASKED BY THE INSTALL SCREEN, which pre-fills it with the
# address you are already reading it at — so setting it here is only useful if you want it stated in
# writing, or if you are upgrading a stack that already had it.
#
# It decides CORS, what enrolled machines dial, the base of every invitation link, and the WebAuthn
# relying party. Changing it later orphans every passkey already enrolled, so the screen warns you.
#WEB_ORIGIN=https://jarvis.example.com
# PUBLIC_URL IS GONE. It was a second address for the same thing, kept separate for a split-hostname
# deployment nobody ran; the install screen asks once and every consumer reads that one value. An
# instance that still sets it is not broken — it is simply ignored.
# How many proxies rewrite X-Forwarded-For before a request reaches the API.
#
# ASKED BY THE INSTALL SCREEN, which is the only place this can honestly be answered: it shows you
# the header chain your own request actually carried and the address the API resolved from it, and
# you confirm what you see. Nobody knows this number in advance.
#
# One is the web container's own nginx, which is always there; two if your own reverse proxy fronts
# it, which is the usual case. Raising it past the real number is the dangerous direction, because
# the API then trusts that many hops of a header the client can forge — and a caller can choose the
# address that lands in your audit log and in the session list.
#TRUST_PROXY_HOPS=2
# ---------------------------------------------------------------------------
# Secrets — there is nothing to fill in here any more
# ---------------------------------------------------------------------------
#
# This section used to hold four values and four `openssl rand` invocations. They are generated for
# you now, on your first `docker compose up`, by the `init` service — into the `jarvis_secrets` volume,
# where the database password, the two session-signing secrets and the vault master key live.
#
# WHY THEY LEFT THIS FILE. A vault key is not a preference. Asking for one put the single most
# consequential value in the deployment — everything in the vault is encrypted under it — in front of
# whoever was least equipped to look after it, at the moment they were least interested in it, in a
# file they were trying to get through. Nobody ever chose a better key than `openssl rand` would have.
#
# WHAT YOU STILL HAVE TO DO, AND IT IS THE IMPORTANT ONE:
#
# BACK UP THE `jarvis_secrets` VOLUME, SEPARATELY FROM THE DATABASE.
#
# The vault key is in there and NOWHERE ELSE. 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 every reveal. It is a separate volume from `postgres_data` precisely so that the two
# can be, and must be, backed up to different places: a dump that travelled with the key that opens
# it is a dump that opens itself.
#
# The setup screen shows you the key once, on your first visit, and will not let you finish until you
# have put it somewhere. That is the moment to do this.
#
# UPGRADING FROM A .env THAT ALREADY HAS THESE? Leave them exactly where they are. The generator
# ADOPTS an existing value rather than replacing it, and never overwrites a secret it has already
# written — so your instance keeps its own keys and nothing about your vault changes. You can delete
# them from this file once the stack has come up once, or leave them; they are read only when the
# corresponding file does not exist yet.
#
# Pointing at secrets of your own instead? Every one of them also accepts a `_FILE` variable
# (VAULT_MASTER_KEY_FILE, JWT_ACCESS_SECRET_FILE, …), which is the convention the postgres image and
# most others already use — so a `docker secret` can be mounted at those paths with nothing else
# changing.
# ---------------------------------------------------------------------------
# The model — asked by the install screen, with a test button
# ---------------------------------------------------------------------------
#
# All four moved. The install screen asks for the endpoint and the key, lists the models the endpoint
# actually serves, and ASKS THE MODEL FOR A TOKEN before it saves anything — so a key that is
# well-formed and wrong, an account out of credit or a model id the provider does not serve is a
# sentence on screen instead of a fault in somebody's first conversation.
#
# The key is stored encrypted under this instance's vault key, exactly like the outbound mail secret,
# and is never shown again. Change any of it later under Settings -> Platform -> Model.
#
# Setting them here still works and seeds an instance that has nothing stored — for a deployment
# that would rather state its model in a file. They are read once and never again.
#OPENAI_BASE_URL=https://api.openai.com/v1
#OPENAI_API_KEY=
#OPENAI_MODEL=gpt-4o
# minimal | low | medium | high. Higher costs latency and tokens and is worth it for real work.
#OPENAI_THINKING_LEVEL=medium
# ---------------------------------------------------------------------------
# Unattended installation (for fleets and for CI)
# ---------------------------------------------------------------------------
#
# Everything above is optional because the install screen asks for it. This is the other direction:
# somebody rolling out fifty instances from a template has answered those questions once already,
# and making them answer each one in a browser is exactly what this product stopped doing to people.
#
# Set this and the instance installs itself at boot from whatever the variables above provide, marks
# itself installed, and never shows the wizard.
#
#JARVIS_UNATTENDED=on
#
# The first administrator. REQUIRED when JARVIS_UNATTENDED is on and the database is empty; the
# password must be at least 12 characters.
#
# If they are missing or too short the instance says so loudly in its logs and leaves setup
# OUTSTANDING rather than completing — an instance marked installed with no account is one nobody
# can ever get into, and the claim is gated on setup being unfinished, so there would be no
# way back. Falling through to the ordinary install screen is strictly better than that.
#
#JARVIS_ADMIN_EMAIL=
#JARVIS_ADMIN_PASSWORD=
#JARVIS_ADMIN_NAME=Administrator
#
# Worth setting alongside them, since nobody will be there to be asked: WEB_ORIGIN, OPENAI_API_KEY
# and OPENAI_MODEL above, and JARVIS_LICENSE_KEY below. Each is optional even here — an unattended
# instance with no model comes up and reports that it has none, rather than refusing to start.
# ---------------------------------------------------------------------------
# Channels and pinning (optional, recommended in production)
# ---------------------------------------------------------------------------
# Every image tracks the `stable` channel unless you set these, so `docker compose pull && up -d`
# upgrades you to whatever has most recently been promoted. `stable` moves only after a build has
# run on the publisher's own instance; `dev` moves on every build and nothing has tried it yet.
# `latest` is a second name for `stable`, kept so nothing that already used it has to change.
#
# Which channel you are on is yours to state, because nothing in the image knows it — a channel is
# decided after a build and moves afterwards. Set it and the app shows it beside the version
# numbers; leave it empty if you pin below, because then you follow no channel.
#JARVIS_CHANNEL=stable
# Pinning makes an upgrade a decision instead of a side effect of pulling. The app reports its real
# version either way — a channel tag is a second name on the same image, not a build that forgot
# its number.
#
# THERE IS NO EXAMPLE NUMBER HERE ON PURPOSE. Pin the version you are ALREADY RUNNING, which the app
# footer shows as `web … · api …`. Nothing in the publishing path bumps a number written into this
# file, so any number printed here is one that went stale while nobody was looking — and moving the
# api pin BACKWARDS runs an old build against a schema that has already been migrated forward.
#JARVIS_IMAGE_API=git.luxit.be/luxit/jarvis-api:<the api version in your footer>
#JARVIS_IMAGE_WEB=git.luxit.be/luxit/jarvis-web:<the web version in your footer>
# Only read when the agent overlay is enabled, just below. Its version is the AGENT's, and moves
# independently of the two above — a Jarvis release usually does not change the agent at all.
#
# PIN THIS ONE FIRST if you pin only one. The two above change what your own server runs; this one
# changes what runs on every machine you administer. Pinning it decides which build your instance
# publishes; a separate UPDATE POLICY decides when a machine takes it, and its default is "let the
# agent decide" — which in practice means the next time its service starts. Set that policy
# instance-wide under Settings → Platform → General, per organization under Settings → Organization →
# Agent updates. An enrolled agent also refuses any version that is not strictly newer, so moving
# this back stops a rollout rather than reversing it on machines that already took the update.
#
# The app footer does not carry this number. Each enrolled machine reports the build it runs, on the
# Agents page — that is the one to pin.
#JARVIS_IMAGE_AGENT=git.luxit.be/luxit/jarvis-agent-dist:<version>
# ---------------------------------------------------------------------------
# The Jarvis agent (optional)
# ---------------------------------------------------------------------------
# Enrolling a machine downloads a compiled binary, which the api serves from a directory it can
# only read. `docker-compose.agent.yml` supplies that directory as a pullable image, so the release
# arrives the same way the rest of the stack does. Uncomment this and every later `docker compose`
# command picks up both files with no extra flags:
#
#COMPOSE_FILE=docker-compose.yml:docker-compose.agent.yml
#
# Leaving it off is a supported state, not a broken one: everything except the agent works, and the
# installer answers 503 saying no build is published. The SSH, Proxmox, Microsoft 365 and MikroTik
# connectors all reach machines without it.
#
# AGENT_RELEASE_DIR is set by that overlay and should NOT be set here — a value in this file would
# point the api at a path nothing populates, turning the honest 503 into a 404 per platform.
#AGENT_HEARTBEAT_INTERVAL_SEC=30
#RUN_SHUTDOWN_GRACE_SEC=25
# How long a sign-in lasts, in seconds. Both were fixed in the compose file until now, which made
# them the only tuning values on this page you could read about and not change.
#
# JWT_ACCESS_TTL is how long an access token stays valid — and therefore how long a revoked session
# keeps working before it notices. Lower it if that window matters to you; the cost is a refresh
# round trip more often.
#
# JWT_REFRESH_TTL IS AN IDLE TIMEOUT, NOT A SESSION LIFETIME, and the difference will matter to you
# if you are here because of a policy. The refresh token rotates on every use and the new one starts
# its full term from that moment, so nothing anywhere measures how old a sign-in is: somebody who
# keeps a tab open stays signed in indefinitely. What this value really sets is how long a session
# survives being left alone. Fourteen days is the default. To end sessions by age rather than by
# idleness, revoke them — Settings → Security lists them and kills them individually.
#JWT_ACCESS_TTL=900
#JWT_REFRESH_TTL=1209600
# ---------------------------------------------------------------------------
# Licence (optional — without one you get the community edition)
# ---------------------------------------------------------------------------
# Leave this empty and the instance runs the COMMUNITY EDITION: 1 organization, 3 users, 5 agents,
# 10 assets, no expiry and nothing to renew. Every feature works inside
# those numbers. A full limit refuses the NEXT thing of that kind and touches nothing already there.
#
# A key raises the limits — ask antoine@luxit.be for one. It is verified offline, on this machine. A
# licensed instance then reports counts and its own address to your provider; the README lists every
# field it sends. A key that expires or is withdrawn drops back to the allowances above, never to
# nothing.
#
# This value SEEDS the database on first boot and does not govern it afterwards: a renewal arrives
# through the check-in and is stored, so leaving an old key here cannot roll you back. To replace a
# key, use Settings → Licence.
#JARVIS_LICENSE_KEY=
+13
View File
@@ -0,0 +1,13 @@
# These files are read by Docker on a Linux host, whatever the machine that cloned them.
#
# Without this, a clone on Windows checks them out with CRLF, and the carriage return rides into
# `.env` as part of a VALUE — Compose passes it through verbatim, so it ends up inside the database
# password, the JWT secrets and the vault master key. The failure then surfaces as Postgres refusing
# the connection, or as a vault that cannot decrypt what it wrote yesterday, with nothing anywhere
# naming a line ending as the cause.
* text=auto eol=lf
# And the screenshots are bytes, not text. `text=auto` above already detects that correctly, but
# saying so costs one line and removes the question before somebody adds a JPEG.
*.png binary
*.jpg binary
+12
View File
@@ -0,0 +1,12 @@
# Your instance's configuration, which holds every secret it has: the vault master key, the JWT
# secrets, the database password and your AI provider's API key. NEVER commit it. `.env.example` is
# the template and is the only one of these that belongs in a repository.
.env
.env.*
!.env.example
# Database dumps, which the README tells you to take before an upgrade. They contain every
# conversation, asset and audit row on the instance — and the vault's ciphertext, which is one
# leaked .env away from being plaintext.
*.sql
*.sql.gz
+573
View File
@@ -0,0 +1,573 @@
# Changelog
What each published build changes for you, newest first.
The three images are versioned independently and their numbers are not meant to match — usually only
one of them changed. An entry says which artefact and which version, so `web 0.102.0` and
`api 0.81.0` sitting under the same date is the ordinary case, not a mismatch.
Every version published to a channel appears here. Nothing is written from memory afterwards: a build
cannot be published without its entry.
## 2026-08-24 — Two places still described the switch we had just removed `web 0.115.0`
The entry above says `JARVIS_TELEMETRY` was removed "from the compose file, from `.env.example`,
from the licence screen and from the install wizard". Three of those four were true. **The licence
screen still carried a line telling you to set `JARVIS_TELEMETRY=off` in `.env` to stop reporting**,
in all three languages, and this README's community-edition table still listed reporting as
"Switchable off". Both were wrong the moment that release shipped, and both are corrected here.
Nothing about the behaviour changes — there was never a switch behind those sentences to begin with.
What changes is that the product and this document now say the same thing: every instance reports,
the fields are listed below and on your own licence screen, and there is nothing to set.
## 2026-08-24 — Reporting is no longer optional `api 0.90.0` `web 0.114.0`
**`JARVIS_TELEMETRY` is gone, and with it the ability to stop your instance checking in.** Three days
ago this repository told you the variable worked, and apologised for the fact it had not. We are
withdrawing the option rather than leaving you to discover it stopped working: it is removed from the
compose file, from `.env.example`, from the licence screen and from the install wizard, which is
nine steps now instead of ten.
The reason is the one already written down for why a community instance reports at all. A free tier
the publisher cannot see is a free tier nobody can warn — and the instances that opt out are exactly
the ones most likely to be running an old build unattended, which is the population a warning is for.
**What has not changed is what leaves your network, or your ability to see it.** The field list below
is complete and unchanged: your instance id and public key, the version and contract hash it runs,
its public address, and counts of organizations, users, agents and assets. No names, no conversation
content, nothing about the machines you administer. **Settings → Licence** shows you the address and
the contents resolved by your own running instance, which is the answer that matters rather than
anything this document says.
If that does not suit your deployment, tell us before you deploy — we would rather have the
conversation than have you find out from a packet capture.
### Also in this release
**The sign-in field is off on a new installation.** The orb you press and hold to reveal the sign-in
form is a deliberate piece of the product, and it is no longer what a brand-new instance greets you
with — a first-time deployer meeting an unlabelled glowing hexagon has a puzzle rather than a login.
Turn it on under **Settings → Platform → Sign-in**; instances that already have it keep it.
## 2026-08-24 — "Open Jarvis" sent you back to the install screen `web 0.113.0`
**If you installed on web 0.112.0, the last click of the wizard put you in a loop.** You finished,
pressed **Open Jarvis**, and landed back on the install screen reading "This instance already has an
administrator" — with a button to sign in that bounced you to the console, which sent you back to the
install screen again. Nothing was wrong with your instance and nothing was lost; a reload of the page
was enough to break out of it. It was still the worst possible moment for it, on the one screen a
new deployer meets exactly once.
The console asks "does this instance need installing" once, when the page first loads, and it had no
way to learn the answer had changed under it — so at the end of the wizard it was still acting on
what it had been told before you started. The wizard now tells it.
Upgrade with the usual two lines:
```sh
docker compose pull
docker compose up -d
```
## 2026-08-24 — The claim no longer expires `api 0.89.0` `web 0.112.0`
**The thirty-minute window on the install screen is gone.** There is no countdown, no "the window
has closed", and no more restarting the api container to open another one. The first account created
still owns the instance; it can now be created whenever you get to it.
**Which means the exposure is yours to manage, and we would rather say so than let a timer imply it.**
Until somebody creates that first account, anyone who reaches this instance can. The gap that matters
is between `docker compose up -d` and you opening a browser — so keep it short, and prefer pointing
a public hostname at the instance _after_ you have installed rather than before.
Everything else about the claim is unchanged, including the part that protects you where a timer
never did: an instance whose database has gone missing still costs the last characters of your vault
master key to claim, because from the outside it is indistinguishable from a new one.
## 2026-08-24 — Losing your database no longer puts your instance up for grabs `api 0.88.0` `web 0.111.0`
**If your database is ever lost or replaced, your instance was claimable by whoever loaded the page
first.** From the outside it looked exactly like a brand-new deployment — no accounts, no completed
setup — so the install screen opened its 30-minute window and offered a super-admin account. Except
it was not a new deployment: it was yours, on a hostname already in DNS, already in people's browser
history, and already dialled by every agent you have enrolled.
We found this the way these things get found. A `docker compose down -v` meant for a throwaway test
stack landed on a production one, because the compose file that had been copied into that directory
carried its own project name. The database went. What was left was an instance offering itself to the
internet.
**What changes.** An instance that cannot prove it is new still opens the window — you need a way back
in — but creating the first administrator now costs the last characters of your vault master key.
You have it, in your `.env` or on the `jarvis_secrets` volume you were told to back up separately.
Nobody else does.
**A genuine first installation is never asked for it**, and neither is a restart during a slow one.
The test is the age of the vault key at the moment your database first saw it: seconds on a real
first boot, months on a replacement. It is recorded once, so restarting the api to re-open a window
does not change the answer.
**Nothing to do.** No new variable, no migration step. Instances that upgrade into this keep working
exactly as they are — the rule only ever applies to an instance with an empty database, which yours
is not.
## 2026-08-24 — A friendlier install, and a vault key you can only be handed once `api 0.87.0` `web 0.110.0`
**The install screen leads with Jarvis now.** The mark, the name and a welcome come before anything
is asked of you — the first thing this product ever showed a new deployer used to be a password
field with no explanation attached. Ten steps, about five minutes, and the road map on the first
screen is honest about which ones you can skip.
**Only the address really needs you.** The model step used to refuse to continue without a model id
_and_ a key, which trapped anybody evaluating Jarvis without a provider key in hand — on a product
whose remote terminal, agent fleet and vault all work perfectly well without one. An untouched form
now continues and saves nothing. A half-filled one still refuses: that is a mistake rather than a
decision.
**The wizard now asks who may create an account.** Self-registration defaults to open, and nothing
in the install had ever mentioned it — so an instance on a public hostname would accept an account
from anybody who found it, the moment you finished. It is a switch on the address step, beside the
field that decides who can reach the sign-in page at all. Change it later under **Settings →
Platform → Sign-in**.
**The vault key is shown, and downloadable, exactly once.** The install screen hands you the file
rather than telling you to go and run `docker compose exec` — that command is a wall for anybody
deploying through Coolify, Portainer or a managed host, and what is behind the wall is the one value
no backup can reconstruct. The screen says so before you click. Afterwards, and any time later, the
key is on the host:
```sh
docker compose exec api cat /var/lib/jarvis/secrets/vault-master-key
```
### Fixed
- **An interrupted install could not be finished.** Coming back to the wizard after the organization
step showed you an empty form and demanded a name, and the request behind it could only fail —
the community edition allows one organization and you already had it. There was no way forward and
no way around. It now recognises the organization you already have and moves on.
- **A first boot with no configuration printed a red `ERROR` about `JARVIS_UNATTENDED`** on
instances where it was not set. Cosmetic, but it was the first thing a new deployer read in
`docker compose logs`.
- **`JARVIS_ADMIN_EMAIL` and `JARVIS_ADMIN_PASSWORD` alone would install the instance silently**,
with no `JARVIS_UNATTENDED=on`, and skip the wizard entirely. Those variables are what an
unattended install needs _in addition to_ the flag, never a second way of asking for one. If you
keep them in a shared shell or a fleet template, this is the release where they stop acting on
their own.
- **Installing in German or French left you in an English console** — the account the wizard creates
now records the language you installed in.
### Changed
- `JWT_ACCESS_TTL` and `JWT_REFRESH_TTL` are read from `.env` like every other tuning value
instead of being fixed in the compose file. `.env.example` documents what each actually does —
the refresh one is an idle timeout, not the session lifetime its name suggests.
- `PUBLIC_URL` is gone. It was a second variable for the same address as `WEB_ORIGIN` and had not
been read by anything for two releases; it is the stored instance address now, on a settings
screen. Remove it from your `.env` if it is still there — nothing breaks either way.
### New in the manual
The published README grew the procedures it was missing: restoring the vault key onto a new host
(**do it before the first `up -d`** — a generated key is never overwritten, so bringing the stack up
first makes yours unusable), getting back in when you are locked out of the only administrator
account, moving an instance to a new address and what that does to enrolled agents, and which
generated secrets can be rotated.
## 2026-08-24 — `JARVIS_TELEMETRY=off` did not work, and now does
**If you set `JARVIS_TELEMETRY=off` in `.env`, your instance has been reporting anyway.** We are
sorry. The variable never reached the container: compose reads `.env` to fill in `${...}`
placeholders inside the compose file, it does not hand that file to the services, and the `api`
service never listed this one. So the API saw nothing, applied its documented default of "on", and
checked in — while the README, the changelog and `.env.example` all told you that one line was
enough.
What was sent is what the README has always listed and nothing more: your instance id and public
key, the version and contract hash it runs, its public address, and counts of organizations, users,
agents and assets. No names, no conversation content, nothing about the machines you administer.
**The fix is in the compose file, not in the application**, so pulling this repository again is the
whole of it:
```sh
git pull # or re-download docker-compose.yml
docker compose up -d
```
Then confirm it took, from the instance itself:
```sh
docker compose exec api printenv JARVIS_TELEMETRY
```
No output means the fix has not landed yet; `off` means you are silent. **Settings → Licence** says
the same thing on screen, and that is the answer to trust — it reads what the API actually resolved
rather than what a file claims.
If you would like the entries a silenced instance should never have created removed from our side,
write to <antoine@luxit.be> and quote the instance id from that screen.
## web 0.109.0 — 2026-08-24
**The welcome now comes before the account.** The very first thing anybody saw of Jarvis was a form
asking for a password — no name, no explanation, no idea what they were about to be given
administrator rights over. The introduction needs no account, so it goes first, and creating the
administrator is step two with a way back to it.
**Mail and the licence are two steps.** They shared a page and have nothing to do with each other —
one is an app registration in your Entra tenant, the other a key from your supplier — which made one
long screen out of two short questions. The licence step now leads with the thing most people need
to hear: you almost certainly do not need a key, and the community edition is perpetual.
## web 0.108.0 — 2026-08-24
**The install opens on a welcome rather than on a checklist.** It used to begin with "Checks:
database, Redis, vault key" — which tells somebody who has just deployed a product they have never
run that they are already in the middle of something. The first screen now introduces Jarvis, says
what the next steps will ask for, roughly how long it takes, and which of them you can skip.
**The checks moved to the end, and that is a correction rather than a preference.** Run first, they
had nothing to look at: your public address had not been chosen, so there was no URL to probe and no
hostname to judge security keys against — three of the six findings were about a deployment that did
not exist yet. They now run immediately before the screen that commits the install, against the one
you have just described.
**Reporting has a step of its own.** It shared a page with mail and the licence, where a question
about what leaves your network sat underneath two forms. It is the only decision in the wizard we
benefit from, which is exactly why it gets a page you cannot scroll past.
**The step markers no longer collide.** Eight labels never fit the wizard column at any screen width
and ran into each other. The strip is markers now — filled where you are, ticked behind you — with
the step named in full underneath and the whole road laid out on the welcome screen.
## api 0.86.0 · web 0.107.0 — 2026-08-24
**Mail and the licence are configured IN the wizard now, not somewhere else.** That step used to
show two paragraphs whose only affordance was a link to the settings screens — and those links could
not work: an instance that has not finished installing sends every other address back to the install
screen, so clicking one opened a tab that bounced straight back. The real forms are on the step now,
the same ones you meet under Settings afterwards, and nothing on it is required.
**You can decide there whether this instance reports to us.** Reporting used to be an environment
variable and nothing else, so a fresh install had no way to answer the question without editing a
compose file. It is a switch on that step and on Settings → Licence.
`JARVIS_TELEMETRY=off` keeps its authority: an instance whose host has switched reporting off shows
the control locked and says why. Deciding what leaves your network stays with whoever runs the host,
which is exactly why it was a variable in the first place — the switch adds the case where the person
installing the instance IS that person, and the case where you want to fall silent later without a
restart.
**Your vault key is a download.** The last step used to print `docker compose exec api cat …` and ask
you to go and run it. If you deployed through Coolify, Portainer or a managed host, that was a wall
in front of the one thing here that no backup can reconstruct. Now the key is shown on the page with
a button that saves it as a file, and the command is still underneath for anyone who would rather
take it off the host.
That view exists only while the install is unfinished. Once you press Finish it is refused for good,
and every time it was used is in the audit trail.
**Unattended installation, for fleets and for CI.** Set `JARVIS_UNATTENDED=on` with
`JARVIS_ADMIN_EMAIL` and `JARVIS_ADMIN_PASSWORD` and the instance installs itself at boot from the
variables you already provide, and never shows the wizard. Everything else stays optional.
If those credentials are missing or the password is under twelve characters it says so loudly and
leaves the install UNFINISHED rather than completing. An instance marked installed with no account
is one nobody can ever get into, and no restart recovers it — falling through to the ordinary install
screen is strictly better.
## web 0.106.3 — 2026-08-24
**The install screen tells you where you are.** Its step list was six labels in a row where the
current one differed by a font weight — and the difference did not even arrive, because the class
meant to dim the others named a colour this console does not define. Every label rendered
identically, so the one screen whose whole job is to walk you through six steps could not say which
of them you were on.
The stepper now carries that three ways at once, none of them colour alone: a tick for what is done
and a filled number for where you are, connectors that fill in behind you, and a line that names the
step — "Step 3 of 6 · The model" — which is also what makes it readable on a phone, where the labels
step aside.
The same missing colour had flattened the rest of the wizard: hints and explanations rendered as
bright as the headings above them, error messages were not red, and the boxes around the warnings
had no edge. All of it reads properly now.
## api 0.85.3 — 2026-08-24
**An instance still being installed no longer reports itself.** It used to check in thirty seconds
after boot, before anybody had chosen its address — so a fresh install announced itself as
`http://localhost:3000`, with every count at zero. That is worse than silence: the address exists in
the report so that your provider can tell one installation from another, and a list of identical
localhost entries answers nothing.
It now waits until the install screen is finished, then reports normally with the address you chose.
Nothing is lost, only deferred. `JARVIS_TELEMETRY=off` still silences it entirely.
## web 0.106.2 — 2026-08-23
**A language picker on the install screen.** Every other page that can be reached without an account
has one — the sign-in page, a shared transcript — and the install wizard, which is the longest piece
of reading this product puts in front of somebody who has not yet decided to trust it, did not.
It follows your browser as before; the picker is for the case the browser is wrong, which is
routinely: installing from a colleague's laptop, or on a server whose locale nobody set. Before the
first account exists the choice is remembered in this browser; after it, it is saved to your account
as well — so it also corrects the language the claim guessed.
## api 0.85.2 · web 0.106.1 — 2026-08-23
**Installing in German now ends in a German console.** The install screen has always followed your
browser, in English, French or German — but the account it created took the database default of
English, and the console adopted that the moment the wizard handed over. Somebody who had just read
seven screens in their own language was greeted in another and left to find the language switcher.
The account now records the language the wizard was read in. Change it whenever you like under
Settings → Profile; this only decides where you start.
## api 0.85.1 — 2026-08-23
**A fresh install no longer stores an address nobody chose.** api 0.85.0 wrote
`http://localhost:3000` as the public address of any instance that started with no configuration —
the schema default for `WEB_ORIGIN`, saved as though somebody had decided it. The boot banner then
told an operator on a remote host to open a URL that leads nowhere.
The address is now left unset until you choose it, which is what the install screen already assumed:
it fills the field from the address you are reading it at, and the checks say plainly that nothing is
set yet. The banner names the path and not the host — you know how you reach the machine, you were
only missing `/install`.
Nothing to do if you set `WEB_ORIGIN` yourself: it is still read, once, exactly as before.
## api 0.85.0 · web 0.106.0 — 2026-08-23
**Installing Jarvis no longer starts with a text editor.**
```sh
docker compose up -d
```
Then open it in a browser. An install screen creates the first administrator, checks the deployment
and asks for the rest. **The `.env` step is gone** — it demanded six values before the stack would
start, four of them secrets you had to generate with `openssl`, and the whole file is optional now.
**A wrong model key is a message instead of a crash loop.** This is the change underneath all the
others. The api validated its configuration at boot and called `process.exit(1)` when anything was
missing — so a mistyped key, an account out of credit or an endpoint that had moved presented as a
container that would not stay up, with the actual reason on line 40 of a log you had no reason to
open. An unconfigured instance now starts, says what it needs, and the model screen has a **test
button that asks the model for a token before saving**, showing the provider's own words when it
refuses.
**A brand-new instance can be claimed for 30 minutes, and this closes a real hole.** 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 install screen shows a
countdown; `docker compose restart api` opens another window if you miss it.
**Everything the wizard asks has a permanent screen.** Settings → Platform gained two tabs: **Model**
— endpoint, key, model and thinking level, with the same test button the wizard uses — and
**Address**, for your public address and proxy-hop count. The wizard is a first-run convenience, not
the only way in: an instance whose OpenAI key has been rotated, or whose hostname has moved, says so
on a settings page rather than being re-installed.
**The proxy setting is now something you are shown rather than asked to guess.** `TRUST_PROXY_HOPS`
decided which address landed in your audit trail, and nobody can know it in advance. The install
screen displays the `X-Forwarded-For` chain your own request actually carried and the address the
API resolved from it, and you confirm what you see.
**Your secrets are generated for you, into a new `jarvis_secrets` volume.** Back it up, **separately
from the database** — the vault key lives there and nowhere else, and a database backup holds only
ciphertext. The install screen will not finish until you have read the key and typed its last
characters back. The key never leaves the server: there is deliberately no endpoint that returns it.
### Upgrading from an earlier build? There is nothing to do
Leave your `.env` exactly as it is. Your secrets are **adopted** into the new volume rather than
replaced, so the vault is untouched. `WEB_ORIGIN` becomes the stored public address, your `OPENAI_*`
values become the stored model settings, and an instance that already has an administrator is marked
installed by a backfill — **you will never see the install screen**. Everything those variables used
to govern is now a settings page; the variables still seed a fresh install and are otherwise ignored.
One thing did change for you: **`PUBLIC_URL` is no longer read.** It was a second variable for the
same address. There is one now, seeded from `WEB_ORIGIN` and edited under Settings → Platform.
**Changing your public address orphans enrolled passkeys.** It always did — a passkey is bound to the
hostname it was created under — but it was previously a variable nobody edited twice. Now that it is
a field on a screen, the screen warns you before it saves.
## api 0.84.0 · web 0.105.0 — 2026-08-21
**Ask for a licence from inside Jarvis.** Settings → Licence has a form: your company, somebody to
answer, roughly what you need. Send it and your provider gets it with your instance's version and
counts attached, so the conversation starts at "here is what fits" instead of "how many machines do
you have".
**An approved licence installs itself.** The request is signed by your installation, so the licence
is issued for that exact deployment and arrives on it at the next report — 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 rebuilt the host while you waited. A decline comes back the same way, with
your provider's reason on the licence screen and in your inbox. You can withdraw a request you no
longer want, and ask again whenever something changes.
**Community instances now report, and this reverses what we told you this morning.** api 0.83.0 said
— here, and in the README — that an instance without a licence contacts nobody at all. That is no
longer true. A community instance now sends the same fields a licensed one does: counts, the version
it runs, its contract checksum and its public address, to `checkin.luxit.be`. No names, no
conversation content, nothing about what you administer; the README lists every field, and it is the
same list as before.
Why: we could not tell whether the community edition had reached anybody, or which builds were
running when we needed to warn people about one — and the request form above needs the same channel
to work at all.
**`JARVIS_TELEMETRY=off` in `.env` stops it completely**, and costs you nothing else: every
community allowance, every feature and every connected machine stay exactly as they are. What you
lose is the licence request form, which has nowhere to send. A licensed instance reports regardless —
that has always been part of what a licence is, and the licence screen says so.
If you would rather not report, set that variable before you upgrade. Nothing is sent until the
instance's first check-in after start-up.
**On the licence screen**: what leaves your network, the exact address it goes to, and whether
reporting is on at all — all three visible without opening a compose file.
## api 0.83.0 · web 0.104.0 — 2026-08-21
**Jarvis no longer needs a licence key to be useful.** An instance without one now runs the
**community edition** — 1 organization, 3 users, 5 agents, 10 assets — perpetually, free, with every
feature working and nothing reported to anybody. Install it, connect it to something real, and see
what it does before there is anyone to talk to. A licence raises those limits; it does not switch the
product on.
This replaces the behaviour of the last three days, where an instance with no key came up, accepted
its first administrator account and then refused everything else. **If you installed Jarvis and found
it would not let you create anything, this is the release that fixes it** — upgrade, and the
allowances above apply immediately. Nothing needs to be reset, re-run or re-entered.
**A licence that stops applying now falls back to the same allowances**, instead of refusing every
creation. Expired, withdrawn, or a key that will not verify: your instance keeps running everything
already set up and may still create up to the community numbers. There is no state left in which
Jarvis stops being usable over a billing question.
**Fixed: an unreadable licence key blocked every creation.** The licence screen said nothing was
being withheld, and something was — a key that lost a character in a paste resolved every limit to
zero. It now falls back to the community allowances, which is what the screen always claimed.
**On the licence screen**, a fresh install no longer shows a red "No licence" badge. It says
Community edition, lists what that allows against what you use, and states plainly that nothing is
reported. The refusal you get when a limit is full names the edition and what raises it.
Nothing else changes. No configuration, no migration, no new variable — `JARVIS_LICENSE_KEY` is now
documented as optional, and it always could be left empty.
## api 0.82.0 · web 0.103.0 · agent 0.27.0 — 2026-08-21
**Nothing changes in what these builds do.** They are identical in behaviour to api 0.81.0,
web 0.102.0 and agent 0.26.0, rebuilt so that every published image carries the same set of
registry labels: what the image is, where its documentation lives, and a link to this file. The
package page in the registry now says something useful to somebody who lands on it without
context.
Upgrading is worthwhile only for that. If you are running the previous three and reading this,
you already have everything they do.
**The earlier version tags have been removed from the registry.** Every build before these three
is gone; the `stable`, `dev` and `latest` channels point at them. If you had pinned an exact
older version in `.env`, that pin no longer resolves and `docker compose pull` will fail — pin
one of the versions above instead, or drop the pin to follow `stable`.
## api 0.81.0 — 2026-08-21
**Approving a plan now actually authorises it.** When Jarvis asks for consent on a whole plan and you
approve it, the steps that touch the machines the plan names are no longer confirmed one at a time.
Previously the approval bound nothing at all — the assistant was asked to honour it and the executor
never checked — so an approved plan still stopped at every step, and a read-only conversation could
be granted a plan it had no way to carry out.
The clearance is deliberately narrow. It applies only in the conversation the plan was raised in,
only to the machines the plan names, only up to a risk ceiling, and only for four hours. Anything
outside it still asks. It can never be wider than the approver's own access to those machines.
**Two ways somebody could get more than they should, closed.**
- Approving used to lend the approver's own permissions to the plan for the rest of the turn. Work
now runs as whoever asked for it, which is what the audit trail already claimed.
- Deciding a plan asked nothing about whether the decider may touch the machines it names — the
per-command confirmation had checked this for months and this path had not. It does now, for every
machine in the plan. Refusing is never restricted: anyone who can see a plan is wrong may stop it.
**Wording that was not true.** A command that was waiting for a human when the server restarted used
to be reported as possibly having taken effect, and told you to go and check the machine. It never
ran — the confirmation happens before anything is sent — so it now says so.
If you have approval requests older than this release, they are recorded as expired. They were made
under the old behaviour, where approving authorised nothing; treating them as live permissions would
grant something nobody was asked for.
## web 0.102.0 — 2026-08-21
**The approval panel says what approving does.** It used to promise "Jarvis runs the steps below" and
"Nothing runs until you decide". Neither was true: a pending plan blocks nothing, and approving did
not run anything. It now describes the clearance you are actually granting — which machines, what
ceiling, how long — and the rejection notice no longer claims that nothing ran.
The per-command confirmation is unchanged. That one does hold the command until you answer.
## api 0.80.0 · web 0.101.0 — 2026-08-21
**The footer says which channel this instance follows**, beside the version numbers, when you set
`JARVIS_CHANNEL` in `.env`. Leave it empty if you pin exact versions — you then follow no channel,
and the footer shows nothing rather than a label that stopped being true.
## api 0.78.2 · web 0.100.2 — 2026-08-21
**Distribution channels.** Images now carry `stable` and `dev` tags as well as their version.
`stable` moves only after a build has run on the publisher's own instance; `dev` moves on every
build. `latest` is a second name for `stable`, so nothing that already used it has to change, and
the compose files default to `stable`.
A channel is a pointer and a version is a fact. Promotion copies the manifest of an image that has
already been published and run — it never rebuilds — so the bytes you receive are the bytes that were
tested, not a fresh build of the same source.
**Fixed: some builds reported their version as `dev`.** The version is stamped into the image and
shown in the footer; a build published without it said `dev` while being tagged, pinned and run
correctly. Affected api 0.78.0 and 0.78.1 and web 0.100.0 and 0.100.1.
## web 0.100.0 — 2026-08-21
**Recorded terminal sessions can be replayed** at the pace they happened, not only read as a finished
transcript. Idle time is compressed so a session that was mostly waiting is watchable, and how much
was skipped is stated. Sessions recorded before this release can still be read; they carry no timing,
so they cannot be replayed, and the panel says so rather than hiding the control.
## agent 0.26.0 — 2026-08-21
**The command is now `jarvis`.** `jarvis-agent` keeps working and will continue to: both names are
present on every machine, and an agent that updates itself gives itself the missing one. Uninstalling
is a verb — `jarvis uninstall` — beside `status`, `start` and `stop`. The old
`jarvis-agent-uninstall` command is unchanged.
`jarvis uninstall` refuses to run from inside a Jarvis terminal on the machine it is removing.
Stopping the service would kill the command mid-way, leaving the agent stopped and nothing
uninstalled, with no connection left to fix it through. Run it from an ordinary SSH session.
## agent 0.25.0 — 2026-08-21
Internal: the agent asks the machine which name its service is registered under rather than assuming
one. No visible change; it is what makes a later rename survivable.
## agent 0.24.0 — 2026-08-21
**Remote terminal on macOS.** Interactive shells now work on managed Macs, as they already did on
Linux and Windows. The session opens a login shell so the PATH matches what an administrator expects.
**Fixed: sessions were recorded as "closed" instead of "exited".** Typing `exit` ended the session
correctly but filed it as though it had been taken away, with no exit code. Sessions from before this
release keep the reason they were given.
**Fixed: a file descriptor and a thread leaked on every clean exit.** Only visible on machines that
stay up for a long time with many sessions.
+760
View File
@@ -0,0 +1,760 @@
<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, and not optional |
| 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 your instance cannot reach us at all — an outbound firewall, an air gap — the form says so
rather than failing quietly. 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.
+45
View File
@@ -0,0 +1,45 @@
# Jarvis — the agent release, as a pullable image.
#
# OPTIONAL OVERLAY. The base stack runs perfectly without it; what it adds is the one feature a
# self-hosted instance cannot otherwise have, because enrolling a machine downloads a compiled
# binary and there is nowhere for a compose-only deployment to get one. This carries that release
# as an OCI image, so it arrives through the same `docker compose pull` as the api and the web.
#
# Turn it on by naming both files. Either spelling works:
#
# docker compose -f docker-compose.yml -f docker-compose.agent.yml up -d
#
# or, so that a plain `docker compose ...` keeps working for every later command, put this in .env:
#
# COMPOSE_FILE=docker-compose.yml:docker-compose.agent.yml
#
# Upgrading is unchanged: `docker compose pull && docker compose up -d`. The publisher re-runs,
# replaces the release in the volume, and the api picks it up WITHOUT a restart — digests are
# computed from the bytes on disk on every request, not cached at boot.
services:
# Runs once per `up`, copies its payload into the shared volume, exits. Not a server.
agent-releases:
image: ${JARVIS_IMAGE_AGENT:-git.luxit.be/luxit/jarvis-agent-dist:stable}
# Explicit, because the default would be wrong the moment somebody copies this block: a
# restarting one-shot is an infinite loop, and Compose's own default policy is already "no".
restart: "no"
volumes:
- agent_releases:/out
api:
# NOTE THE COUPLING: until the publisher has exited 0, the api does not start. That is
# deliberate — a release that failed to arrive should stop the deploy and say so, rather than
# leave an instance quietly handing 404s to every installer somebody runs this week. The cost
# is that an unreachable registry now blocks the whole stack, so if that trade is wrong for
# you, drop these three lines and the api will simply serve no build until the volume fills.
depends_on:
agent-releases:
condition: service_completed_successfully
volumes:
# Read-only: the API serves these bytes to every managed machine and never writes here.
- agent_releases:/srv/agent-releases:ro
environment:
AGENT_RELEASE_DIR: /srv/agent-releases
volumes:
agent_releases:
+240
View File
@@ -0,0 +1,240 @@
# Jarvis — self-hosting stack.
#
# Everything runs from published images; nothing is built here and no source is needed.
#
# docker compose pull
# docker compose up -d
#
# That is the whole first deployment. Open the address in a browser and an install screen asks the
# questions: it creates the first administrator, checks what this deployment actually looks like
# from inside, and stores the address, the model and the rest as settings you can change later.
#
# THERE IS NO .env STEP ANY MORE. This file used to demand six variables before it would start,
# four of them secrets you had to generate with `openssl` — and it failed badly when one was wrong,
# because the API exited 1 and the restart policy turned a typo into a crash loop. `.env.example`
# still exists and every value in it is optional: pinning, the host port, the agent overlay.
#
# THE ONE THING TO DO AFTERWARDS: back up the `jarvis_secrets` volume, separately from the database.
# The install screen shows you why and will not finish until you have. See the `init` service below.
#
# Only the `web` service publishes a port. Its nginx serves the app and reverse-proxies /api and the
# websocket to the internal `api` service, so your own TLS terminator has exactly one target and the
# API is never reachable from outside this compose network.
#
# UPGRADING: `docker compose pull && docker compose up -d`. Every Jarvis image here tracks `stable` by
# default, so that is the whole upgrade. Postgres and Redis are not on a Jarvis channel — they follow
# their own upstream tags. Schema changes apply themselves when the api starts, and they are ONE-WAY:
# there is no migration history, so pulling an older api image does not put the schema back. Take a
# dump first. See the README.
#
# A channel tag does NOT mean the app forgets which build it is: `stable` is a second name on the same
# image as its version tag, and the version is stamped INTO the image when it is built. The footer in
# the app and /version.json keep reporting the real number whichever name you pulled it under — which
# is what lets you tell somebody which build you are on when something goes wrong.
#
# To pin instead — recommended once you are in production, because it makes an upgrade a decision rather
# than a side effect of pulling — set JARVIS_IMAGE_API and JARVIS_IMAGE_WEB (and JARVIS_IMAGE_AGENT, if
# you run the agent overlay) in .env to explicit version tags.
#
# No version number is written in this comment on purpose. Nothing in the publishing path would ever
# bump one, so a number here is a number that goes stale while nobody is looking.
name: jarvis
services:
# Writes the stack's secrets, once, into a volume the other services read. Runs to completion
# before anything else starts and then exits — `docker compose ps` shows it as `Exited (0)`, which is
# what success looks like and not something to fix.
#
# THIS IS WHY .env NO LONGER ASKS FOR FOUR openssl INVOCATIONS. A vault key, two signing secrets
# and a database password are values no person should be choosing, and asking for them put the
# most consequential one — the vault key, which everything in the vault is sealed under — in the
# hands of whoever was least equipped to look after it, at the moment they were least interested.
#
# It never overwrites. On an upgrade it ADOPTS whatever is still in your .env, so a stack that has
# been running for a year keeps its own keys and this service is inert from its second run onward.
#
# BACK THIS VOLUME UP, AND SEPARATELY FROM THE DATABASE. It is deliberately not `postgres_data`:
# every credential in the vault is encrypted under a key that lives here, so a database dump that
# travelled with its own key would be a dump that decrypts itself. The setup screen shows you the
# key once and will not let you past until you have put it somewhere.
init:
image: ${JARVIS_IMAGE_API:-git.luxit.be/luxit/jarvis-api:stable}
entrypoint: ["node", "/app/apps/api/init-secrets.cjs"]
restart: "no"
environment:
# Only read when the corresponding file does not exist yet — the upgrade path for a stack
# whose .env already holds these. Empty on a fresh install, which is the ordinary case now.
VAULT_MASTER_KEY: ${VAULT_MASTER_KEY:-}
JWT_ACCESS_SECRET: ${JWT_ACCESS_SECRET:-}
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-}
volumes:
- jarvis_secrets:/var/lib/jarvis/secrets
postgres:
image: postgres:16-alpine
restart: unless-stopped
depends_on:
init:
condition: service_completed_successfully
environment:
POSTGRES_USER: jarvis
# The file, never the variable. The two are mutually exclusive in this image — it refuses to
# start when both are set — which is exactly why `init` adopts an existing .env value into the
# file instead of the compose file trying to choose between them.
POSTGRES_PASSWORD_FILE: /var/lib/jarvis/secrets/postgres-password
POSTGRES_DB: jarvis
volumes:
- postgres_data:/var/lib/postgresql/data
# Read-only: postgres consumes this secret and has no business ever writing one.
- jarvis_secrets:/var/lib/jarvis/secrets:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U jarvis -d jarvis"]
interval: 5s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
restart: unless-stopped
# Append-only so a restart does not lose the queue and the socket fan-out state.
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
api:
image: ${JARVIS_IMAGE_API:-git.luxit.be/luxit/jarvis-api:stable}
restart: unless-stopped
# The assistant runs long operations, and a deploy is the most common thing that interrupts one.
# Given room to stop, the API aborts each loop, writes the partial answer with a note saying why
# the transcript ends there, and marks the run interrupted so the next process picks it up.
# Docker's 10s default is not enough. RUN_SHUTDOWN_GRACE_SEC must stay the smaller of the two.
stop_grace_period: 60s
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
init:
condition: service_completed_successfully
volumes:
# Read-only. The api reads these keys and must never be the thing that creates one: a
# container that could write here is a container whose bug can replace the key the vault is
# sealed under, and nothing about that failure is visible until somebody opens a credential.
- jarvis_secrets:/var/lib/jarvis/secrets:ro
environment:
NODE_ENV: production
API_PORT: "4000"
# A SEED, not a requirement. The install screen asks for this address and pre-fills it with the
# one you are reading the page at; whatever is stored wins from then on. Passed through so a
# stack that already set it upgrades with its origin intact, and empty on a fresh install —
# which is a supported state, not a broken one.
#
# PUBLIC_URL is deliberately no longer passed. It was a second variable for the same address,
# and every consumer now reads the single stored value.
WEB_ORIGIN: ${WEB_ORIGIN:-}
# Assembled by the entrypoint from POSTGRES_PASSWORD_FILE, because a password that lives in a
# file cannot be interpolated into a URL by compose. Set DATABASE_URL in .env to override it
# outright — pointing at a managed postgres outside this stack, say.
DATABASE_URL: ${DATABASE_URL:-}
POSTGRES_PASSWORD_FILE: /var/lib/jarvis/secrets/postgres-password
REDIS_URL: redis://redis:6379
# Delivered as files rather than values, by the `init` service above. The `_FILE` suffix is the
# convention this postgres image and most others already use, so a `docker secret` of your own
# can be pointed at these paths instead with nothing here changing.
JWT_ACCESS_SECRET_FILE: /var/lib/jarvis/secrets/jwt-access-secret
JWT_REFRESH_SECRET_FILE: /var/lib/jarvis/secrets/jwt-refresh-secret
# Interpolated like every other tuning value, rather than frozen here. These two were the only
# ones written as literals, which meant the one knob an operator under a session policy asks
# for was also the one they could not reach from `.env`. See `.env.example` for what each
# actually does — the refresh one is not the session lifetime it looks like.
JWT_ACCESS_TTL: ${JWT_ACCESS_TTL:-900}
JWT_REFRESH_TTL: ${JWT_REFRESH_TTL:-1209600}
# THE ONE YOU CANNOT LOSE. Every credential in the vault is encrypted under it, and so is this
# instance's licence identity, every authenticator secret and every terminal recording. It is
# generated into `jarvis_secrets` on your first boot and exists NOWHERE ELSE — back that volume
# up separately from the database. The setup screen shows it to you once.
VAULT_MASTER_KEY_FILE: /var/lib/jarvis/secrets/vault-master-key
# Any OpenAI-compatible endpoint. All four of these are seeds for the stored settings.
OPENAI_BASE_URL: ${OPENAI_BASE_URL:-https://api.openai.com/v1}
# NO LONGER REQUIRED, and that is the point of the change. This used to be validated at boot,
# so a key that was merely wrong made the API exit 1 and the restart policy turned it into a
# crash loop. The install screen asks for it, tests it against the provider, and stores it
# encrypted; what is passed here only seeds an instance that has nothing stored yet.
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
OPENAI_MODEL: ${OPENAI_MODEL:-gpt-4o}
OPENAI_THINKING_LEVEL: ${OPENAI_THINKING_LEVEL:-medium}
# How many proxies sit in front and rewrite X-Forwarded-For. ONE is the web container's own
# nginx, which is always there — so 1 is right when nothing else fronts it, and 2 when your own
# TLS terminator does. Raising it is the dangerous direction: the API trusts that many hops of a
# header the client can forge, and too high lets a caller choose the IP that lands in the audit
# log, in the session list and in the enrollment rate limit.
TRUST_PROXY_HOPS: ${TRUST_PROXY_HOPS:-2}
# Required to create new organizations, users or agents. Without it an instance keeps running
# everything it already has, creates nothing new, and contacts nobody. See the README.
JARVIS_LICENSE_KEY: ${JARVIS_LICENSE_KEY:-}
# Which distribution channel this instance follows, shown to signed-in operators beside the
# version numbers. Set `JARVIS_CHANNEL=stable` (or `dev`) in .env if you track a channel;
# LEAVE IT EMPTY IF YOU PIN EXACT VERSIONS, because then you follow no channel — you follow a
# decision — and the footer shows nothing rather than a label that stopped being true.
#
# It is not baked into the image, and cannot be: a channel is decided after a build and moves
# afterwards, so the same image is `dev` one week and `stable` the next. Only you know which
# one you are on.
APP_CHANNEL: ${JARVIS_CHANNEL:-}
# Install from the environment and never show the wizard. For fleets and for CI — see
# .env.example. Off means the ordinary install screen, which is what one deployment wants.
JARVIS_UNATTENDED: ${JARVIS_UNATTENDED:-off}
JARVIS_ADMIN_EMAIL: ${JARVIS_ADMIN_EMAIL:-}
JARVIS_ADMIN_PASSWORD: ${JARVIS_ADMIN_PASSWORD:-}
JARVIS_ADMIN_NAME: ${JARVIS_ADMIN_NAME:-}
AGENT_HEARTBEAT_INTERVAL_SEC: ${AGENT_HEARTBEAT_INTERVAL_SEC:-30}
RUN_SHUTDOWN_GRACE_SEC: ${RUN_SHUTDOWN_GRACE_SEC:-25}
# Where the agent binaries live, if you have them. Leaving this unset is a supported state:
# everything except the agent installer works, and the installer answers 503 saying no build is
# published. See the README — a self-hosted instance has no way to produce these.
AGENT_RELEASE_DIR: ${AGENT_RELEASE_DIR:-}
healthcheck:
test:
- CMD
- node
- -e
- "fetch('http://localhost:4000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
interval: 10s
timeout: 5s
retries: 12
# First boot syncs the schema and runs the data backfills before it listens.
start_period: 40s
web:
image: ${JARVIS_IMAGE_WEB:-git.luxit.be/luxit/jarvis-web:stable}
restart: unless-stopped
depends_on:
- api
ports:
# Put your own TLS terminator in front of this. Jarvis speaks plain HTTP here on purpose and
# reads X-Forwarded-Proto to know what the browser actually used.
- "${JARVIS_PORT:-8080}:80"
volumes:
postgres_data:
redis_data:
# The keys, deliberately apart from postgres_data. Back it up, and not to the same place: a
# database dump is worthless to a thief without this, and worthless to YOU without it either.
jarvis_secrets:
Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

+40
View File
@@ -0,0 +1,40 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" width="96" height="96" role="img" aria-label="Jarvis">
<!--
The Jarvis identity mark. Two hexagonal shells OUT OF PHASE — a flat-top holding a pointy-top —
around a core. That phase offset is the signature: a version that drops the inner ring is a
hexagon, not this logo.
Colours are literals rather than CSS variables, because a README is rendered by a forge that
does not load the app's stylesheet. The stroke reads on a white and on a dark ground alike,
which is what a mark in a README has to survive.
-->
<defs>
<linearGradient id="shell" gradientUnits="userSpaceOnUse" x1="48" y1="6" x2="48" y2="90">
<stop offset="0" stop-color="#6e64d8" />
<stop offset="1" stop-color="#6e64d8" stop-opacity=".76" />
</linearGradient>
<radialGradient id="halo" gradientUnits="userSpaceOnUse" cx="48" cy="48" r="30">
<stop offset="0" stop-color="#a49cfc" />
<stop offset=".55" stop-color="#a49cfc" stop-opacity=".32" />
<stop offset="1" stop-color="#a49cfc" stop-opacity="0" />
</radialGradient>
</defs>
<circle cx="48" cy="48" r="30" fill="url(#halo)" opacity=".26" />
<path
d="M 48 23 L 26.35 35.5 L 26.35 60.5 L 48 73 L 69.65 60.5 L 69.65 35.5 Z"
fill="none"
stroke="#6e64d8"
stroke-width="4"
stroke-linejoin="round"
opacity=".4"
/>
<path
d="M 90 48 L 69 11.63 L 27 11.63 L 6 48 L 27 84.37 L 69 84.37 Z"
fill="none"
stroke="url(#shell)"
stroke-width="6"
stroke-linejoin="round"
/>
<circle cx="48" cy="48" r="8" fill="#a49cfc" />
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB