Sync the self-hosting stack

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