# 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`. Both services track `latest` by default, # so that is the whole upgrade. Schema changes apply themselves when the api starts. # # `latest` does NOT mean the app forgets which build it is: the tag is a second name on the same image # as the version tag, and the version is stamped INTO the image at build time. The footer in the app and # /version.json keep reporting 0.51.0 or whatever you actually pulled. # # 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 in .env to explicit tags. 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:latest} 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:-} 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:latest} 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: