Deployment Guide¶
JARVIS is a self-hosted research assistant. This document covers installation, remote access, TLS, service configuration, and troubleshooting.
For first-time setup, start with README.md Quickstart. If the two conflict, this file is canonical.
Solo deployment (recommended for single-user)¶
Use the installer for first-time setup. It generates secrets, asks how you will access JARVIS (localhost, LAN diagnostics, Tailscale, Cloudflare Tunnel, or Let's Encrypt), selects single-user or multi-user mode, detects GPU support, pulls the prebuilt application images, starts the stack, and opens the first-run wizard.
Manual docker compose up -d is an advanced recovery path after .env and
secrets already exist. GPU users should prefer ./setup.sh; it persists the GPU
overlay so later compose starts keep using it. See GPU acceleration.
Required .env vars (setup.sh or init-secrets.sh generates any that are blank):
| Var | Purpose |
|---|---|
JARVIS_API_KEY |
32-byte hex; gates the REST API + dashboard login |
JARVIS_CONFIG_KEY |
Fernet key; encrypts database-backed integration settings at rest |
LITELLM_MASTER_KEY |
32-byte hex; gates LiteLLM admin endpoints |
JARVIS_MODEL_HMAC_KEY |
64-hex; HMAC-signs Pulse classifier pickle blobs; auto-generated — do not hand-edit |
Health checks:
curl http://127.0.0.1:8010/health # paper_ingestion
curl http://127.0.0.1:8011/health # learning_engine
Fresh install on a second machine¶
git clone https://github.com/limitcycle-oss/jarvis-rd-assistant.git
cd jarvis-rd-assistant
./setup.sh --check
./setup.sh
Manual .env editing is optional and mostly for advanced operators. Telegram,
SMTP, source keys, model choices, and cloud providers can be configured through
the first-run wizard or Settings after the stack starts.
Telegram is optional. If you enable it, two machines must never share a bot token — Telegram routes updates to whichever client polled last. Create a separate bot via @BotFather per machine.
After the stack is healthy, open the dashboard. The onboarding wizard runs on first visit and guides you through admin-account creation, SMTP, Pulse schedule, and Telegram pairing in one continuous flow.
Source HTTP cache¶
JARVIS caches successful GET responses from external metadata sources (Semantic Scholar, arXiv, OpenAlex, Crossref, PubMed) to reduce duplicate outbound requests and 429 failures. Cache is on by default; only GET+200 responses for those hosts are stored.
| Env var | Default | Description |
|---|---|---|
SOURCE_HTTP_CACHE_ENABLED |
true |
Set false to disable entirely. |
SOURCE_HTTP_CACHE_TTL_SECONDS |
900 |
Seconds a cached response is considered fresh. |
A service restart is required when changing these.
Pulse source caveats¶
arXiv rate-limits to ~1 req/3 s; repeated manual Pulse runs can return HTTP 429. A zero-card run with degraded_reason means the job completed but every source was empty, rate-limited, or misconfigured. If arXiv returns 429, wait the Retry-After window (≥30 s) before retrying. Use Settings → Pulse → Diagnostics for production troubleshooting; /api/pulse/debug is dev-mode only (DEV_MODE=true).
OpenAlex requires the free OPENALEX_API_KEY; create one at openalex.org/settings/api. PubMed works without a key but an NCBI key raises the rate limit.
GPU acceleration (optional)¶
The default stack is CPU-safe. Ollama runs on CPU out of the box. On GPU, the first paper analysis takes a few minutes; on CPU-only it can take 30 minutes or more — fully supported, just slower. The first run also downloads the Ollama model set for your hardware tier — roughly 5–22 GB depending on tier, after the application images are pulled; see Disk budget. On macOS, Docker containers cannot use the Apple GPU — expect CPU-speed analysis; allocate ≥8 GB to Docker Desktop.
Standard GPU overlay (auto-engaged by setup.sh)¶
setup.sh automatically enables GPU support when it detects the Docker NVIDIA runtime (docker info probe). On a CUDA-capable host with the NVIDIA Container Toolkit installed, run setup.sh once — it merges docker-compose.gpu.yml into the active compose file list and persists COMPOSE_FILE to .env, so every subsequent docker compose up -d call continues to use the GPU overlay without any extra flags.
To start with the GPU overlay manually (without running setup.sh):
Note: this one-off command does not persist COMPOSE_FILE. To make it permanent, add to .env:
Makefile targets (make up, make profile-stack-up) always run CPU-only. They do not include the GPU overlay, regardless of hardware. Use the explicit -f form or the .env COMPOSE_FILE setting for GPU acceleration outside of setup.sh.
The ./setup.sh --check command reports GPU toolkit availability as an informational item.
vLLM overlay (optional, manual)¶
vLLM is a separate, optional overlay — it is not auto-started by setup.sh. Use it to serve large models locally at higher throughput than Ollama.
docker compose -f docker-compose.yml -f docker-compose.gpu.yml -f docker-compose.vllm.yml --profile vllm up -d
After the vLLM container is healthy, expose it through the local LiteLLM route and choose the served model in Settings → Models → AI models. The advanced backend and hardware panel shows local runtime guidance, while the Main and Quick model cards remain the assignment surface.
Observability (optional, off by default)¶
LLM-call tracing via Langfuse is opt-in. OBSERVABILITY_ENABLED defaults to false and the Langfuse SDK is never constructed — zero overhead.
To enable (provisions a loopback-only Langfuse instance, no signup needed):
Open http://localhost:3002 and sign in with LANGFUSE_INIT_USER_EMAIL (default operator@jarvis.local). Langfuse is a single operator tool, loopback-bound, decoupled from JARVIS user accounts.
Full contract and rotation procedure: docs/contracts/04-observability.md §9.
Remote access (optional)¶
By default the dashboard is reachable only on the machine it runs on
(http://localhost:3001): it binds to loopback (DASHBOARD_BIND_HOST) and nginx
answers only to a Host allowlist (DASHBOARD_SERVER_NAME, below). If you only
use JARVIS on that one machine, skip this section — nothing needs to be exposed.
To reach it from another device, re-run ./setup.sh, accept the Overwrite
prompt, and select the access route you want. An unattended reconfiguration must
include --overwrite-env; without either confirmation, setup starts the existing
configuration unchanged. Setup configures the JARVIS settings, service profiles,
and health checks for the new choice:
- Guided private HTTPS uses Tailscale Serve on the same host and keeps the
dashboard bound to loopback. Setup uses an existing client or offers an
explicit-consent package install on supported Linux hosts, can ask before
sudo tailscale up, configures Serve, and verifies the final endpoint. - A reverse proxy with TLS on your own public domain — the Caddy
letsencryptprofile (see Deployment Modes below). Notelocal-https(mkcert) is not a remote-access option — it is loopback-only, for a locally-trustedhttps://localhost:3443on the JARVIS machine itself. - A Cloudflare Tunnel — outbound-only, no open ports.
- A mesh VPN with your own proxy is a manual adapter; provide a named HTTPS origin and satisfy the trust contract below.
Changing access routes safely¶
Setup treats an access change as one operation and accepts the replacement only after its exact endpoint passes the health and production-readiness checks. If a later step fails, setup restores and verifies the last working dashboard and owned access route. Running setup again resumes that recovery after an interruption. If recovery cannot be verified automatically, setup stops and prints exact commands rather than reporting success; follow those commands from the same checkout, then rerun setup to complete verification. Do not delete the retained recovery data or edit generated configuration while recovery is pending.
A proxy supplied with --public-origin remains operator-owned. Setup can
restore the dashboard settings and verify that address, but it does not
reconfigure the proxy itself.
Example: Tailscale¶
Install Tailscale on family devices, then choose the guided private-HTTPS option
in ./setup.sh. If the host client is missing on Debian, Ubuntu, Linux Mint,
Pop!_OS, or Fedora with systemd, setup prints the official package-repository
commands and asks before running them. Non-interactive installation requires
--install-prereqs. macOS, unsupported distributions, and WSL without systemd
receive manual installation guidance instead.
If the client is signed out, interactive setup asks before sudo tailscale up;
Tailscale handles the browser sign-in. JARVIS never handles those credentials.
After authentication, setup sends Tailscale Serve to the host-only listener at
127.0.0.1:3003 (or the persisted DASHBOARD_TRUSTED_HOST_PORT) and probes the
named HTTPS address for JARVIS's exact health marker. A missing client,
cancelled sign-in, or failed Serve command leaves localhost working but exits
nonzero so an unfinished private route is not reported as complete.
--public-origin https://<host> remains the non-interactive/manual adapter for
an HTTPS proxy that the operator already configured. Setup does not install or
configure that proxy, but it verifies the exact JARVIS health marker and exits
nonzero until the address reaches this installation.
Deployment Modes¶
| Mode | Additional route work | Inbound ports | Transport |
|---|---|---|---|
| Localhost | None after the base install | none | Plain HTTP on loopback (:3001) |
| LAN diagnostics | Firewall or VM-network check when needed | 3001/tcp on LAN iface |
Plain HTTP; remote clients can request only /health/jarvis |
| Guided private HTTPS (Tailscale Serve) | Depends on account sign-in | none on your host | Real HTTPS at the tailnet hostname; Tailscale owns the certificate |
Local HTTPS (--profile=local-https) |
Setup creates and trusts a local certificate | 3443/tcp on loopback |
mkcert-issued HTTPS, trusted only by browsers in the same OS trust stores |
| Cloudflare Tunnel | Depends on account, hostname, and policy setup | none (outbound-only) | HTTPS; Cloudflare edge owns the cert |
| Let's Encrypt / Caddy | Starts after DNS and inbound ports are ready | 80/tcp, 443/tcp |
HTTPS; Caddy ACME edge owns the cert |
The first base install normally spends 20–60 minutes downloading container images and AI models. Route setup is additional; later starts reuse those files.
./setup.sh defaults to localhost. The interactive chooser also offers guided
private HTTPS, LAN diagnostics, Cloudflare Tunnel, and Let's Encrypt. The
selected route sets APP_BASE_URL, the host allowlist, and
JARVIS_ACCESS_MODE; no hand-edited environment file is required. See Access
from other devices.
Ingress and adapter contract¶
The dashboard container has two listeners. Container port 3000, normally
published as host port 3001, serves the full app only to localhost; remote LAN
clients get only /health/jarvis. Trusted HTTPS edges use container port 3002.
Tailscale reaches that listener through host loopback port 3003.
Each remote adapter must also agree with JARVIS on the public hostname,
certificate owner, Host handling, setup-token handoff, and passkey origin.
Setup configures the listed adapters; a custom proxy must meet the same rules.
| Adapter | Origin / hostname | Cert owner | Host rewrite |
APP_BASE_URL / CORS / DASHBOARD_SERVER_NAME |
Trusted-proxy | Cookie | Setup-token handoff | Passkey origin |
|---|---|---|---|---|---|---|---|---|
| Cloudflare Tunnel | TUNNEL_HOSTNAME configured in Zero Trust |
Cloudflare | No — original Host forwarded | Set automatically by setup.sh option 4 |
Pinned cloudflared container reaches the trusted ingress |
Secure | URL fragment only after active-tunnel and exact-app checks pass | TUNNEL_HOSTNAME |
| Tailscale Serve | Tailnet hostname detected after tailscale up |
Tailscale | No — original Host forwarded | Set automatically by setup.sh option 3 or --tailscale |
Host loopback reaches the trusted ingress on ${DASHBOARD_TRUSTED_HOST_PORT:-3003} |
Secure | URL fragment only after the exact-app check passes | Exact tailnet hostname |
| Let's Encrypt (Caddy) | LETSENCRYPT_DOMAIN |
Caddy ACME | Yes — rewritten to localhost |
Set automatically by setup.sh option 5 |
Pinned Caddy container reaches the trusted ingress | Secure | URL fragment after the certificate and exact-app gates pass | LETSENCRYPT_DOMAIN |
Local HTTPS (caddy_local) |
https://localhost:3443 |
mkcert (local trust store only) | Preserved as localhost:3443 |
CORS_ORIGINS gains https://localhost:3443 automatically with --profile=local-https |
Pinned local Caddy container reaches the trusted ingress | Secure | URL fragment | Exact https://localhost:3443 origin |
| Custom reverse proxy | Whatever you configure | You | Your choice — must match what you allowlist | Pass its existing HTTPS origin with --public-origin; you own the proxy configuration |
Forward only through a trusted ingress you have explicitly constrained | Secure (requires HTTPS) | Manual; setup does not install or authenticate the proxy | Exact origin in APP_BASE_URL; never a raw IP |
Raw-IP LAN browsing is absent because it is not an app route. It exposes only
/health/jarvis; all other remote requests return HTTP 403. See LAN
diagnostics.
Mode 1 — Localhost¶
The default mode — see Solo deployment above for the commands, or run ./setup.sh and pick option 1 at the access-mode prompt.
The dashboard is at http://localhost:3001 (default
DASHBOARD_HOST_PORT=3001). Loopback traffic never leaves the machine, and
browsers treat localhost as a secure context for passkeys. Choose
--profile=local-https only when you also want a locally trusted
https://localhost:3443; setup installs the supported tools with your consent
and prepares the certificate before starting the edge.
Bootstrapping the first admin (the setup token)¶
scripts/init-secrets.sh generates JARVIS_SETUP_TOKEN (a Docker Secret, secrets/jarvis_setup_token.txt) on first run. It gates the unauthenticated first-admin bootstrap endpoints via an X-Setup-Token header — a second factor on top of "no admin exists yet", closing the window where anyone who reaches the instance before you could create the first account. setup.sh prints the click-to-finish link with the token embedded in a URL fragment (.../setup#setup_token=…), never a query string — a fragment is never sent to the server, so it never lands in access logs, the Referer header, or a reverse proxy's request line. Links printed before this fragment migration used a ?setup_token= query string; the wizard still accepts those for compatibility, but new links are always fragments.
If the browser never received the printed link, the admin-creation step can
accept the token value from the end of the line
.../setup#setup_token=<this part>. Use that field only on localhost or a
verified named HTTPS route. Setup and authentication writes are refused over
non-loopback plain HTTP.
In production, an unset JARVIS_SETUP_TOKEN fails closed: the bootstrap endpoint refuses every write until the secret exists. Outside production it warns and allows the request, for local/dev convenience.
Mode 2 — LAN¶
setup.sh sets DASHBOARD_BIND_HOST=0.0.0.0, detects the LAN IPv4
(--address <ipv4> overrides it), and adds it to the dashboard allowlist. This
is plain HTTP with no certificate. From another device, only /health/jarvis
is available; the dashboard and every setup, sign-in, cookie, and passkey path
return HTTP 403. Use guided private HTTPS for family access.
When your LAN IP changes¶
DHCP can assign a different IP after a reboot. The localhost app may still work while the old LAN health address stops responding.
Network precautions¶
Use LAN diagnostics only on a trusted network. This mode intentionally keeps
ENVIRONMENT=development because authenticated routes remain blocked; it writes
the generated API key, encryption key, CORS origins, and host allowlist needed
for the diagnostic endpoint. Setup does not change the host firewall; allow port
3001 only on the intended private interface if a firewall blocks it.
Rate-limit client-IP trust (automatic)¶
JARVIS pins the internal Docker network to 10.137.241.0/24 and derives the
trusted dashboard and edge peers from that subnet. Browser-supplied forwarding
headers are discarded at the direct ingress; only the separate trusted edge may
assert HTTPS.
Subnet collision: if the default conflicts with another local network,
setup.sh --check warns. Set JARVIS_NET_SUBNET to a free IPv4 /27 or larger
network and re-run setup. Setup and update derive the gateway and the highest
four usable addresses; do not edit Compose or nginx.
TRUSTED_PROXY_CIDRS — Python-layer variable for the XFF walk. The standard
stack trusts loopback and the derived dashboard /32. Add another CIDR only
when an external proxy with that exact source address reaches the services.
Encrypted config key rotation¶
Database-backed integration settings stored through Settings are encrypted with
JARVIS_CONFIG_KEY. Provider, SMTP, and Telegram-bot settings are
deployment-wide; Zotero credentials are per-user. Host Docker secrets are a
separate configuration layer.
Rotation is a maintenance task, not part of a routine update. Before starting,
open Admin → Backups, run a backup, and wait until it is marked Complete
and Encrypted. Keep the matching backup encryption key somewhere off the
server.
From the repository root, run:
The script validates every encrypted Settings row before it asks for
confirmation. It then opens a short maintenance window, rotates all rows in one
database transaction, updates both .env and the Docker secret file, restarts
the affected services, and waits for them to become healthy. Key values are read
from files rather than placed in command arguments.
If the command is interrupted or reports an error, do not delete its recovery
files and do not edit either key store by hand. Resolve the reported Docker or
service problem, then run the same command again; it resumes from the recorded
phase. Use bash scripts/rotate-config-key.sh --yes only in automation where a
current verified backup already exists, because it skips the confirmation
prompt.
On success, the script removes its temporary recovery files. On failure, it keeps them and prints the safe resume command.
Docker Secrets¶
JARVIS supports Docker Secrets for sensitive credentials. Each is read from a file at runtime via a _FILE-suffixed env var, keeping plaintext out of the compose environment and shell history. Secrets live in secrets/ at the repo root (gitignored); setup.sh creates and populates them on first run. Each file is mode 644 inside a mode 700 secrets/ directory: the owner-only directory keeps the files private on the host, while the world-readable file bit lets the non-root service containers read them through the compose bind mount.
Operator-provisioned secrets (create manually if not using setup.sh):
The host SMTP fallback is secrets/smtp_pass.txt. It is separate from the
deployment-wide encrypted SMTP row saved through Settings; the Settings value
takes effect without a service restart, while changing the host fallback
requires restarting its service consumers.
| Secret name | _FILE env var |
Purpose |
|---|---|---|
postgres_password |
POSTGRES_PASSWORD_FILE |
PostgreSQL password for the jarvis user |
litellm_master_key |
LITELLM_MASTER_KEY_FILE |
LiteLLM master key (gateway auth) |
jarvis_api_key |
JARVIS_API_KEY_FILE |
JARVIS REST API key (frontend + Telegram) |
jarvis_model_hmac_key |
JARVIS_MODEL_HMAC_KEY_FILE |
HMAC-signs Pulse classifier pickle blobs (auto-generated; mandatory in production) |
jarvis_config_key |
JARVIS_CONFIG_KEY_FILE |
Fernet key for encrypted config values |
litellm_salt_key |
LITELLM_SALT_KEY |
Encrypts provider/model keys stored in LiteLLM's admin DB — never rotate: rotating orphans all stored model keys and requires re-entering them |
telegram_bot_token |
TELEGRAM_BOT_TOKEN_FILE |
Telegram bot token (telegram profile only) |
qdrant_api_key |
QDRANT_API_KEY_FILE |
Qdrant service API key |
infra_ingest_key |
INFRA_INGEST_KEY_FILE |
Shared key for the infrastructure ingestion endpoint |
backup_encrypt_key |
BACKUP_ENCRYPT_KEYFILE |
Encrypts backup archives at rest |
Observability profile secrets (auto-provisioned by make observability-up; only present when the observability profile is active):
langfuse_init_pk, langfuse_init_sk — Langfuse SDK keys injected into app services. langfuse_pg_password, langfuse_nextauth_secret, langfuse_salt — internal Langfuse service credentials.
To rotate the JARVIS API key without exposing it in shell history, generate a replacement file and then restart every running consumer:
install -m 644 /dev/null secrets/jarvis_api_key.txt.next
openssl rand -hex 32 > secrets/jarvis_api_key.txt.next
mv secrets/jarvis_api_key.txt.next secrets/jarvis_api_key.txt
docker compose restart paper_ingestion learning_engine
if docker compose ps --status running --services | grep -qx telegram_bot; then
docker compose restart telegram_bot
fi
The old API key stops working after those services restart. Update any external client that uses it before ending your current local session.
Web UI configuration¶
All ongoing configuration goes through the web wizard and Settings — no .env editing needed beyond what setup.sh writes:
| Setting | Where | Restart? |
|---|---|---|
| SMTP relay | Settings → System → Email / SMTP / onboarding wizard | No |
| Cloud LLM providers and routing | Settings → Models → Providers & Routing | No |
| Telegram bot token | Settings → Integrations → Bot Token | Yes — docker compose restart telegram_bot |
| Sign-in method (single ↔ multi-user) | Settings → System → Sign-in Method | No |
| Auto-fetch interval | Settings → Automation | No |
For multi-user (team) deployment and the full trust boundary, see docs/SECURITY.md.
Choice 4 — Cloudflare Tunnel¶
Exposes JARVIS over an outbound tunnel — no inbound port needed. Edge TLS is terminated by Cloudflare.
Prerequisites: a Cloudflare account, a tunnel and public hostname in the Zero Trust dashboard, and these two Access applications:
- Protect the main hostname (for example
jarvis.example.com/*) with your normal Allow policy. - Add a more-specific application for exactly
jarvis.example.com/health/jarviswith Bypass / Everyone. Cloudflare evaluates the more-specific path first. Bypass disables Access enforcement and Access logging for that path, so use it only here: this endpoint returns the fixed textjarvis-rd-assistantand no account, setup-token, or health details. Never bypass/*or any API path.
See Cloudflare's documentation for path-specific applications and the Bypass policy. Setup cannot create or authenticate the Cloudflare account or policies.
Run ./setup.sh, accept Overwrite for an existing installation, and choose
Cloudflare Tunnel. Setup reads the tunnel token without echoing it, stores it
as a Docker secret, starts the profile, and verifies both an active tunnel
connection and the public hostname.
For unattended setup, put the token in an owner-readable file and pass only its path:
./setup.sh --non-interactive --profile=tunnel --tunnel-ack \
--tunnel-hostname=jarvis.example.com \
--tunnel-token-file=/secure/path/cloudflare-token
docker compose ps cloudflared # should show Up
docker compose exec -T dashboard \
curl -fsS http://cloudflared:2000/ready # proves an active edge connection
docker compose logs --tail=20 cloudflared # look for "Connection established"
Tunnel routing is configured in the Cloudflare Zero Trust dashboard: set the
public hostname to route to http://dashboard:3002. Tunnel setup enables client
IP handling automatically. Do not hand-set JARVIS_TRUST_CF_CONNECTING_IP for a
different proxy: nginx accepts Cloudflare's client header only on the trusted
listener and only from the pinned cloudflared container.
The external probe expects JARVIS's exact health marker. DNS or TLS failure, the wrong application, and a Cloudflare Access or WAF challenge are reported as different states. An Access page means the exact health-path application or its Bypass policy is missing; a WAF challenge means that exact path still needs a narrow challenge exception. Neither response proves that the tunnel reaches JARVIS, so setup keeps the token-bearing finish link on localhost and exits nonzero until the marker itself is returned.
Other private-network adapters¶
Tailscale Funnel is an internet-facing adapter distinct from the guided private Serve route. It is not configured by JARVIS. See the Tailscale Funnel documentation and satisfy the custom proxy contract below.
Another VPN or reverse proxy can work if it provides a named HTTPS origin
and forwards only to the trusted loopback ingress. A VPN that merely exposes
plain http://<ip>:3001 provides diagnostics, not JARVIS authentication.
TLS / Certificates¶
The dashboard container does not terminate TLS. It serves plain HTTP on two
internal listeners: :3000 for localhost and the health-only LAN route, and
:3002 for trusted HTTPS edges. Host port 3001 maps to :3000; Tailscale
Serve reaches :3002 through host loopback port 3003. Caddy, Cloudflare, or
Tailscale owns the certificate before forwarding to the trusted listener.
JARVIS_SKIP_SELFSIGNED_GEN and JARVIS_CERT_SAN belong to an older design.
Current dashboard containers skip self-signed generation and mount ./certs/
read-only, so those variables do not enable HTTPS.
Local HTTPS (mkcert)¶
Setup shows the package plan and asks before installing mkcert and its browser
trust dependency. For a non-interactive cold install, add
--non-interactive --install-prereqs. It then runs scripts/init-mkcert.sh,
which idempotently installs the local CA trust and issues a certificate for jarvis.localhost,
localhost, 127.0.0.1, and ::1 only. This is a loopback-only certificate,
not usable for LAN or public access. Setup advertises https://localhost:3443
only after the exact JARVIS marker validates both against that mkcert CA and the
host's normal trust store; otherwise it exits nonzero with the localhost HTTP
fallback. The script auto-regenerates when the existing certificate expires
within 30 days.
Trust stays inside the operating system where setup ran. A CA created in WSL
does not trust a Windows browser, and a CA created inside a VM does not trust a
browser outside the VM; mkcert trust does not cross that boundary. Use the
default http://localhost:3001 inside the same OS, or guided private HTTPS for
family devices.
For setup through SSH or on a headless VM, the installer prints an HTTP localhost finish link for the outside browser and the exact SSH command that forwards it to the dashboard's loopback HTTP port. HTTP stays inside the encrypted SSH connection; it is not exposed on the LAN. Keep the address as printed instead of importing the VM's local CA into another computer.
The local HTTPS edge sends Strict-Transport-Security: max-age=0, which clears
any earlier policy instead of enabling HSTS. Browsers apply HSTS to a hostname
across ports, so a positive policy received from https://localhost:3443 would
also rewrite the supported http://localhost:3001 fallback and send it to the
wrong TLS port.
make certs remains the advanced repair command. To force early regeneration:
Let's Encrypt (Caddy profile)¶
# .env
LETSENCRYPT_DOMAIN=jarvis.example.com
LETSENCRYPT_EMAIL=you@example.com
CORS_ORIGINS=https://jarvis.example.com,http://localhost:3001
docker compose --profile letsencrypt up -d caddy
Point DNS at the host and ensure ports 80/443 are reachable. Caddy terminates public TLS and reverse-proxies to the dashboard on the internal Docker network, rewriting Host to localhost. The ACME certificate lives in the caddy_data/caddy_config named volumes, managed entirely by Caddy — there is nothing under ./certs/ to hand-edit or import for this path. setup.sh waits for the certificate to be issued (up to 120s) before reporting success or printing the setup link.
The public Caddy edge sends a one-year HSTS policy for the configured hostname
only. JARVIS does not enable includeSubDomains or browser preload: either choice
belongs to the domain owner and is safe only after every affected hostname
already supports HTTPS. Preload submission is a separate browser-vendor process,
not a Caddy setting.
Importing your own certificate¶
There is no supported "drop in a certificate" path for the dashboard container. Two options:
- Local HTTPS edge (loopback only): overwrite
./certs/cert.pemand./certs/key.pemwith your own cert/key pair, then restart the edge that reads them:docker compose up -d caddy_local. This only affectshttps://localhost:3443. - A public certificate: use the Let's Encrypt profile above (fully automatic), or front JARVIS with your own reverse proxy under the custom-proxy trust contract — the certificate then belongs to that proxy, not to JARVIS.
Non-interactive / Automated Installer¶
setup.sh supports --non-interactive for CI pipelines and cloud-init scripts. Run ./setup.sh --help for the full flag reference.
Use ./setup.sh --non-interactive for a complete unattended installation: it
owns access-mode selection, prerequisites, TLS profiles, and route verification.
scripts/jarvis-setup.sh is a local-only compatibility bootstrap for older CI
jobs. It serves plain HTTP on localhost, does not configure remote access or TLS,
and expects host prerequisites to be present.
Pre-flight check¶
Read-only and idempotent. Verifies Docker Engine, Docker Compose v2.24.4 or
newer, openssl, curl, Python 3, GPU toolkit (informational), and .env
presence. With --profile=local-https, it also checks mkcert and browser trust
tooling. Exit 0 = PASS, exit 1 = FAIL. The pre-flight path never installs
packages, writes .env, or starts services, even if --install-prereqs is also
present.
setup.sh verifies the Docker daemon is reachable (docker info), not just that the docker binary is installed — both in ./setup.sh --check and at the start of a real install, before any prompt. If the daemon is down (Docker Desktop not started, DOCKER_HOST misconfigured, or missing group permissions), the installer exits immediately with a fix hint instead of crashing mid-wizard.
Guided prerequisite installation¶
If Docker Engine, Docker Compose v2.24.4 or newer, openssl, curl, Python 3,
or the selected route's host tools are missing, setup prints the package-manager
commands it can run before changing the host. Local HTTPS adds mkcert and the
OS browser-trust package. After showing the plan, setup can install the missing
packages on supported hosts. Interactive setup asks for consent. Non-interactive
setup runs the plan only with --install-prereqs.
# Preflight only; never installs packages
./setup.sh --check
# Non-interactive install; writes configuration, downloads files, and starts JARVIS
./setup.sh --non-interactive --profile=dev
# Interactive setup; prompts before running supported apt/Homebrew commands
./setup.sh
# Unattended setup that is allowed to run the reviewed prerequisite plan
./setup.sh --non-interactive --install-prereqs --profile=dev
--check is the only read-only setup mode. A non-interactive run without
--install-prereqs refuses missing host packages, but otherwise performs the
installation.
General prerequisites support Debian/Ubuntu-style apt-get, Fedora dnf, and
macOS Homebrew where applicable. For an unsupported host or package, setup
stops with manual installation guidance instead of guessing. Tailscale
installation is limited to supported apt/dnf Linux hosts with systemd. macOS,
unsupported distributions, and WSL without systemd receive manual Tailscale
guidance. A non-interactive install still cannot complete account sign-in: run
sudo tailscale up, then rerun setup with --tailscale.
Re-runs: running ./setup.sh with an existing .env and declining the overwrite prompt no longer exits with services stopped — it keeps your .env (secrets, database, and model selection untouched) and starts the stack with it via docker compose up -d, honouring the COMPOSE_FILE and COMPOSE_PROFILES values persisted in .env. New installs write COMPOSE_PROFILES=<selection> (for example telegram, tunnel) into .env; for older .env files without it, setup derives the telegram profile from a non-empty TELEGRAM_BOT_TOKEN and prints a notice.
Key flags include --mode <single|multi>, --tailscale,
--install-prereqs, --domain <host>, --admin-email <email>, and
--profile <dev|local-https|tunnel|letsencrypt>. Run ./setup.sh --help for the full
reference. TELEGRAM_BOT_TOKEN in the environment enables Telegram
automatically.
Single-user vs multi-user mode¶
single (default) makes the sign-in screen offer API-key login first and does not require SMTP. multi makes the sign-in screen offer email magic-link login first; configure and test an SMTP relay to have JARVIS email magic links directly, or leave it unset and the Admin → Invite dialog returns a manual sign-in link to hand to each new user instead. The mode is a login-method preference, not a tenancy switch: user/library scoping and admin invite capability remain governed by sessions, roles, and route-level authorization. In either mode, signed-in users can additionally register passkeys (WebAuthn) for future sign-ins; passkeys are bound to the exact origin in APP_BASE_URL and require HTTPS for a public origin — see the User Guide → Passkeys.
Copy-paste examples¶
# Local development / CI smoke test:
./setup.sh --non-interactive --profile=dev
# Local mkcert HTTPS (loopback only; installs reviewed prerequisites if needed):
./setup.sh --non-interactive --install-prereqs --profile=local-https
# Production with Let's Encrypt:
umask 077
printf '%s' "$MY_SMTP_PASS" > ./smtp-password.txt
./setup.sh --non-interactive \
--domain=jarvis.example.com \
--admin-email=ops@example.com \
--profile=letsencrypt \
--smtp-host=smtp.resend.com \
--smtp-user=resend \
--smtp-pass-file=./smtp-password.txt
rm ./smtp-password.txt
After setup, open the exact Finish setup link printed by the installer. It
carries the one-time token needed to create the initial admin; opening the bare
dashboard URL does not. Once signed in as an admin, invite additional users at
https://jarvis.example.com/admin/users.
Update Workflow¶
Use the lifecycle command:
It verifies the target release, protects data-changing migrations with a signed restore point, and can resume an interrupted run. The command-line guide is the canonical update runbook, including the constrained manual fallback and rollback rules.
Upgrade notes¶
Telegram pairing (breaking change). The Telegram bot now identifies chats
exclusively via the /pair token flow. To pair: open the dashboard → Settings
→ Integrations → Telegram, copy the one-time token, and send /pair <token>
to the bot. The legacy TELEGRAM_CHAT_ID environment variable and the
dashboard-code pairing path (/start PAIR_<code>) no longer work — any
existing TELEGRAM_CHAT_ID value can be removed from .env after pairing.
Telegram owner-override network. The bot calls service endpoints with
X-Owner-User-Id to make per-user requests, trusted only from
OWNER_OVERRIDE_ALLOWED_CIDRS. The bundled compose stack sets this
automatically to cover the jarvis bridge subnet (it tracks JARVIS_NET_SUBNET,
default 10.137.241.0/24), so no change is needed for the default stack. If
you override JARVIS_NET_SUBNET, the allowlist follows it — only set
OWNER_OVERRIDE_ALLOWED_CIDRS explicitly if the bot reaches the services from
some other network. (The bare code default 127.0.0.0/8 is loopback-only and
does not cover the jarvis bridge — the compose stack overrides it.)
Ownership migration (0092). On first startup after upgrade, migration 0092 re-assigns any pre-existing product rows with a NULL owner to the single admin account. This is automatic and non-destructive; it only runs when exactly one admin user exists (the normal single-tenant state).
Instance-owner migration (0105). An upgraded instance with exactly one live
administrator assigns that account as the owner automatically. An instance with
two or more live administrators remains deliberately unresolved. After startup,
run jarvis-research owner status; if repair is required, choose the account
explicitly with jarvis-research owner set <admin-email>. The Admin → Users page
then marks the owner, protects it from demotion or removal, and permits a
database-managed owner to transfer to another live administrator after exact
email confirmation.
Makefile shortcuts for development:
make rebuild-dashboard # frontend-only changes
make rebuild-backend # paper_ingestion + learning_engine
make rebuild-local # core app containers
make rebuild-telegram # optional bot
make up-build # full docker compose up -d --build
Backup + Restore¶
The default stack takes encrypted restore points daily. Administrators can run,
download, retain, and restore them under Admin → Backups. Keep a copy of
secrets/backup_encrypt_key.txt off the server and separate from downloaded
archives; the key is not included in the archive it unlocks.
The backup and restore guide is the canonical runbook for backup contents, same-host restore, fresh-host browser recovery, headless recovery, and failure handling.
Restore capability boundaries¶
The standard stack separates request handling from destructive restore work:
| Service | Restore-related access |
|---|---|
paper_ingestion |
Reads backup archives, writes the small request/status trigger volume, and exposes the authenticated admin API. It has no restore inbox, host-secret write mount, or Docker socket. |
postgres-backup |
The backup sidecar owns database reloads, PDF swaps, Qdrant snapshot staging, and the narrow writable host-secret mount used for the three restored data keys. |
restore-uploader |
Writes only allowlisted uploads to the restore inbox and reads a hashed, expiring upload grant from the trigger volume. It has no database credentials, application secrets, or Docker socket. |
Learning Engine and Telegram receive the maintenance trigger read-only. None of the three restore roles above mounts the Docker socket; the optional Vector log collector's separate read-only Docker socket is unrelated to restore authority.
Do not restore with raw database drops, archive extraction, or volume deletion. Those paths bypass the signed manifest, safety backup, staged swap, and secret reconciliation. Use the linked runbook even when the web app cannot start.
Production Readiness Check¶
setup.sh runs this at install end. It checks DEV_* flags, JARVIS_API_KEY length, SMTP presence, and HTTPS config. Exit non-zero on any HIGH finding; --profile=letsencrypt treats that as fatal.
Troubleshooting¶
| Symptom | Likely cause | Fix |
|---|---|---|
NET::ERR_CERT_AUTHORITY_INVALID on https://localhost:3443 |
The browser is outside the VM/WSL environment, or trust is missing on the JARVIS host | Outside the VM, use setup's SSH command and exact HTTP localhost finish link. On the JARVIS host itself, re-run ./setup.sh --profile=local-https; for manual repair, run make certs, then docker compose up -d caddy_local. |
SSL_ERROR_RX_RECORD_TOO_LONG opening a LAN address |
The browser is trying HTTPS against the plain-HTTP diagnostic port | Check http://<lan-ip>:3001/health/jarvis. Use guided private HTTPS for the app. |
| A LAN health check works but the dashboard returns 403 | This is the intended raw-LAN boundary | Open http://localhost:3001 on the host or the named HTTPS address printed by setup. |
Tunnel works but dashboard 404s at / |
Tunnel routes to wrong port | In Cloudflare Zero Trust, confirm the public hostname routes to http://dashboard:3002. |
| Tunnel traffic is rate-limited as one proxy client | The current installation was not completed with the Cloudflare Tunnel choice, or its pinned ingress settings are stale | Re-run ./setup.sh, accept Overwrite, and choose Cloudflare Tunnel again. Do not hand-edit JARVIS_TRUST_CF_CONNECTING_IP. |
| Rate limiter buckets by proxy IP instead of client | Upstream proxy not in TRUSTED_PROXY_CIDRS |
Add the proxy CIDR to TRUSTED_PROXY_CIDRS. |
Pinned subnet 10.137.241.0/24 collides with LAN |
setup.sh --check warns |
Set JARVIS_NET_SUBNET to a free IPv4 /27 or larger network, re-run setup, and accept Overwrite; setup derives the addresses. |
LAN device pings host but curl http://<IP>:3001/health/jarvis fails |
The dashboard remains loopback-bound or a firewall blocks port 3001 | Re-run setup, accept Overwrite, and choose LAN diagnostics; if it still fails, allow port 3001 only on the trusted LAN interface. |
setup.sh Cloudflare Tunnel mode stops at the Zero-Trust warning |
You have not acknowledged that a tunnel exposes the instance | Configure your Cloudflare Zero-Trust access policy first, then type I understand. For unattended setup, use --profile=tunnel with --tunnel-ack, --tunnel-hostname, and --tunnel-token-file. The old environment hand-edit is no longer used. |
| Settings → "Models & Preferences" shows "No config entries" | DB initialized before default config rows were seeded | docker compose restart paper_ingestion, then verify: docker compose exec postgres psql -U jarvis -d jarvis -c "SELECT key FROM user_config WHERE key LIKE 'llm.%';" — expect 3 rows. |
| Selecting a model returns HTTP 400 "LiteLLM config is read-only" | Model not pulled or config read-only | Pull the model from Settings → Models first. The default smart model is qwen3:8b (16 GB VRAM tier). |
| Pulse generates 0 cards but job shows "done" | All enabled sources returned zero candidates, were rate-limited, or are unconfigured | Open Settings → Pulse → Diagnostics. For OpenAlex set OPENALEX_API_KEY; for arXiv wait Retry-After (≥30 s). |
| Embedding dimension mismatch on startup | Qdrant collection dimension doesn't match the active embedding model | Set EMBEDDING_MODEL_NAME=qwen3-embedding:4b and EMBEDDING_DIMENSION=2560, pull the model, then run REEMBED_RECREATE_COLLECTION=true REEMBED_SNAPSHOT_CONFIRMED=true python -m scripts.reembed if the collection dimension is still wrong. |
| Re-embedding too slow | scripts/reembed.py defaults to the HTTP-bound LiteLLM path |
Switch to local backend: REEMBED_BACKEND=local python -m scripts.reembed (requires sentence-transformers). Benchmark first with REEMBED_BENCHMARK=true. |
password authentication failed for user "jarvis" after changing POSTGRES_PASSWORD |
An existing database still uses the original password | Do not delete the database volume. Restore the matching secret from your backup or revert the accidental change, then run jarvis-research doctor. Use the guided restore if the original secret is unavailable. |
docker compose build/up fails with "no space left on device" during install |
Docker's data root ran out of space | Check the path printed by docker info -f '{{ .DockerRootDir }}' and free or add space through the host's Docker storage controls. Then re-run ./setup.sh; already downloaded layers are reused. |
docker compose build prints pull access denied for jarvis/paper_ingestion (or a sibling service) before building |
Cosmetic — Compose probes the registry for the pinned tag before falling back to a local build | Harmless; ignore it. From v1.1.0 the application images are prebuilt on GHCR and docker-compose.yml sets pull_policy: missing on them (only the locally-built Langfuse wrapper uses pull_policy: build), so this message is only seen on pre-1.1.0 installs or a --build-local build. |
API reference¶
JARVIS exposes REST APIs on paper_ingestion (:8010) and learning_engine
(:8011). Product routes use a browser session; internal operations routes use
the API key; setup and health endpoints have narrower route-specific gates.
Interactive OpenAPI documentation is available at /docs on each service to a
local operator.
Other access options¶
Both of these are the custom reverse proxy row of the trust contract table — JARVIS does not configure or verify either, so you own the hostname/CORS/allowlist agreement:
- ngrok — set
CORS_ORIGINS=https://<subdomain>.ngrok.io, add the ngrok hostname toDASHBOARD_SERVER_NAME, and setAPP_BASE_URLto the ngrok URL. - Traefik — add a label-based override next to
docker-compose.yml. Refer to Traefik's documentation.
See also¶
- README.md — quick start and high-level orientation.
- docs/SECURITY.md — threat model, auth boundaries, multi-tenant hardening checklist.
- docs/known-residual-risks.md — acknowledged-but-deferred risks and their reopen criteria.
- docs/contracts/04-observability.md — Langfuse env-var table, trust boundary, rotation procedure.