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