314 lines
17 KiB
YAML
314 lines
17 KiB
YAML
# Jarvis — self-hosting stack.
|
|
#
|
|
# Everything runs from published images; nothing is built here and no source is needed.
|
|
#
|
|
# docker compose pull
|
|
# docker compose up -d
|
|
#
|
|
# That is the whole first deployment. Open the address in a browser and an install screen asks the
|
|
# questions: it creates the first administrator, checks what this deployment actually looks like
|
|
# from inside, and stores the address, the model and the rest as settings you can change later.
|
|
#
|
|
# THERE IS NO .env STEP ANY MORE. This file used to demand six variables before it would start,
|
|
# four of them secrets you had to generate with `openssl` — and it failed badly when one was wrong,
|
|
# because the API exited 1 and the restart policy turned a typo into a crash loop. `.env.example`
|
|
# still exists and every value in it is optional: pinning, the host port, the agent overlay.
|
|
#
|
|
# THE ONE THING TO DO AFTERWARDS: back up the `jarvis_secrets` volume, separately from the database.
|
|
# The install screen shows you why and will not finish until you have. See the `init` service below.
|
|
#
|
|
# Only the `web` service publishes a port. Its nginx serves the app and reverse-proxies /api and the
|
|
# websocket to the internal `api` service, so your own TLS terminator has exactly one target and the
|
|
# API is never reachable from outside this compose network.
|
|
#
|
|
# UPGRADING: `docker compose pull && docker compose up -d`. Every Jarvis image here tracks `stable` by
|
|
# default, so that is the whole upgrade. Postgres and Redis are not on a Jarvis channel — they follow
|
|
# their own upstream tags. Schema changes are migrations the api applies when it starts, and they are
|
|
# ONE-WAY: pulling an older api image does not take one back. Take a dump first. See the README.
|
|
#
|
|
# A channel tag does NOT mean the app forgets which build it is: `stable` is a second name on the same
|
|
# image as its version tag, and the version is stamped INTO the image when it is built. The footer in
|
|
# the app and /version.json keep reporting the real number whichever name you pulled it under — which
|
|
# is what lets you tell somebody which build you are on when something goes wrong.
|
|
#
|
|
# To pin instead — recommended once you are in production, because it makes an upgrade a decision rather
|
|
# than a side effect of pulling — set JARVIS_IMAGE_API and JARVIS_IMAGE_WEB (and JARVIS_IMAGE_AGENT, if
|
|
# you run the agent overlay) in .env to explicit version tags.
|
|
#
|
|
# No version number is written in this comment on purpose. Nothing in the publishing path would ever
|
|
# bump one, so a number here is a number that goes stale while nobody is looking.
|
|
name: jarvis
|
|
|
|
services:
|
|
# Writes the stack's secrets, once, into a volume the other services read. Runs to completion
|
|
# before anything else starts and then exits — `docker compose ps` shows it as `Exited (0)`, which is
|
|
# what success looks like and not something to fix.
|
|
#
|
|
# THIS IS WHY .env NO LONGER ASKS FOR FOUR openssl INVOCATIONS. A vault key, two signing secrets
|
|
# and a database password are values no person should be choosing, and asking for them put the
|
|
# most consequential one — the vault key, which everything in the vault is sealed under — in the
|
|
# hands of whoever was least equipped to look after it, at the moment they were least interested.
|
|
#
|
|
# It never overwrites. On an upgrade it ADOPTS whatever is still in your .env, so a stack that has
|
|
# been running for a year keeps its own keys and this service is inert from its second run onward.
|
|
#
|
|
# BACK THIS VOLUME UP, AND SEPARATELY FROM THE DATABASE. It is deliberately not `postgres_data`:
|
|
# every credential in the vault is encrypted under a key that lives here, so a database dump that
|
|
# travelled with its own key would be a dump that decrypts itself. The setup screen shows you the
|
|
# key once and will not let you past until you have put it somewhere.
|
|
init:
|
|
image: ${JARVIS_IMAGE_API:-git.luxit.be/luxit/jarvis-api:stable}
|
|
entrypoint: ["jarvis", "init-secrets"]
|
|
restart: "no"
|
|
environment:
|
|
# Only read when the corresponding file does not exist yet — the upgrade path for a stack
|
|
# whose .env already holds these. Empty on a fresh install, which is the ordinary case now.
|
|
VAULT_MASTER_KEY: ${VAULT_MASTER_KEY:-}
|
|
JWT_ACCESS_SECRET: ${JWT_ACCESS_SECRET:-}
|
|
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-}
|
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-}
|
|
volumes:
|
|
- jarvis_secrets:/var/lib/jarvis/secrets
|
|
|
|
postgres:
|
|
image: postgres:16-alpine
|
|
restart: unless-stopped
|
|
depends_on:
|
|
init:
|
|
condition: service_completed_successfully
|
|
environment:
|
|
POSTGRES_USER: jarvis
|
|
# The file, never the variable. The two are mutually exclusive in this image — it refuses to
|
|
# start when both are set — which is exactly why `init` adopts an existing .env value into the
|
|
# file instead of the compose file trying to choose between them.
|
|
POSTGRES_PASSWORD_FILE: /var/lib/jarvis/secrets/postgres-password
|
|
POSTGRES_DB: jarvis
|
|
volumes:
|
|
- postgres_data:/var/lib/postgresql/data
|
|
# Read-only: postgres consumes this secret and has no business ever writing one.
|
|
- jarvis_secrets:/var/lib/jarvis/secrets:ro
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U jarvis -d jarvis"]
|
|
interval: 5s
|
|
timeout: 5s
|
|
retries: 10
|
|
|
|
redis:
|
|
image: redis:7-alpine
|
|
restart: unless-stopped
|
|
# Append-only so a restart does not lose the queue and the socket fan-out state.
|
|
command: ["redis-server", "--appendonly", "yes"]
|
|
volumes:
|
|
- redis_data:/data
|
|
healthcheck:
|
|
test: ["CMD", "redis-cli", "ping"]
|
|
interval: 5s
|
|
timeout: 3s
|
|
retries: 10
|
|
|
|
api:
|
|
image: ${JARVIS_IMAGE_API:-git.luxit.be/luxit/jarvis-api:stable}
|
|
restart: unless-stopped
|
|
# The assistant runs long operations, and a deploy is the most common thing that interrupts one.
|
|
# Given room to stop, the API aborts each loop, writes the partial answer with a note saying why
|
|
# the transcript ends there, and marks the run interrupted so the next process picks it up.
|
|
# Docker's 10s default is not enough. RUN_SHUTDOWN_GRACE_SEC must stay the smaller of the two.
|
|
stop_grace_period: 60s
|
|
depends_on:
|
|
postgres:
|
|
condition: service_healthy
|
|
redis:
|
|
condition: service_healthy
|
|
init:
|
|
condition: service_completed_successfully
|
|
volumes:
|
|
# Read-only. The api reads these keys and must never be the thing that creates one: a
|
|
# container that could write here is a container whose bug can replace the key the vault is
|
|
# sealed under, and nothing about that failure is visible until somebody opens a credential.
|
|
- jarvis_secrets:/var/lib/jarvis/secrets:ro
|
|
environment:
|
|
# Read by the api under this name: the refresh cookie is `Secure` only in production.
|
|
NODE_ENV: production
|
|
API_PORT: "4000"
|
|
|
|
# A SEED, not a requirement. The install screen asks for this address and pre-fills it with the
|
|
# one you are reading the page at; whatever is stored wins from then on. Passed through so a
|
|
# stack that already set it upgrades with its origin intact, and empty on a fresh install —
|
|
# which is a supported state, not a broken one.
|
|
#
|
|
# PUBLIC_URL is deliberately no longer passed. It was a second variable for the same address,
|
|
# and every consumer now reads the single stored value.
|
|
WEB_ORIGIN: ${WEB_ORIGIN:-}
|
|
|
|
# Assembled by the api from POSTGRES_PASSWORD_FILE, because a password that lives in a
|
|
# file cannot be interpolated into a URL by compose. Set DATABASE_URL in .env to override it
|
|
# outright — pointing at a managed postgres outside this stack, say.
|
|
DATABASE_URL: ${DATABASE_URL:-}
|
|
POSTGRES_PASSWORD_FILE: /var/lib/jarvis/secrets/postgres-password
|
|
REDIS_URL: redis://redis:6379
|
|
|
|
# Delivered as files rather than values, by the `init` service above. The `_FILE` suffix is the
|
|
# convention this postgres image and most others already use, so a `docker secret` of your own
|
|
# can be pointed at these paths instead with nothing here changing.
|
|
JWT_ACCESS_SECRET_FILE: /var/lib/jarvis/secrets/jwt-access-secret
|
|
JWT_REFRESH_SECRET_FILE: /var/lib/jarvis/secrets/jwt-refresh-secret
|
|
# Interpolated like every other tuning value, rather than frozen here. These two were the only
|
|
# ones written as literals, which meant the one knob an operator under a session policy asks
|
|
# for was also the one they could not reach from `.env`. See `.env.example` for what each
|
|
# actually does — the refresh one is not the session lifetime it looks like.
|
|
JWT_ACCESS_TTL: ${JWT_ACCESS_TTL:-900}
|
|
JWT_REFRESH_TTL: ${JWT_REFRESH_TTL:-1209600}
|
|
|
|
# THE ONE YOU CANNOT LOSE. Every credential in the vault is encrypted under it, and so is this
|
|
# instance's licence identity, every authenticator secret and every terminal recording. It is
|
|
# generated into `jarvis_secrets` on your first boot and exists NOWHERE ELSE — back that volume
|
|
# up separately from the database. The setup screen shows it to you once.
|
|
VAULT_MASTER_KEY_FILE: /var/lib/jarvis/secrets/vault-master-key
|
|
|
|
# Any OpenAI-compatible endpoint. All four of these are seeds for the stored settings.
|
|
OPENAI_BASE_URL: ${OPENAI_BASE_URL:-https://api.openai.com/v1}
|
|
# NO LONGER REQUIRED, and that is the point of the change. This used to be validated at boot,
|
|
# so a key that was merely wrong made the API exit 1 and the restart policy turned it into a
|
|
# crash loop. The install screen asks for it, tests it against the provider, and stores it
|
|
# encrypted; what is passed here only seeds an instance that has nothing stored yet.
|
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
|
OPENAI_MODEL: ${OPENAI_MODEL:-gpt-4o}
|
|
OPENAI_THINKING_LEVEL: ${OPENAI_THINKING_LEVEL:-medium}
|
|
|
|
# How many proxies sit in front and rewrite X-Forwarded-For. ONE is the web container's own
|
|
# nginx, which is always there — so 1 is right when nothing else fronts it, and 2 when your own
|
|
# TLS terminator does. Raising it is the dangerous direction: the API trusts that many hops of a
|
|
# header the client can forge, and too high lets a caller choose the IP that lands in the audit
|
|
# log, in the session list and in the enrollment rate limit.
|
|
TRUST_PROXY_HOPS: ${TRUST_PROXY_HOPS:-2}
|
|
|
|
# Required to create new organizations, users or agents. Without it an instance keeps running
|
|
# everything it already has, creates nothing new, and contacts nobody. See the README.
|
|
JARVIS_LICENSE_KEY: ${JARVIS_LICENSE_KEY:-}
|
|
|
|
# Which distribution channel this instance follows, shown to signed-in operators beside the
|
|
# version numbers. Set `JARVIS_CHANNEL=stable` (or `dev`) in .env if you track a channel;
|
|
# LEAVE IT EMPTY IF YOU PIN EXACT VERSIONS, because then you follow no channel — you follow a
|
|
# decision — and the footer shows nothing rather than a label that stopped being true.
|
|
#
|
|
# It is not baked into the image, and cannot be: a channel is decided after a build and moves
|
|
# afterwards, so the same image is `dev` one week and `stable` the next. Only you know which
|
|
# one you are on.
|
|
APP_CHANNEL: ${JARVIS_CHANNEL:-}
|
|
|
|
# Install from the environment and never show the wizard. For fleets and for CI — see
|
|
# .env.example. Off means the ordinary install screen, which is what one deployment wants.
|
|
JARVIS_UNATTENDED: ${JARVIS_UNATTENDED:-off}
|
|
JARVIS_ADMIN_EMAIL: ${JARVIS_ADMIN_EMAIL:-}
|
|
JARVIS_ADMIN_PASSWORD: ${JARVIS_ADMIN_PASSWORD:-}
|
|
JARVIS_ADMIN_NAME: ${JARVIS_ADMIN_NAME:-}
|
|
|
|
AGENT_HEARTBEAT_INTERVAL_SEC: ${AGENT_HEARTBEAT_INTERVAL_SEC:-30}
|
|
RUN_SHUTDOWN_GRACE_SEC: ${RUN_SHUTDOWN_GRACE_SEC:-25}
|
|
|
|
# This container brings the database up to its own schema before serving. Left on, because on
|
|
# compose one container IS the deployment. Turn it off only where something else runs
|
|
# `jarvis migrate` first — an orchestrator with a run-once Job, say.
|
|
JARVIS_SKIP_MIGRATIONS: ${JARVIS_SKIP_MIGRATIONS:-false}
|
|
# Let a migration DROP columns and tables. OFF, and it should stay off between deliberate acts:
|
|
# without it a migration that would drop one is REFUSED, naming what it would have destroyed,
|
|
# which is the answer you want from an upgrade that was not expecting to lose anything.
|
|
SCHEMA_ACCEPT_DATA_LOSS: ${SCHEMA_ACCEPT_DATA_LOSS:-false}
|
|
|
|
# The browser the assistant may use, if you run one. Unset is a supported state: the two web
|
|
# tools report themselves unavailable and everything else works. It is a separate service ON
|
|
# PURPOSE — a browser runs code written by strangers, and this container holds the vault key.
|
|
# Empty by default, matching the profile above: with no browser running, pointing the api at
|
|
# one would only produce timeouts. Set it when you enable the profile.
|
|
BROWSER_URL: ${BROWSER_URL:-}
|
|
|
|
# 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:-}
|
|
networks:
|
|
- default
|
|
- browsernet
|
|
healthcheck:
|
|
test: ["CMD", "jarvis", "healthcheck"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 12
|
|
# First boot applies the schema and runs the data backfills before it listens.
|
|
start_period: 40s
|
|
|
|
# The headless browser, and everything about this service is about keeping it away from the rest.
|
|
#
|
|
# WHY IT IS NOT PART OF THE API. A browser's whole job is to fetch code written by strangers and
|
|
# run it. The api container holds VAULT_MASTER_KEY and a live connection to Postgres and Redis, so
|
|
# a browser sharing its network would let any page the assistant is pointed at reach them — and
|
|
# reach a cloud metadata endpoint, which is how instance credentials get read out loud.
|
|
#
|
|
# THE ISOLATION IS THE NETWORK, not the good intentions. This service is on `browsernet` and
|
|
# NOTHING ELSE. postgres and redis are on `default`, which it is not a member of, so it cannot
|
|
# resolve or reach them at all. The api is on both, because it is the only thing that calls this.
|
|
# It also carries no secret, no volume and no database URL: a renderer that escapes lands in a
|
|
# container that holds nothing worth having.
|
|
#
|
|
# Delete this service and unset BROWSER_URL if you would rather Jarvis had no web access. Nothing
|
|
# else stops working; the two web tools report themselves unavailable.
|
|
browser:
|
|
# Pinned to a VERSION rather than to :stable, unlike the other three, and the reason is honest
|
|
# rather than stylistic: nothing has promoted a browser to stable yet, because promotion means
|
|
# "this ran on a canary" and the canary does not deploy one — it is opt-in there too. So :stable
|
|
# does not exist for this image, and defaulting to a tag that 404s would be a worse first
|
|
# five minutes than a pin. It moves to :stable once a canary has actually run one.
|
|
image: ${JARVIS_IMAGE_BROWSER:-git.luxit.be/luxit/jarvis-browser:0.1.0}
|
|
# OPT-IN. A profiled service does not start unless you ask for it:
|
|
# COMPOSE_PROFILES=browser docker compose up -d
|
|
# Web access is a decision, not a default — an assistant that can read the internet is a new
|
|
# class of input into a system that administers your customers, so you turn it on deliberately.
|
|
profiles:
|
|
- browser
|
|
restart: unless-stopped
|
|
networks:
|
|
- browsernet
|
|
# A renderer is a memory hog, and a compromised one is a fork bomb. Both are bounded here rather
|
|
# than left to take the host down with them.
|
|
mem_limit: 1g
|
|
pids_limit: 512
|
|
security_opt:
|
|
- no-new-privileges:true
|
|
healthcheck:
|
|
test:
|
|
- CMD
|
|
- node
|
|
- -e
|
|
- "fetch('http://localhost:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
|
interval: 30s
|
|
timeout: 5s
|
|
retries: 3
|
|
start_period: 20s
|
|
|
|
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}:8080"
|
|
|
|
volumes:
|
|
postgres_data:
|
|
redis_data:
|
|
# The keys, deliberately apart from postgres_data. Back it up, and not to the same place: a
|
|
# database dump is worthless to a thief without this, and worthless to YOU without it either.
|
|
jarvis_secrets:
|
|
|
|
# Two networks, and the split is a security control rather than tidiness.
|
|
#
|
|
# default is what every service was already on implicitly — postgres, redis, api, web.
|
|
# browsernet exists so the browser can be reached by the api and reach the internet, WITHOUT
|
|
# being able to resolve or connect to the database and the cache. A service that names no
|
|
# network is on default, so nothing changes for the ones that were already there.
|
|
networks:
|
|
default:
|
|
browsernet:
|