evOo-local/docs/self-host.md
cynarAI b78eff1f7b
Run on the deployment's local models without any model choice, preselect Claude Opus 5.5 otherwise, explain why dictation is unavailable (#151)
* Recommend Claude Opus 5.5 by bumping the Pi model catalog

The pinned pi-ai 0.85.1 had no Claude Opus 5.5, so the connect flow fell
back to Opus 5. pi-ai and pi-agent-core 0.87.1 add it, and the keyless
recommendation now preselects it.

The new catalog names router aliases "Qwen Max Latest (Qwen3.8 Max)";
those now read "(auto-updates)" like the other alias labels, so no
picker entry claims to be "latest".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

* Name HTTPS as the cause when dictation is unavailable, and translate it

Over plain HTTP on a LAN address, Safari hides the microphone and speech
recognition, so voice mode said the browser had no dictation, in English
inside a German UI. Dictation now checks for a secure context first and
says to open the address with https://. Its other errors go through
Lingui too, and Safari's blocked recognizer gets a clear message instead
of "Dictation failed: not-allowed".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

* Serve a fake Kokoro voice server in the E2E harness

Onboarding's voice step connects to the local Kokoro server on its
default port, so the shell E2E failed on any machine where nothing (or a
misbehaving server) listened there: the failed request shows up as a
browser console error. The harness now starts an offline stand-in and
points KOKORO_BASE_URL at it, so the suite no longer depends on the
runner host's own voice server.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

* Show only the recommended sign-in until a model is connected

With no model connected, "Connect model" opened the full model settings:
provider list, tiers and a model picker, even though the recommendation
was already preselected. Until something is connected, the Models panel
now shows just the preselected model with its sign-in and API key field;
"Other providers" reveals the full settings.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

* Give the second chart-links test the same cold-load allowance as the first

Each Playwright test runs in a fresh browser context, so the second test's
first navigation re-fetches the lazily imported Plot chunk just like the
first test's warm-up does. On a loaded CI runner that exceeded the default
20 s expect timeout.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

* Use the recommended model as the Anthropic deployment default

A deployment that sets only ANTHROPIC_API_KEY and PI_DEFAULT_PROVIDER=anthropic
now runs Claude Opus 5.5, the same model the connect flow preselects, instead
of an older default.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

* Run every space on the deployment's local models without a connect step

A deployment with PI_DEFAULT_PROVIDER=local and RAKAZO_LOCAL_MODELS set still
asked every user to connect a model, because only OpenRouter and Anthropic
counted as a deployment model. Configured local models now do, keylessly.

RAKAZO_LOCAL_TIERS assigns local models to the tiers the agent delegates to,
for spaces that have not mapped their own, and a RAKAZO_LOCAL_MODELS entry can
name its own server (id=url) when the models run behind different endpoints.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

* Hide model choices when the deployment routes models itself

When the server provides its own models, the system picks and routes
them per task, so Settings no longer offers Models and bot settings no
longer offer Model and Thinking, on web and mobile.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

* Wait for the workspace pane's width transition before measuring it

The pane animates its width over 300 ms, so a single boundingBox read
right after it opens can land mid-transition on a slow runner.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

* Allow chart re-renders inside the chart-links loops to take longer

Each navigation in the loops renders Observable Plot again. On a loaded
CI runner that exceeded the default expect timeout even after the
warm-up, so the per-iteration visibility checks get 30 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 23:18:40 +02:00

688 lines
40 KiB
Markdown

