The self-hosting stack

This commit is contained in:
Luxit
2026-08-21 14:40:30 +02:00
commit a96c277011
9 changed files with 897 additions and 0 deletions
+154
View File
@@ -0,0 +1,154 @@
# Jarvis — configuration. Copy to `.env` beside the compose file and fill in.
#
# cp .env.example .env
#
# 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.
# ---------------------------------------------------------------------------
# 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.
JARVIS_PORT=8080
# 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.
#
# 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=
# ---------------------------------------------------------------------------
# The model
# ---------------------------------------------------------------------------
# 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_MODEL=gpt-4o
# minimal | low | medium | high. Higher costs latency and tokens and is worth it for real work.
#OPENAI_THINKING_LEVEL=medium
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
# Licence (required to create anything)
# ---------------------------------------------------------------------------
# Your licence key. REQUIRED to create anything new. Ask antoine@luxit.be for one.
#
# Without it, an instance keeps running everything already set up and refuses to create new
# organizations, users, agents and assets — so an existing deployment does not stop working when
# this reaches it, and a fresh install gets as far as its first administrator and then needs a key.
# An unlicensed instance also contacts nobody at all. See the README for exactly what a licensed one
# reports.
#
# 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.
#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
+478
View File
@@ -0,0 +1,478 @@
<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.
- [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)
- [Licence keys, and what your instance reports](#licence-keys-and-what-your-instance-reports)
- [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 api will not start without one.
- A licence key. See [Licence keys](#licence-keys-and-what-your-instance-reports) — without one an
instance runs but creates nothing new.
- Roughly 2 GB of RAM for the stack and room for Postgres to grow.
## 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
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.
**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.
**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.
## 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, 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.
## 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.
- **`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.
- **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
```
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`:
| 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. |
| 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 nothing can be created | No licence key. 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.
## 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`.
```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.
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.
## 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.
## Licence keys, and what your instance reports
**Jarvis needs a licence key.** Ask for one (see [below](#getting-a-licence-and-getting-help)) and
put it in `.env` as `JARVIS_LICENSE_KEY`. 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.
Without one, an instance keeps running everything already set up — every organization, every user,
every agent, every asset, and the assistant itself — and refuses only to create NEW ones: no new
organization, user, agent or asset. So an existing deployment does not stop working when this reaches
it, and a fresh install gets as far as its first administrator account and then needs a key.
**An unlicensed instance contacts nobody.** No check-in, no telemetry, nothing leaves your network at
all. The reporting below starts only once a key is in place.
A licensed instance then reports to the address written into that key, at an interval your provider
sets — every ten minutes on the current arrangement, so that a renewal or a revocation reaches you
promptly rather than tomorrow. This is everything it sends, in full:
| Field | What it is |
| ------------------------ | ------------------------------------------------- |
| Product | The literal string `jarvis` |
| Licence id | Which licence this is |
| 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 on a
licensed instance 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 → Platform → Licence**. If that is not acceptable for your deployment, the answer is
to run unlicensed — which contacts nobody at all — or to take it up with us before installing.
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,
Jarvis refuses only the creation of new organizations, users, agents and assets; everything already
set up keeps running, and so does the assistant. 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.
## 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:
+155
View File
@@ -0,0 +1,155 @@
# Jarvis — self-hosting stack.
#
# 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
#
# 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:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: jarvis
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
POSTGRES_DB: jarvis
volumes:
- postgres_data:/var/lib/postgresql/data
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
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}}
DATABASE_URL: postgresql://jarvis:${POSTGRES_PASSWORD}@postgres:5432/jarvis?schema=public
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"
# 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}
# Any OpenAI-compatible endpoint. Defaults to OpenAI itself.
OPENAI_BASE_URL: ${OPENAI_BASE_URL:-https://api.openai.com/v1}
OPENAI_API_KEY: ${OPENAI_API_KEY:?set OPENAI_API_KEY in .env}
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:-}
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:
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