# Self-hosting evOo
The signed-in product is a long-running API, a Graphile Worker, Postgres, and a computer provider (Docker supervisor, E2B, Daytona, or Box). It is not a static site. The marketing site in `apps/www` can be hosted separately.
## Local (source checkout)
Same as the README quick start: `.env` from `.env.example`, Postgres via Compose, `pnpm sandbox:build`, `pnpm dev`, then [http://127.0.0.1:5173](http://127.0.0.1:5173) (or `http://localhost:5173` — both loopback hosts are trusted). Electron: `pnpm --filter @rakazo/desktop dev` while that stack is up, choosing **Existing instance** with that address. The desktop app's **This computer** option instead installs and runs the published images itself with Docker Compose (see [Published images](#published-images-no-checkout)), using port 45173 by default so it can run alongside `pnpm dev`. If that port is occupied, the app selects and remembers another loopback port. The managed API gets a Docker-assigned loopback port; all desktop traffic uses the web origin.
For source development in WSL, keep the checkout and `data` directory in the Linux filesystem (for example, `~/evoo`), and run `pnpm dev` as your normal user. The host-run supervisor matches bot container UID/GID to that user. If Docker Desktop container IPs are unreachable, set `SANDBOX_CONTROL_VIA_LOOPBACK=true` in `.env`; this publishes the token-protected control service on a random loopback port. Leave this unset for the Compose-hosted supervisor.
Compose bot homes mount only their own subdirectory of the application volume using Docker volume semantics. Docker's internal volume paths are never used as host bind mounts.
## Published images (no checkout)
Pull Postgres and `ghcr.io/elie222/rakazo/app` into any empty folder. No clone or image build.
Requires Docker Engine 26+ (API 1.45+ for bot home volume subpaths), the Compose plugin, curl, and OpenSSL.
```bash
mkdir -p evoo && cd evoo &&
curl -fsSLO https://raw.githubusercontent.com/elie222/rakazo/main/infra/compose/install-images.sh &&
bash install-images.sh
```
The installer downloads `docker-compose.images.yml` and `.env.images.example`, creates `.env` with
random secrets, then pulls and starts the images. It preserves an existing `.env` when rerun. For
the installer secret list, non-reuse rules, and recovery, see
[Self-host secrets checklist](./self-host-secrets.md). To customize the public URL, image tag, or
optional providers before startup, run `bash install-images.sh --prepare-only`, edit `.env`, then
run `bash install-images.sh`. Flags may be combined in either order: `--prepare-only`, `--local`.
`SANDBOX_PROVIDER` defaults to `docker`. The images Compose file runs a sandbox supervisor
(from the app image, on the internal network only) and pulls `ghcr.io/elie222/rakazo/computer`.
Signup and local Docker computers work without an E2B account. Optional remote providers: set
`SANDBOX_PROVIDER` to `e2b`, `daytona`, or `box` and add the matching API key. The published-images
Compose stack requires `SANDBOX_SUPERVISOR_TOKEN` for every provider; leave it empty and `compose up` fails closed.
Optional: set `OPENROUTER_API_KEY` or connect a model in the UI after signup.
The example defaults to `edge` (main builds). Published images are `linux/amd64` only for now;
arm64 publishing is paused until an arm64 runner with Docker buildx exists. Do not assume `latest`
is present until a stable release exists.
Open [http://127.0.0.1:5173](http://127.0.0.1:5173). The first registered user becomes the
deployment owner. Put TLS in front of `:5173` for a public host and set the three public origins to
that HTTPS URL.
Images Compose binds web to loopback (`127.0.0.1:5173`). Terminate TLS on the host and proxy
there. Vite preview same-origin-proxies `/api` and `/rpc`, so do not expose `:3100`. Set
`BETTER_AUTH_URL`, `WEB_ORIGIN`, and `API_URL` to that same HTTPS origin, and set
`RAKAZO_HOST` to its hostname (for example, `app.example.com`).
```Caddyfile
app.example.com {
reverse_proxy 127.0.0.1:5173
}
```
Open **Agent computer** on a bot, or send a message that uses the desktop, to see
the local Docker computer. For in-stack Caddy plus remote E2B computers, use the
[production Compose](#public-single-vm-deployment) path and `infra/compose/Caddyfile.prod`
instead of this host proxy.
### Restricted networks / mirror downloads
If the installer, Compose downloads, or image pulls are blocked, use the
[restricted-network guide](./self-host-restricted-network.md) for mirror settings and local files.
### Bot computer resource ceilings
Each Docker computer runs Xvfb, a window manager and a full Chromium driven by an agent that
decides for itself what to open, so it is capped. These defaults provide a starting point for the
Docker computer topology:
| Variable | Default | Accepts |
| --- | --- | --- |
| `RAKAZO_COMPUTER_MEMORY` | `2g` | `2g`, `1536m`, a byte count. Minimum `6m`, Docker's own floor. Also caps swap, so the ceiling holds. |
| `RAKAZO_COMPUTER_CPUS` | `2` | Whole or fractional cores, e.g. `1.5` |
| `RAKAZO_COMPUTER_PIDS_LIMIT` | `2048` | A positive integer |
Set any of them to `0`, `none` or `unlimited` to remove that ceiling. A malformed value fails the
supervisor at startup naming the variable, rather than surfacing later as a failed bot.
### Media library
The deployment owner can add folders of photos and videos under Settings, Media. The worker
indexes them (names, dates, sizes; never moving or changing a file), re-indexes every six hours,
and the owner's bots can then search the library and copy a file into a conversation. Paths are
read on the server, so under Compose mount each folder read-only into both `api` and `worker` at
the same path, for example in an overlay:
```yaml
services:
api:
volumes: ["/srv/media/Photos:/media/Photos:ro"]
worker:
volumes: ["/srv/media/Photos:/media/Photos:ro"]
```
and add `/media/Photos` in the settings. Indexed are the photo and video types a conversation can
hold; capture dates come from video metadata when ffmpeg is installed, otherwise from the file's
modification time.
## Docker Compose (single machine)
1. Copy `.env.example` to `.env` and set `POSTGRES_PASSWORD` (`openssl rand -hex 16`), plus `BETTER_AUTH_SECRET`, `ENCRYPTION_KEY`, and `SCREEN_PROXY_SECRET` to independent long random strings (32+ characters; 64 hex for `ENCRYPTION_KEY`). Docker sandboxes also need a dedicated `SANDBOX_SUPERVISOR_TOKEN`. Keep existing `ENCRYPTION_KEY` values so stored credentials stay decryptable.
2. Set `OPENROUTER_API_KEY` (and `COMPOSIO_API_KEY` if you want Plugins).
3. Build the computer image: `pnpm sandbox:build` (Compose also builds it via the `computer` service).
4. `docker compose --env-file .env -f infra/compose/docker-compose.yml up --build`
5. Open the web origin (`http://127.0.0.1:5173` by default). The first registered user becomes the deployment owner.
On Windows, if an older clone with `core.autocrlf=true` leaves the computer pane hung on boot (`bash\r` in sandbox logs): from a clean worktree, set `git config core.autocrlf false`, run `git add --renormalize . && git checkout -- .`, then rebuild with `pnpm sandbox:build`.
Compose runs Postgres, the sandbox supervisor (Docker socket), API, worker, and a Vite preview of the web app. Bot computers are sibling containers (`evoo/computer:local`) on separate per-bot networks; only the supervisor and screen proxy join each one. The API process does not get an unrestricted Docker socket; the supervisor owns the lifecycle.
Postgres stays on the Compose network only (not published on the host), matching the images
compose. Credentials come from `.env` (`POSTGRES_PASSWORD` is required). Prefer a URI-safe value
(`openssl rand -hex 16`); characters such as `@ : / ? # %` break the interpolated `DATABASE_URL`
inside Compose. Official Postgres images set user, password, and database only on first volume
init, so an existing `pgdata` volume keeps its original identity: keep those values in `.env`, or
change them in place with `ALTER ROLE` / rename. Recreate the volume only after a backup (or when
the data is disposable); `docker compose down -v` deletes all Postgres state. For host-side clients
(`pnpm db:migrate`, GUI tools),
add `infra/compose/docker-compose.postgres-host.yml` so Postgres is published on loopback
`127.0.0.1:5433`, or use
`docker compose --env-file .env -f infra/compose/docker-compose.yml exec postgres sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"'`.
Do not publish Postgres on a public interface.
The Docker supervisor is not published as its own image and is not exposed on the host. It runs from
the app image, stays on the internal Compose network, and holds the Docker socket because access to
it is equivalent to control of the Docker host. Docker sandboxes require `SANDBOX_SUPERVISOR_TOKEN`
(API, worker, supervisor). `SCREEN_PROXY_SECRET` signs browser-screen capabilities (API and web
proxy). Keep both distinct from `BETTER_AUTH_SECRET`.
New credentials use versioned AES-GCM with per-record salt and row-bound AAD. Legacy ciphertext stays readable.
On a VPS, put TLS in front of `:5173` (or serve the web build behind your proxy) and set:
```env
BETTER_AUTH_URL=https://app.example.com
WEB_ORIGIN=https://app.example.com
API_URL=https://app.example.com
```
Cookies and CORS follow those origins. `SIGNUPS_ENABLED` / `SIGNUP_ALLOWLIST` seed the signup
policy when the API starts for the first time. They are not reapplied on restart, so configure them
before that first start.
With a nonempty signup allowlist, users—including existing accounts—must verify their email to sign
in. Configure SMTP below before enabling an allowlist or upgrading an allowlisted deployment.
For a public deployment, configure SMTP and an allowlist before the API's first start.
Keep an installation without email on a trusted local network.
### Verification and password recovery email
Password changes for signed-in users require no email configuration. Forgotten-password recovery
appears on sign-in only when a transactional email provider is available. evOo uses a
provider-neutral contract and ships an SMTP adapter, so Amazon SES, Resend, and self-hosted SMTP
servers use the same configuration:
```env
SMTP_URL=smtps://smtp-user:replace-with-password@smtp.example.com:465
EMAIL_FROM=evOo <no-reply@example.com>
```
For Resend, use `smtp.resend.com`, username `resend`, and an API key as the password. For Amazon
SES, use the regional SMTP endpoint and SES SMTP credentials; these are different from ordinary AWS
access keys. Verify the sender/domain with the provider before testing delivery. Keep credentials in
`.env`, never in tracked files. `smtps://` uses implicit TLS; `smtp://` is also supported but requires
STARTTLS. evOo rejects configuration that disables TLS or certificate verification.
Local source development can use the offline email emulator instead. It captures email without
contacting a provider:
```env
EMAIL_EMULATOR=true
```
The emulator is forcibly disabled when `NODE_ENV=production` and requires the API to bind to a
loopback host. In `NODE_ENV=development`, captured messages are available from
`http://127.0.0.1:3100/api/dev/emails` with cache disabled; the API logs only delivery
metadata, never reset tokens. The inbox route is not registered in test, staging, or production.
### Logging
Backend services write structured logs to stdout. `LOG_LEVEL` is `debug`, `info`, `warn`, `error`,
or `off` (default `info`). Production defaults to `LOG_FORMAT=json`; development defaults to pretty
unless you set `json` or `pretty`.
Axiom is optional. Set both `AXIOM_TOKEN` and `AXIOM_DATASET` for ingest to one shared dataset.
Services set `service.name` (`evoo-api`, `evoo-worker`, `evoo-sandbox-supervisor`,
`evoo-updater`). A partial Axiom config logs a one-time warning and stays off. `AXIOM_EDGE` is a
regional hostname; `AXIOM_EDGE_URL` must be https and wins when both are set.
Compose passes these into the API, worker, supervisor, and updater. Computer containers and updater
child commands do not receive them.
Optional:
```env
SIGNUPS_ENABLED=true
SIGNUP_ALLOWLIST=you@example.com,@company.com
SANDBOX_PROVIDER=docker # or none, e2b, daytona, box. Keep fake only for pnpm test.
AGENT_RUNTIME=pi # Keep scripted only for pnpm test.
WAKEUP_DRIVER=graphile
SANDBOX_IDLE_MS=600000 # pause the bot computer after 10 minutes idle
SANDBOX_COMMAND_TIMEOUT_MS=300000 # stop a shell command after 5 minutes
MAX_TOOL_CALLS_PER_TURN= # optional Pi turn tool-call fuse; unset/0 = unlimited
E2B_API_KEY= # when SANDBOX_PROVIDER=e2b
DAYTONA_API_KEY= # when SANDBOX_PROVIDER=daytona
BOX_API_KEY= # when SANDBOX_PROVIDER=box
```
To use an operator-controlled OpenAI-compatible server such as Ollama, LM Studio, llama.cpp, or
MLX, list its model IDs and an endpoint that both the API and worker processes can reach:
```env
RAKAZO_LOCAL_MODELS=qwen3:4b,llama3.1:8b,qwen3-vl
RAKAZO_LOCAL_MODELS_URL=http://127.0.0.1:11434/v1
RAKAZO_LOCAL_CONTEXT_WINDOW=32768
RAKAZO_LOCAL_MAX_TOKENS=4096
# Optional: model ids on this endpoint that accept images (screenshot computer tools).
RAKAZO_LOCAL_VISION_MODELS=qwen3-vl
```
The loopback default is suitable when running evOo from a source checkout. From containers,
prefer a stable LAN RFC1918 address (not Compose service DNS alone). On Docker Desktop,
`host.docker.internal` also works.
On Docker Desktop, a bot computer shell can often reach services bound to host `127.0.0.1`
through that same hostname. Do not run sensitive unauthenticated services on loopback while
bots run, or firewall / block that path. Linux does not get `host.docker.internal` the same
way by default.
Only configure an endpoint you control: prompts, attachments, and tool results sent to that model
leave evOo through this URL. Leave `RAKAZO_LOCAL_MODELS` blank to disable the provider.
To run every space on these models without anyone connecting one, make them the deployment's
model, and optionally assign them to the tiers the agent delegates to. An entry can name its own
endpoint when the models run behind different servers:
```env
PI_DEFAULT_PROVIDER=local
PI_DEFAULT_MODEL=qwen3-vl # the main agent's model; defaults to the first id
RAKAZO_LOCAL_MODELS=qwen3-vl,llama3.1:8b,qwen3:4b=http://127.0.0.1:11435/v1
RAKAZO_LOCAL_TIERS=fast=qwen3:4b,standard=llama3.1:8b,code=qwen3-vl
```
A tier a space maps in **Settings → Models** still wins, and a tier left out keeps the run's model.
Each user can also connect their own OpenAI-compatible endpoint from **Connect a model** /
**Settings → Models** on web and mobile. Choose **OpenAI-compatible**, enter the server base URL
(for example `http://127.0.0.1:8000/v1`), the exact model id, and an optional API key.
Public hosts and ordinary hostnames need `RAKAZO_OPENAI_COMPAT_ALLOW_PUBLIC=1` and HTTPS.
Literal private IP, loopback, and `host.docker.internal` targets do not. If that endpoint's model
accepts images, enable **Supports images** under **Advanced** when connecting so attachments and
screenshot computer tools stay available. Existing connections default to disabled. For centrally
managed endpoints, the deployment-wide fallback remains
`RAKAZO_OPENAI_COMPATIBLE_VISION_MODELS=gpt4o-vision,llava`.
**Settings → Models → Tiers** maps the tiers fast, standard, and code to models of your
connections, per space. One router endpoint that serves several models (for example llama.cpp
behind a model router) can cover all three, because each tier offers every model the server
lists. A bot asks for a tier through its **Model** setting, and a helper can ask for one per
call. A tier with no model mapped falls back to the usual model choice.
For servers that accept standard `reasoning_effort`, enable **Supports thinking** under
**Advanced** when connecting. The setting is saved on the connection (no env var or restart).
Existing connections default to disabled. Reconnect former Qwen-list or deployment-local models
via **Settings → Models** and turn it on; the old environment list is no longer read.
Enabled connections default to medium thinking. Web and desktop expose **Thinking** in a bot's
advanced settings; mobile inherits the same backend policy. evOo sends standard
`reasoning_effort` (`minimal`, `low`, `medium`, `high`, or `none` when off); the server owns
model-specific translation. Leave **Supports thinking** off when the server lacks standard effort
support. Existing token limits still apply; effort is not a separate reasoning-token budget.
Do not commit `.env`. Never put `COMPOSIO_API_KEY`, OpenRouter keys, or provider tokens in git, logs, or chat.
Optional messaging platforms (iMessage, Slack, WhatsApp, Telegram, Feishu/Lark) mount when their env credentials are set — see `.env.example`. Point a Feishu/Lark bot event subscription at `/api/v1/messaging/webhook/lark` (webhook/HTTP inbound only; do not enable long connection). Groups stay iMessage-only.
## Choosing a computer provider
The Electron desktop app is a client of the same API. Docker and E2B still apply. On first launch, Electron asks the deployment owner whether bots should keep using Docker or run on this Mac as you. `SANDBOX_PROVIDER=desktop` is a separate, explicit provider that always runs commands on the service host.
- **Published images** (`docker-compose.images.yml`) default to `SANDBOX_PROVIDER=docker` with a
local supervisor and published `ghcr.io/elie222/rakazo/computer` image. No E2B account required.
Optional: set `e2b`, `daytona`, or `box` plus the matching API key for remote computers.
- **Docker** is the quick-start default for published images and for a source checkout / full local
Compose stack. Workspace bots share a persistent Team Computer by default; Private computers are
optional. Keep the supervisor private, as the included Compose files do.
- **E2B** runs bot computers away from the evOo host and is a good choice for public or multi-user
production deployments. evOo checkpoints the portable workspace and browser-profile directory to
`DATA_DIR`; the E2B disk is a runtime cache, not the durable source of truth.
- **Daytona** provides the same remote-computer contract through Daytona sandboxes. Configure
`DAYTONA_API_KEY` and optionally `DAYTONA_API_URL` / `DAYTONA_TARGET`.
- **Box by ASCII** provides a managed Linux desktop through `BOX_API_KEY` and optionally
`BOX_API_URL`. evOo always creates or resumes boxes with `noEnv: true`, keeps the portable
workspace under `/home/user/evoo-home`, and refreshes a two-hour TTL. Box uses the shared Linux
desktop runtime and protected port routes for concurrent bot desktops. Each bot has its own
persistent Chrome profile; logins are not shared between bots.
- **Desktop provider** / **This Mac** runs commands on the API/worker host. Docker stays the default.
The Electron app asks once; if you choose This Mac, bots can use working directories under your home
folder. Do not enable it on a public or shared service. macOS does not show its own permission
dialog for this.
- **Fake** is only an emulator for verification.
- **None** boots the product without a computer host (fallback when Docker/supervisor is not
configured, or when a remote provider is selected without its API key).
For provider configuration and health checks, see the [provider setup guide](./self-host-sandbox-providers.md).
## Backup
```bash
./scripts/backup.sh
```
This dumps Postgres (`pg_dump`) and archives `data/` into `backups/<stamp>/`. A missing
`data/` produces an empty archive; database or archive errors fail the backup. Discard the
output directory of any failed run.
## Public single-VM deployment
`infra/compose/docker-compose.prod.yml` runs the hosted product with Postgres, the API, worker, web app,
and automatic HTTPS through Caddy. It uses E2B for bot computers, so the VM never exposes a Docker
supervisor or browser containers. The root-equivalent updater sidecar is an explicit opt-in profile.
Before deploying to a new Ubuntu host, create and verify a key-only `deploy` account, then apply the
idempotent host-hardening baseline. It disables SSH passwords and root login, rate-limits SSH, allows
only SSH/HTTP/HTTPS through UFW, enables fail2ban, unattended security updates, AppArmor, audit rules,
and conservative kernel/network protections. Keep the provider console open until a fresh SSH login
succeeds after the script reloads SSH.
```bash
sudo DEPLOY_USER=deploy bash infra/compose/harden-host.sh
```
The production host also uses `infra/compose/docker-daemon.json` to enable live restore, bounded local
container logs, default no-new-privileges, and the kernel NAT path instead of Docker's userland proxy.
1. Point an `A`/`AAAA` record such as `app.example.com` at the VM and allow inbound TCP 80/443 and
UDP 443. If you use Cloudflare, enable the proxy with **Full (strict)** TLS and copy
`Caddyfile.cloudflare.example` to an operator-controlled path outside the public checkout. Set
`CADDYFILE_PATH` to that absolute path. The example drops application requests that do not come
from Cloudflare's [published IP ranges](https://www.cloudflare.com/ips/); reconcile those ranges
whenever Cloudflare publishes a change. A Cloudflare Tunnel can replace the public web listeners.
2. Clone the repository on the VM and create a root `.env` with production-only values. At minimum set
`POSTGRES_PASSWORD`, `BETTER_AUTH_SECRET`, `ENCRYPTION_KEY`, `SCREEN_PROXY_SECRET`,
`OPENROUTER_API_KEY`, the API key for your selected sandbox provider,
`RAKAZO_HOST`, and the three public origins. Set `RAKAZO_DEPLOY_DIR` when the checkout is not at
the supported Linux default, `/srv/evoo`. Use URL-safe random values for database credentials.
If you enable the `updater` profile, also set a dedicated `RAKAZO_UPDATER_TOKEN` (at least 32
characters) that differs from `BETTER_AUTH_SECRET`, `SANDBOX_SUPERVISOR_TOKEN`, and
`SCREEN_PROXY_SECRET`.
3. Keep registration allowlisted while the service is private:
```env
NODE_ENV=production
RAKAZO_HOST=app.example.com
# Optional operator-owned override, for example the Cloudflare allowlist file:
# CADDYFILE_PATH=/etc/evoo/Caddyfile.prod
BETTER_AUTH_URL=https://app.example.com
WEB_ORIGIN=https://app.example.com
API_URL=https://app.example.com
SIGNUPS_ENABLED=true
SIGNUP_ALLOWLIST=owner@example.com,reviewer@example.com
# e2b, daytona, or box
SANDBOX_PROVIDER=e2b
AGENT_RUNTIME=pi
WAKEUP_DRIVER=graphile
DATA_DIR=/data
# Absolute path of this checkout as the Docker daemon sees it. /srv/evoo is the Linux default;
# set this explicitly for every other layout. See "The deploy directory must be one path" below.
RAKAZO_DEPLOY_DIR=/srv/evoo
RAKAZO_IMAGE_TAG=local
# Optional: required only with `--profile updater`.
# RAKAZO_UPDATER_TOKEN=replace-with-32-plus-character-updater-token
```
4. Build the images from your checkout and start the stack, then verify its public health endpoint:
```bash
docker compose --env-file .env -f infra/compose/docker-compose.prod.yml \
build --build-arg GIT_SHA=$(git rev-parse HEAD)
docker compose --env-file .env -f infra/compose/docker-compose.prod.yml \
up -d --wait --pull never
curl --fail https://app.example.com/health
```
**Build, do not pull, for a first deployment.** `RAKAZO_IMAGE_TAG` ships as `local`, a tag no
registry serves, so the commands above build `api`, `worker`, and `web` from the checkout you just
cloned. The opt-in command under [Updater sidecar](#updater-sidecar) builds `updater` when needed.
Passing `GIT_SHA` is what makes `GET /health` report a `"revision"`; a locally built image has no
other way to know its commit. Prebuilt images from the registry bake it in at publish time, so when
you switch to a release tag you should leave `GIT_SHA` unset — a value in `.env` would override what
the image already knows.
Once a release has been published you can switch this host to prebuilt images by setting
`RAKAZO_IMAGE_TAG` to that release tag and running `pull` followed by `up -d --wait --pull never`.
See [Published images and tags](#published-images-and-tags) for the tag contract.
The root `.env` is excluded from both Git and the Docker build context. The database, application data,
and Caddy certificates live in named Docker volumes.
The production Compose file pins Postgres and Caddy to multi-architecture manifest digests, and the
published application/updater builds pin their base-image digests. Refresh those pins deliberately
when taking upstream security updates; changing only the visible major tag does not change the
content while a digest is present.
For the single-VM production layout, install `infra/compose/backup-prod.sh` as
`/usr/local/sbin/evoo-backup` and enable the supplied `evoo-backup.timer`. It creates a verified
Postgres custom-format dump plus an application-data archive under `/var/backups/evoo`, with mode
`0600` and seven-day rotation. These local snapshots help with operator mistakes but are not a
substitute for an encrypted off-host backup or provider snapshot.
The scheduled backup uses `/srv/evoo` by default. For another deployment directory, set
`RAKAZO_DEPLOY_DIR=/absolute/path/to/checkout` in a root-owned `/etc/evoo/backup.env`
(mode `0600`). The service reads this optional file on each run; the script uses the selected
checkout's `.env` and production Compose file. If the stack was started with a custom `-p`,
set the same `COMPOSE_PROJECT_NAME` in that file. For a manual run, export these variables instead.
When updating an existing backup installation, reinstall both the script and service unit,
then run `systemctl daemon-reload`.
## Restore
For backups created by `scripts/backup.sh`, use an empty `evoo` database in the development
Compose stack, with application services stopped. The SQL import runs in one transaction and
stops on the first error, including conflicts with existing tables. Files are restored and
application services started only after the import succeeds. This script does not consume the
production snapshot's custom-format `evoo.dump` or `appdata.tgz`.
```bash
./scripts/restore.sh backups/<stamp>
```
## Upgrade
A Compose deployment on a published release tag upgrades by moving that tag:
```bash
docker compose --env-file .env -f infra/compose/docker-compose.prod.yml pull api worker web
docker compose --env-file .env -f infra/compose/docker-compose.prod.yml \
up -d --wait --pull never api worker web
```
A deployment on the default `local` tag has no registry to pull from, so it upgrades by rebuilding
the checkout instead:
```bash
git pull
GIT_SHA=$(git rev-parse HEAD) docker compose --env-file .env -f infra/compose/docker-compose.prod.yml \
up -d --wait --pull never --build api worker web
```
`up --wait` does not report success until the new API is healthy and the worker and web containers
are running. The API's start command runs `prisma migrate deploy` before it serves, so migration
failure keeps health red. A failed CLI recreate does not auto-roll back; recover with the previous
`RAKAZO_IMAGE_TAG` (or rebuild `local`) and `up -d --wait --pull never`.
The updater sidecar has its own image and tag so an update never recreates the process performing
it. Move it deliberately by setting `RAKAZO_UPDATER_IMAGE_TAG` to the full `sha-<commit>` tag, then
running `docker compose … pull updater && docker compose … up -d --wait --pull never updater`.
Sidecar `/apply` and `/rollback` recover a failed recreate by redeploying the previously cached
image when possible; if that also fails, they report a possible mixed-version runtime.
Source checkouts (not Compose) still upgrade the old way: pull, rebuild with
`GIT_SHA=$(git rev-parse HEAD)`, run `pnpm --filter @rakazo/db migrate`, then restart API and worker.
Product contracts stay compatible across cloud and self-hosted.
### Space privacy-boundary migration
Before upgrading across migration `20260830200000_space_scope_names_and_user_credentials`, check
that every existing model and voice credential still belongs to a member of its Space. This query
uses the pre-migration `workspaceId` column name and must return no rows:
```sql
SELECT 'model' AS credential_type, credential."id", credential."userId",
credential."workspaceId" AS "spaceId"
FROM "user_model_credentials" AS credential
LEFT JOIN "space_members" AS membership
ON membership."spaceId" = credential."workspaceId"
AND membership."userId" = credential."userId"
WHERE membership."id" IS NULL
UNION ALL
SELECT 'voice', credential."id", credential."userId", credential."workspaceId"
FROM "user_voice_credentials" AS credential
LEFT JOIN "space_members" AS membership
ON membership."spaceId" = credential."workspaceId"
AND membership."userId" = credential."userId"
WHERE membership."id" IS NULL;
```
The migration renames columns used by the API and worker and is therefore a coordinated cutover,
not an online rolling migration. Stop the old API and worker, apply the migration, and start the new
versions together. Its lock waits are bounded so contention fails the migration instead of leaving
application traffic queued indefinitely.
### Published images and tags
`.github/workflows/publish-server-image.yml` publishes to `ghcr.io/<owner>/<repo>/…`, derived from
`${{ github.repository }}` rather than hardcoded, so a fork's CI fills the fork's own namespace. For
this repository that is:
| Image | Contents |
| --- | --- |
| `ghcr.io/elie222/rakazo/app` | api, worker, web, and sandbox supervisor — one image, multiple commands |
| `ghcr.io/elie222/rakazo/computer` | Linux desktop used as each bot computer |
| `ghcr.io/elie222/rakazo/updater` | the updater sidecar, plus the Docker CLI |
`infra/compose/docker-compose.images.yml` is the no-checkout path for those app and computer tags
plus Postgres. The supervisor runs from the app image on the internal network only (not a separate
published supervisor image, and no host port). Production Compose (`docker-compose.prod.yml`) can
also pull the same app tags once `RAKAZO_IMAGE_TAG` is set to a published value.
If you deploy from your own fork, set `RAKAZO_IMAGE` and `RAKAZO_UPDATER_IMAGE` to your namespace —
your CI cannot publish into someone else's.
| Tag | Published on | Moves? |
| --- | --- | --- |
| `local` | nothing — built locally by `up --build` | rebuilt in place |
| `local-<full-commit>` | nothing — built on the server by a fork update | never |
| `vX.Y.Z`, `vX.Y` | release tags | conventionally no / on patch releases |
| `latest` | stable `vX.Y.Z` tags only (not prereleases) | yes, to the newest stable release |
| `sha-<full-commit>` | every push and manual run | source-addressed; used by the updater sidecar |
| `edge` | pushes to main | yes, to the newest main build |
Every publish, including `edge` from main merges, is `linux/amd64` only for now: the image builds
natively on the Linux runner and one manifest is assembled per image. arm64 publishing is paused
until an arm64 runner with Docker buildx exists. Until a
stable `vX.Y.Z` has been published, GHCR may only have `edge` and `sha-*` tags; do not pin
`latest` unless that tag exists in the registry.
Building the images yourself does not need QEMU. `docker compose up --build` builds for the host's
own architecture, and a fork publishing multi-arch images should do what `publish-server-image.yml`
does: build each architecture on a native runner (GitHub Actions provides `ubuntu-24.04-arm` for
public repositories) and merge the digests into one manifest. QEMU emulation
(`docker/setup-qemu-action`, `binfmt`) still works if you have no native arm64 machine, but it is
many times slower, hours rather than minutes for the `computer` image.
The updater resolves the newest stable `vX.Y.Z` source tag but deploys its `sha-<full-commit>` image,
not `latest` or a moving minor tag. A registry tag is not an OCI digest and GHCR package writers can
replace it, so the trust boundary remains this repository's publishing credentials. The workflow
reduces that boundary by using SHA-pinned actions, read-only pull-request jobs, digest-pinned base
images, SBOM/provenance output, and a GitHub build attestation. Operators who require registry-level
content addressing can pin `RAKAZO_IMAGE` outside the automatic updater to a verified digest.
Rollback never contacts the registry: it redeploys the previous tag from the local Docker cache,
so a later tag move cannot change rollback content. Do not prune the previous application image
until the next update has been accepted. If it is missing, rollback fails closed instead of pulling
new content under an old tag.
To populate the registry the first time, run the workflow manually (`workflow_dispatch`) or push a
`v*` tag. A manual run produces `sha-<full-commit>`; only a stable `vX.Y.Z` tag (no prerelease
suffix) produces `latest`, and any `v*` tag produces semver tags. The updater ignores prereleases
and refuses the official path until a stable `vX.Y.Z` exists.
### Updater sidecar
Compose production deployments offer an opt-in `updater` profile on a private `control` network.
Normal deployments do not start it or require its credential. To enable it, set a dedicated
`RAKAZO_UPDATER_TOKEN` and explicitly start the profile:
```bash
docker compose --env-file .env -f infra/compose/docker-compose.prod.yml \
--profile updater up -d --build updater
```
It exposes `/health`, `/state`, `/plan`, `/apply`, and `/rollback` at `http://updater:7092` with
`RAKAZO_UPDATER_TOKEN`. Operator CLI upgrades above do not need it; the sidecar is for automated
apply/rollback over that private HTTP API.
The API cannot update itself — its image has no `.git`, and nothing inside the container would
restart it — so the work happens in a separate `updater` container that outlives the recreate:
- *Official repository:* resolves the newest stable release and its source commit with
`git ls-remote --tags`, pins the corresponding full `sha-<commit>` image tag in `.env`, keeps the
outgoing tag in `RAKAZO_IMAGE_TAG_PREVIOUS`, explicitly pulls the new image, then runs
`up -d --wait --pull never`. No build runs on the server.
- *Fork (Advanced):* a fork has no published images, so the sidecar fast-forwards the checkout in
`RAKAZO_DEPLOY_DIR` and runs `up -d --build`. This builds on the server and takes minutes rather
than seconds. Point it only at a fork you control and have reviewed — the sidecar runs that
Compose file through a root-equivalent Docker socket.
Updates and rollbacks run one at a time. A failed pull leaves running services alone; a failed recreate restores the previous environment
pin and attempts to redeploy the cached previous image. A failed fork build also restores the
pre-update branch and commit (including when checkout succeeded but merge did not) so a later
manual `--build` cannot deploy the rejected or unintended revision. Database migrations are not
reversed. The sidecar never recreates itself, never touches Postgres or Caddy, and never runs
migrations — that ordering belongs to the API start command.
Only `https://` and `ssh://` git remotes are accepted. Merges are fast-forward only. A dirty or
untracked source tree fails closed before anything runs (the application Dockerfile uses `COPY . .`).
### The deploy directory must be one path
`RAKAZO_DEPLOY_DIR` is bind-mounted into the updater at the same path it is read from
(`${RAKAZO_DEPLOY_DIR}:${RAKAZO_DEPLOY_DIR}`), and that is load-bearing rather than tidy. Production
Compose defaults both sides to `/srv/evoo`; set the variable for any other layout. When the
updater runs `docker compose -p <project> --file $RAKAZO_DEPLOY_DIR/infra/compose/docker-compose.prod.yml up -d`,
the Compose CLI *inside* the container expands this file's relative bind mounts — `../../.env`,
`./Caddyfile.prod` — against that path and hands the results to the daemon. The daemon has to be
able to resolve the same strings, or it silently creates empty directories where your `.env` and
Caddyfile should be. Compose makes the effective `-p` value available for interpolation but does
not automatically put it in a container's environment, so the production file explicitly assigns
`COMPOSE_PROJECT_NAME` to the updater. A standalone sidecar can instead set
`RAKAZO_COMPOSE_PROJECT_NAME`; the final fallback is `evoo-prod`. Without that propagation, a
stack started with `-p something-else` would be left alone while a second project with a new empty
Postgres volume came up beside it.
### Deployments that layer a Compose overlay
`RAKAZO_COMPOSE_FILE` takes a list, separated the way Compose's own `COMPOSE_FILE` is
(`:` by default, or whatever `COMPOSE_PATH_SEPARATOR` says). Each entry becomes its own `--file`,
in the order given, so the updater reconciles the same stack the operator runs by hand:
```
RAKAZO_COMPOSE_FILE=infra/compose/docker-compose.prod.yml:ops/compose/overlay.yml
```
Every entry is validated separately and must stay inside `RAKAZO_DEPLOY_DIR`.
If the overlay adds a service built from the application image, name it in
`RAKAZO_UPDATE_SERVICES` (comma separated) so it is pulled, recreated and rolled back with the
rest. Otherwise an update leaves that service running the previous code:
```
RAKAZO_UPDATE_SERVICES=supervisor
```
These names are appended to the built-in `api`, `worker`, `web`, never substituted for them, so no
value here can drop a core service from an update.
The value therefore has to be the path **the daemon** sees, which is not always the path your shell
sees:
- **Linux.** The daemon shares the host filesystem, so the checkout path is the answer:
`/srv/evoo` is the default and supported production layout. Set `RAKAZO_DEPLOY_DIR` explicitly
when the checkout is elsewhere.
- **Docker Desktop (Windows/macOS).** The daemon runs in a VM that mounts your drive somewhere else.
On Windows, `C:` appears at `/run/desktop/mnt/host/c`, so a checkout at `C:\Users\you\evoo` is
`RAKAZO_DEPLOY_DIR=/run/desktop/mnt/host/c/Users/you/evoo`. Host Git may use `core.autocrlf=true`; the updater ignores CR-only diffs so that does not block `/apply`. Verify the mount before deploying:
```bash
docker compose --env-file .env -f infra/compose/docker-compose.prod.yml \
--profile updater run --rm updater git -C "$RAKAZO_DEPLOY_DIR" log --oneline -1
```
That must print your checkout's HEAD. The two tempting wrong answers both fail: a native Windows
path is rejected by the daemon (`mount denied: … too many colons`, because the drive letter's
colon collides with the bind-mount separator), and `/mnt/c/...` fails *silently* — the container
starts, the mount is an empty directory, and the updater simply reports no checkout.
### The updater's privileges
The updater holds the Docker socket, which is root-equivalent on the host. It is scoped as narrowly
as that allows:
- No `ports`, so nothing is published on the host.
- Only on the dedicated `control` network shared with the API. Caddy is not attached, so the
reverse proxy has no route to the updater.
- Every route except `/health` requires the shared bearer token, compared in constant time.
- The process environment carries only updater settings (`RAKAZO_UPDATER_TOKEN`, deploy path,
image name, project name). Application secrets stay in the bind-mounted `.env` that Compose
reads for interpolation; they are not loaded into this container.
- The Docker CLI lives only in the updater image. The api, worker, and web containers keep
`cap_drop: ALL` and no socket.
Enabling the `updater` profile requires `RAKAZO_UPDATER_TOKEN` to be a dedicated random value (at
least 32 characters in production). It must differ from `BETTER_AUTH_SECRET`,
`SANDBOX_SUPERVISOR_TOKEN`, and `SCREEN_PROXY_SECRET`. Leave the profile disabled if you would
rather not grant the capability.
## Other deployment layouts
API and worker need always-on processes; serverless request handlers are not sufficient. Use a
Node.js version supported by the root `package.json`, Postgres 16 and a persistent `DATA_DIR` volume shared by API and worker, with encrypted off-host
backups. The current home store uses a local filesystem, so deployments on separate hosts need a
shared filesystem; an object-storage adapter is not available yet.
Use the same HTTPS origin for the web app, `/api`, and `/rpc`. Preserve the authenticated screen
proxy routes. Choose a [computer provider](#choosing-a-computer-provider) appropriate to the
service's trust boundary, and configure `SIGNUPS_ENABLED` and `SIGNUP_ALLOWLIST` before the API's
first start.
The optional marketing site in `apps/www` can be hosted separately.
## Connect mobile clients
The iOS and Android app can also point at a self-hosted origin at runtime. On the sign-in screen, tap **Use a custom server** and enter the same HTTPS origin as `WEB_ORIGIN` (for example `https://app.example.com`). Store builds still default to `EXPO_PUBLIC_API_URL`; the in-app setting is an override for people running their own API. Changing the server signs the device out of any previous session.