# Self-Hosting Roomote This guide covers the operator path for running one Roomote deployment. For day-to-day development, use `pnpm dev` from [README.md](README.md). For a containerized install, use the one-command installer or the Compose paths below. ## Deployment Modes | Mode | Command | Use case | | -------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------ | | One-command install | `curl get.roomote.dev \| sudo bash` | Fresh server, guided browser setup, published images (start here) | | Local development | `pnpm dev` | Fast source edits with PM2-managed local services | | Single-host production | `docker compose ... up -d --build` | Put web, API, controller, queues, preview proxy, and Caddy on one host | | Railway (PaaS) | Railway template | Managed platform deploy with hosted sandboxes; see [deploy/railway](deploy/railway/README.md) | | Render (PaaS) | Render Blueprint | Managed platform deploy with hosted sandboxes; see [deploy/render](deploy/render/README.md) | | Coolify (self-hosted PaaS) | Docker Compose resource | Run the published images on a Coolify-managed server; see [deploy/coolify](deploy/coolify/README.md) | | Fly.io (PaaS) | `flyctl` + maintained `fly.toml` | One Fly app with managed Postgres/Redis/storage and hosted sandboxes; see [deploy/fly](deploy/fly/README.md) | The application stack is web, API, controller, BullMQ, preview proxy, Postgres, Redis, and MinIO artifact storage. The production overlay adds Caddy as the HTTPS edge. ## One-Command Install SSH into a fresh Ubuntu or Debian server (x86_64 or arm64, 4 GB+ RAM recommended) and run: ```sh curl -fsSL https://get.roomote.dev | sudo bash ``` Prefer to read what you are about to run as root? Download, inspect, then run the same script: ```sh curl -fsSL https://get.roomote.dev -o install.sh less install.sh sudo bash install.sh ``` The installer brings up the full production stack from published GHCR images and prints a setup link like `https://roomote.203-0-113-7.sslip.io/setup?token=...`. Open it in a browser to configure sign-in and inference. Roomote then continues in a persistent setup conversation: source control is optional, integrations are offered after that decision, and repository-based starter work and automations appear only after repositories synchronize. A sandbox provider is required only when selected coding work needs to launch. Nothing else needs to be edited on the server. What the script does: - installs Docker Engine and the Compose plugin if missing; - generates every secret (`openssl` keypairs, encryption and signing keys, `DASHBOARD_PASSWORD`, and a one-time `SETUP_TOKEN`) into `/opt/roomote/.env`; - defaults the domain to `roomote..sslip.io`, which needs zero DNS setup and still supports HTTPS and flat wildcard preview subdomains; - installs a `roomote-compose` systemd unit so the stack survives reboots; - installs the `roomote` host CLI for day-2 operations. For production, bring your own domain instead of the sslip.io default (sslip domains share public Let's Encrypt rate limits and are tied to the host IP): ```sh curl -fsSL https://get.roomote.dev | sudo bash -s -- --domain roomote.example.com ``` Point `` and `*.` A records at the server first; the installer waits briefly for DNS and Caddy retries certificates until the records are in place. New installs publish flat preview hostnames by default. Pass `--preview-domain preview.` when you prefer the dedicated `task-port.preview.` layout, and point both that hostname and its wildcard at the server. ### Flat preview hostnames New one-command and `.env.production.example` installs use flat preview hostnames by default. Cloudflare's standard certificate covers `*.example.com`, but not `*.preview.example.com`, so this layout publishes `task-port-preview.example.com` without requiring an Advanced Certificate: ```sh ROOMOTE_APP_DOMAIN=example.com ROOMOTE_PREVIEW_DOMAIN=example.com PREVIEW_PROXY_BASE_URL=https://example.com PREVIEW_DOMAINS=example.com PREVIEW_PROXY_SUBDOMAIN_SUFFIX=preview ``` Roomote then publishes `task-port-preview.example.com`, which is covered by `*.example.com`. Point `*.example.com` at Caddy or your tunnel, and reserve the `-preview` suffix so it does not collide with other first-level subdomains. If your certificate provider supports `*.preview.example.com`, the dedicated `task-port.preview.example.com` layout remains an alternative. It keeps preview cookies within a dedicated preview namespace rather than sending them to other `example.com` subdomains. It is also the layout to choose when tasks need nested previews (a preview published by a Roomote instance running inside another task's preview): the preview proxy does not route nested hostnames when a subdomain suffix is configured, so flat layouts do not serve them. One Cloudflare Tunnel can serve both the app and preview hostnames through the same Caddy instance. Install Roomote with `--tls-mode internal` so Caddy issues local certificates for the private tunnel-to-Caddy hop, then configure both ingress rules with the matching origin TLS setting: ```yaml ingress: - hostname: example.com service: https://localhost:443 originRequest: noTLSVerify: true - hostname: '*.example.com' service: https://localhost:443 originRequest: noTLSVerify: true - service: http_status:404 ``` Replace `noTLSVerify: true` by configuring cloudflared to trust Caddy's local CA when you manage that trust chain yourself. The option applies only to the private tunnel-to-Caddy hop; Cloudflare still serves a publicly trusted certificate to browsers. In the Cloudflare dashboard, enable **Match SNI to Host** for the wildcard `*.example.com` public hostname. This sends the incoming preview hostname as the TLS server name to Caddy, so flat wildcard preview URLs such as `task-port-preview.example.com` reach the matching origin without 502 errors. ### Private networks and tunnels When a reverse tunnel or private network terminates public TLS before traffic reaches Roomote, use Caddy's internal certificates instead of requesting Let's Encrypt certificates: ```sh curl -fsSL https://get.roomote.dev | sudo bash -s -- \ --domain roomote.internal \ --tls-mode internal ``` `--tls-mode internal` skips public-DNS verification and configures Caddy to issue local certificates; it requires `--domain`. Direct private-network clients must trust Caddy's local CA, while clients behind a tunnel use the tunnel's public certificate. Use `--skip-dns-check` with the default `acme` mode only when you know public DNS is not ready yet. The selected mode is retained in `/opt/roomote/.env`, so installer reruns and `roomote upgrade` preserve it. ### Cloudflare Tunnel Cloudflare Tunnel works with the supported `internal` TLS mode: Cloudflare serves the public certificate and forwards encrypted traffic to Caddy on the private host. Configure the tunnel to send the app domain and wildcard preview traffic to Caddy, rather than directly to individual Roomote services, so artifact and API routes keep their normal behavior. Because Caddy issues a local certificate in this mode, configure the tunnel's HTTPS origin either to trust Caddy's local CA or to skip origin certificate verification: ```yaml originRequest: noTLSVerify: true ``` Use `noTLSVerify` only for the private tunnel-to-Caddy hop; Cloudflare still serves a publicly trusted certificate to browsers. Cloudflare's standard certificate for `example.com` and `*.example.com` covers the default flat `task-port-preview.example.com` preview shape. If you opt into the dedicated `task-port.preview.example.com` layout, provision a certificate that covers `preview.example.com` and `*.preview.example.com`, and configure the tunnel's wildcard hostname to reach Caddy. Task sandboxes reject private, link-local, and Tailscale ranges to protect the host network. If a self-hosted Gitea or GitLab hostname resolves differently inside Docker than it does on the host, tasks can fail to clone even though the service is reachable from the local PC. Give sandboxes a public, worker-reachable source-control hostname instead of disabling those network guards or overriding Docker DNS globally. Useful flags and env vars: `--version ` pins a release, and `--preview-domain ` overrides the preview root. Both published images are public, so pulls need no credentials. Re-running the installer is safe: existing secrets and the existing domain are preserved. Day-2 operations go through the installed CLI: ```sh roomote status # service health roomote logs [service...] # follow logs roomote setup-url # re-print the tokenized /setup link roomote upgrade [version] # upgrade or roll back by image tag roomote rollback # return to the release before the last upgrade roomote backup # encrypted recovery bundle in /opt/roomote/backups roomote restore --yes # restore configuration and data onto this host ``` The backup command prompts twice for a passphrase. It creates a versioned, encrypted `.roomote` bundle containing the PostgreSQL dump, `/opt/roomote/.env` (including encryption and signing keys), the Compose and Caddy configuration, local MinIO artifacts, schema metadata, and exact container image digests. Keep the passphrase outside the host in a secret manager. For unattended use, pass a root-readable file with `--passphrase-file`; do not put the passphrase on the command line. Add `--include-redis` when queued tasks, BullMQ schedules, sessions, and transient integration state must survive host loss. Without it, restore clears Redis so old transient state cannot conflict with the restored database. Backup establishes an application-quiesced consistency point: it stops web, API, controller, BullMQ, preview proxy, and active Docker task workers before dumping PostgreSQL and snapshotting local MinIO and optional Redis. Schedule it during a maintenance window. The Compose services restart when backup finishes; interrupted Docker tasks may be recovered by the normal orphaned-task flow. For external S3-compatible storage, the bundle records the endpoint and bucket but does not copy objects. Back up and restore that bucket using the storage provider before starting Roomote on the replacement host. To recover a completely new server, run the one-command installer first, copy the bundle onto the host, then run `roomote restore --yes`. Restore decrypts and verifies every bundled file before stopping the new installation, restores the original `.env`, repopulates empty data volumes, pins the recorded image digests, restores PostgreSQL, and starts the recorded release. A wrong passphrase, checksum failure, missing local-MinIO data, or unavailable image identity fails before restored services are started. `roomote upgrade` first creates an encrypted pre-upgrade backup bundle under `/opt/roomote/backups` (skip with `--skip-backup`; supply a passphrase with `--backup-passphrase-file` or `ROOMOTE_BACKUP_PASSPHRASE`, otherwise one is generated next to the bundle). It then pulls the new images and applies database migrations before replacing any running service. Migrations apply in a single transaction, so a migration failure rolls the schema back, restores the previous deployment configuration, and leaves the previous release running. Because every schema change must keep the previous release working (see the compatibility policy in `deploy/README.md`), a bad release can be undone with `roomote rollback`, which re-deploys the release recorded before the last upgrade without touching the database; the pre-upgrade bundle plus `roomote restore` is the last-resort path that also rewinds data. The running application and schema versions are visible under Settings -> Deployment -> Diagnostics. Use `roomote upgrade` instead of updating application image references alone. The upgrade refreshes the release's Compose and Caddy configuration together; mixing newer application images with an older Caddyfile can leave routes used by the new controller unavailable until the deployment configuration is also updated. `roomote upgrade` prunes old Roomote images after a successful upgrade. It keeps the current release plus the newest local Roomote image tags for a total of `ROOMOTE_IMAGE_RETENTION_RELEASES` retained tags (default `3`); set that environment variable before running the command when you want a larger or smaller rollback cache. Retention is best-effort: if Docker cleanup fails, the healthy upgrade remains in place and the command prints a warning. ### Changing your domain later The domain is baked into OAuth apps created during setup (GitHub App, Slack app, sign-in provider redirects). To move a deployment to a new domain: update DNS, set `ROOMOTE_APP_DOMAIN`, `ROOMOTE_PREVIEW_DOMAIN`, `PREVIEW_PROXY_SUBDOMAIN_SUFFIX`, and `TRPC_URL` in `/opt/roomote/.env`, run `roomote up`, then re-create or update the GitHub App and Slack app (both use manifest flows, so this is a few clicks) and update your sign-in provider's redirect URLs. ## Prerequisites - Docker Engine with Compose. - A stable public HTTPS app origin. - A model provider account and API key. - A GitHub App installed on the repositories Roomote should use. - At least one sign-in provider: Slack or Microsoft Teams. - Docker socket access for the default single-host Docker sandbox provider, or a hosted sandbox provider such as Vercel Sandbox or Modal. - Optional Slack and Linear apps if you want those integrations. For production-style use, also prepare: - A host with ports `80` and `443` reachable from the public internet. - An app domain, for example `roomote.example.com`. - Wildcard DNS for flat preview hostnames, for example `*.roomote.example.com`. A dedicated preview namespace such as `preview.roomote.example.com` and `*.preview.roomote.example.com` remains available when its certificate is provisioned separately. - A tested encrypted `roomote backup` schedule and an off-host copy of both the bundle and its separately stored passphrase. - A provider-level object backup when using external S3-compatible storage. ## Environment Files Use `.env.local` for local development and local self-host Compose: ```sh cp .env.local.example .env.local ``` Use `.env.production` for the Caddy-backed production overlay: ```sh cp .env.production.example .env.production ``` Do not commit real env files. Shell exports take precedence over values in these files. Naming follows three rules: Roomote-owned operator configuration uses the `R_*` prefix (`R_APP_URL`, `R_MODEL`, `R_GITHUB_*`, `R_SLACK_*`, `R_TELEGRAM_*`, `R_LINEAR_*`, `R_AUTO_GENERATE_KEYS`, …); conventional infrastructure names stay unchanged (`DATABASE_URL`, `REDIS_URL`, `S3_*`, `NODE_ENV`); and third-party provider credentials keep the provider's own names (`MODAL_*`, `E2B_*`, `DAYTONA_*`, `ANTHROPIC_API_KEY`, …). Remaining `ROOMOTE_*` names are internal plumbing or installer/upgrade machinery kept for compatibility — see "Environment Variable Naming" in [deploy/README.md](deploy/README.md) for the exceptions list. ## Public URLs Roomote needs one stable public app origin for auth callbacks, webhooks, and links sent from Slack or GitHub. For local development, set `R_PUBLIC_URL`: ```sh R_PUBLIC_URL=https://your-static-ngrok-domain.ngrok.app ``` If that URL is an ngrok domain, `pnpm dev` starts and reuses `ngrok http --url= 13000`. For production, set domains in `.env.production`: ```sh ROOMOTE_APP_DOMAIN=roomote.example.com ROOMOTE_PREVIEW_DOMAIN=roomote.example.com PREVIEW_PROXY_SUBDOMAIN_SUFFIX=preview TRPC_URL=https://roomote.example.com/_roomote-api ``` The production Compose overlay derives these runtime URLs: | Runtime key | Value | | -------------------------------- | ------------------------------------------ | | `R_PUBLIC_URL` | `https://$ROOMOTE_APP_DOMAIN` | | `R_APP_URL` | `https://$ROOMOTE_APP_DOMAIN` | | `TRPC_URL` | `https://$ROOMOTE_APP_DOMAIN/_roomote-api` | | `PREVIEW_PROXY_BASE_URL` | `https://$ROOMOTE_PREVIEW_DOMAIN` | | `PREVIEW_DOMAINS` | `$ROOMOTE_PREVIEW_DOMAIN` | | `PREVIEW_PROXY_SUBDOMAIN_SUFFIX` | `preview` for flat preview hostnames | On production Caddy, `ROOMOTE_APP_DOMAIN` serves both the web app and the worker-facing API. Caddy routes the explicit `/_roomote-api/*` prefix to `api:3001` after stripping that prefix, routes `/$S3_BUCKET_ARTIFACTS/*` directly to MinIO for presigned artifact uploads and downloads, and sends all other paths, including web auth, browser tRPC, Slack OAuth, and UI routes, to `web:3000`. After those values are in place, open any task's preview pane as an admin to confirm the detected runtime config and validate the wildcard preview hostname. Live previews are always enabled: they publish for every environment that defines preview ports once the runtime config is ready. ## Model Provider Task execution requires a models.dev-style model id: ```sh R_MODEL=openrouter/anthropic/claude-sonnet-4 OPENROUTER_API_KEY=... ``` Set `R_SMALL_MODEL` for routing, title generation, summaries, and other lightweight calls: ```sh R_SMALL_MODEL=openrouter/openai/gpt-4.1-mini ``` Set `R_VISION_MODEL` for image inspection. For coding tasks, Roomote configures a hidden OpenCode `visual` subagent only when this model differs from the effective coding model, and asks the parent agent to delegate screenshot, diagram, chart, rendered-document, and other visual inspection through it: ```sh R_VISION_MODEL=openrouter/openai/gpt-5.6-sol ``` Set `R_AUDIO_VIDEO_MODEL` for audio transcription and video descriptions, or choose the separate **Audio and video model** picker in **Settings > Models**. This role is independent of **Vision model** and defaults to **Same as coding model**, inheriting the coding model when unset. Choose a model that supports the audio and video inputs you use. Set `R_CODE_REVIEW_MODEL` when pull request and merge request review tasks, implementation judge passes, and other code-review-oriented analysis should use a different model from the active coding model. When unset, those review flows fall back to the default coding model: ```sh R_CODE_REVIEW_MODEL=openrouter/openai/gpt-5.6-sol ``` Set `R_EXPLORE_MODEL` when read-only codebase exploration through the OpenCode `explore` subagent should use a different model from the active coding model. When unset, exploration falls back to the task's active coding model: ```sh R_EXPLORE_MODEL=openrouter/openai/gpt-5.6-luna ``` The provider is the first segment of the model id. Roomote forwards these common provider keys into worker containers: - `OPENROUTER_API_KEY` - `AI_GATEWAY_API_KEY` (Vercel AI Gateway, `vercel/...` models) - `OPENAI_API_KEY` - `ANTHROPIC_API_KEY` - `MOONSHOT_API_KEY` - `KIMI_API_KEY` (Kimi for Coding, `kimi-for-coding/...` models) - `MINIMAX_API_KEY` - `DEEPSEEK_API_KEY` - `ZAI_API_KEY` (with `ZAI_REGION`: `global` or `china`, defaults to `global`) - `ZAI_CODING_PLAN_API_KEY` (Z.AI Coding Plan, `zai-coding-plan/...` models, with `ZAI_CODING_PLAN_REGION`) - `OPENCODE_API_KEY` - `OPENCODE_GO_API_KEY` (OpenCode Go subscription, `opencode-go/...` models) If your provider uses another env var name, list it in `R_MODEL_ENV_KEYS`: ```sh R_MODEL=custom-provider/custom-model R_MODEL_ENV_KEYS=CUSTOM_PROVIDER_API_KEY CUSTOM_PROVIDER_API_KEY=... ``` The checked-in Compose files forward the common provider keys above and the sample `CUSTOM_PROVIDER_API_KEY`. If you use a different custom provider key name in a Compose deployment, add that key to the service environment block or provide it through your deployment secret mechanism. ## Artifact Storage Roomote stores generated artifacts, plans, and visual proof uploads in S3-compatible object storage. The Compose stack includes MinIO and creates `S3_BUCKET_ARTIFACTS` during startup. Local development defaults to: ```sh S3_ENDPOINT=http://localhost:19000 S3_PRESIGN_ENDPOINT=http://localhost:19000 S3_REGION=us-east-1 S3_ACCESS_KEY_ID=roomote S3_SECRET_ACCESS_KEY=roomote-local-artifacts-password S3_BUCKET_ARTIFACTS=roomote-artifacts ``` When a local Docker worker asks the API for a presigned artifact URL through `host.docker.internal`, the API signs the URL with that host so the worker can upload without invalidating the S3 signature. Local self-host keeps both `S3_ENDPOINT` and `S3_PRESIGN_ENDPOINT` on `http://minio:9000` inside the Compose network. Production Caddy keeps `S3_ENDPOINT=http://minio:9000` for server-side access, but defaults `S3_PRESIGN_ENDPOINT` to `https://$ROOMOTE_APP_DOMAIN`; Caddy forwards `/$S3_BUCKET_ARTIFACTS/*` to MinIO without stripping the bucket path so Docker, Modal, and other hosted workers can all reach presigned artifact URLs. ## Sign-In Providers Roomote requires sign-in, but no sign-in provider is needed to get started: the installer's printed setup link acts as the first admin's invite, so you can create an email/password admin account immediately and configure single sign-on later. After that, bring in teammates with invite links from **Settings → Users** (invited users can also sign up with email and password), or configure one of these providers so your whole Slack workspace or Microsoft tenant can sign in: | Provider | Env vars | Redirect URL | | --------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------- | | Slack | `R_SLACK_CLIENT_ID`, `R_SLACK_CLIENT_SECRET` | `/api/auth/oauth2/callback/slack` | | Microsoft Teams | `R_MICROSOFT_CLIENT_ID`, `R_MICROSOFT_CLIENT_SECRET`, `R_MICROSOFT_TENANT_ID` | `/api/auth/oauth2/callback/microsoft-entra-id` | You can restrict access with: ```sh R_ALLOWED_EMAILS=you@example.com,teammate@example.com ``` ### Roles Users are either **admins** (manage deployment settings and users) or **members**. The first user to sign in becomes the founding admin; everyone after joins with the role their invite grants (member by default) until an admin changes it from **Settings → Users**. Admins can also remove users there: removal signs them out immediately, keeps their task history, and frees their email so they can be invited and sign up again later. Roomote refuses role changes that would leave the deployment without an admin, but if you ever lose admin access anyway (for example the only admin account is gone), promote a user directly in the database: ```sh docker compose exec postgres \ psql -U postgres -d roomote_development -c \ "UPDATE users SET role = 'admin' WHERE email = 'you@example.com';" ``` Slack sign-in can reuse `R_SLACK_CLIENT_ID` and `R_SLACK_CLIENT_SECRET` from the Slack integration app if you use one Slack app for both sign-in and bot events. The `/setup` Slack step and **Settings → Communications** are the preferred paths: paste a Slack app configuration token and click **Create Slack app**. Roomote creates the app through Slack's manifest API and saves the Client ID, Client Secret, and Signing Secret automatically. For an existing Slack app, or when production secret management owns the values, set those env vars directly instead. ## GitHub App A GitHub App is required. Roomote uses it to discover repositories, clone code, create branches, and open or update pull requests. For local development and local self-hosting, open `/setup`, choose GitHub, and click **Create GitHub App**; Roomote uses GitHub's manifest flow to create the app settings and save the generated credentials automatically. Use the same public URL for all GitHub settings: | GitHub setting | Value | | -------------- | ----------------------------------------------- | | Homepage URL | `` | | Setup URL | `/github/callback` | | Callback URL | `/github/callback` | | Webhook URL | `/_roomote-api/api/webhooks/github` | Check **Redirect on update** in the GitHub App's Post installation settings so repository access changes return to Roomote. Use manual env vars when you already have a GitHub App or when production/shared self-host secret management should own the values: ```sh R_GITHUB_APP_SLUG= # Optional comma-separated GitHub App slugs that are also trusted as Roomote-managed. R_GITHUB_ADDITIONAL_APP_SLUGS=, R_GITHUB_APP_ID= R_GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----" R_GITHUB_CLIENT_ID= R_GITHUB_CLIENT_SECRET= R_GITHUB_WEBHOOK_SECRET= ``` The detailed GitHub manifest and manual permission checklist is in [README.md#github-app-setup](README.md#github-app-setup). ## Slack Integration Slack integration is optional unless Slack is your sign-in provider or task entry surface. For local development and local self-hosting, open `/setup` or **Settings → Communications**, paste a Slack app configuration token, and click **Create Slack app**. Roomote creates the app with redirect, webhook, interactivity, scope, and event settings prefilled for your public URL and saves the credentials automatically. Workspace installation still happens later in the Slack connection step. For production self-hosting or manual review, use the same public URL for every Slack URL: | Slack setting | Value | | -------------------------- | ---------------------------------------------- | | OAuth redirect URL | `/api/slack/callback` | | Event Subscriptions URL | `/_roomote-api/api/webhooks/slack` | | Interactivity request URL | `/_roomote-api/api/webhooks/slack` | | Slack sign-in redirect URL | `/api/auth/oauth2/callback/slack` | Required integration env vars: ```sh R_SLACK_CLIENT_ID=... R_SLACK_CLIENT_SECRET=... R_SLACK_SIGNING_SECRET=... ``` `SLACK_APP_ID` is optional for manual preconfiguration. Roomote saves Slack's `app_id` from the OAuth installation response after the workspace install flow completes. The Slack manifest-prefill flow and manual scope list are in [README.md#slack-integration-app](README.md#slack-integration-app). To run the containerized stack on your own machine (without a public server), use the production compose overlay below with a local `.env.production` — it runs the same images in production mode with per-install secrets, rather than the source-checked-in development defaults. For fast source edits, use `pnpm dev` instead. ## Production Compose With Caddy Set DNS before starting: | DNS name | Target | | -------------------------- | ------------------- | | `ROOMOTE_APP_DOMAIN` | The deployment host | | `ROOMOTE_PREVIEW_DOMAIN` | The deployment host | | `*.ROOMOTE_PREVIEW_DOMAIN` | The deployment host | Generate fresh keys: ```sh openssl ecparam -name prime256v1 -genkey -noout -out job-auth-private.pem openssl pkcs8 -topk8 -nocrypt -in job-auth-private.pem -out job-auth-private-pkcs8.pem openssl ec -in job-auth-private.pem -pubout -out job-auth-public.pem base64 < job-auth-private-pkcs8.pem | tr -d '\n' # JOB_AUTH_PRIVATE_KEY base64 < job-auth-public.pem | tr -d '\n' # JOB_AUTH_PUBLIC_KEY openssl ecparam -name prime256v1 -genkey -noout -out preview-auth-private.pem openssl pkcs8 -topk8 -nocrypt -in preview-auth-private.pem -out preview-auth-private-pkcs8.pem openssl ec -in preview-auth-private.pem -pubout -out preview-auth-public.pem base64 < preview-auth-private-pkcs8.pem | tr -d '\n' # PREVIEW_AUTH_PRIVATE_KEY base64 < preview-auth-public.pem | tr -d '\n' # PREVIEW_AUTH_PUBLIC_KEY openssl rand -base64 32 # ENCRYPTION_KEY openssl rand -base64 32 # R_DISCORD_GATEWAY_SECRET openssl rand -base64 32 # ARTIFACT_SIGNING_KEY openssl rand -base64 24 # DASHBOARD_PASSWORD openssl rand -hex 16 # SETUP_TOKEN ``` If the deployment platform cannot run `openssl` during provisioning (for example PaaS platforms such as Railway or Render), leave the four `JOB_AUTH_*`/`PREVIEW_AUTH_*` values unset and set `R_AUTO_GENERATE_KEYS=true` instead. Roomote then generates the P-256 keypairs once at first startup, stores them encrypted with `ENCRYPTION_KEY` in the database, and reuses them on every later boot. Env-provided key values always take precedence. The random string secrets (`ENCRYPTION_KEY`, `R_DISCORD_GATEWAY_SECRET`, `ARTIFACT_SIGNING_KEY`, `DASHBOARD_PASSWORD`, `SETUP_TOKEN`) are still required and can come from the platform's secret generator. API and BullMQ must share the same `R_DISCORD_GATEWAY_SECRET` value when Discord is enabled. ### Port exposure and the queue dashboard Published container ports bind to `127.0.0.1` by default. This covers the datastores in `docker-compose.yml` (Postgres `15432`, Redis `16379`, MinIO `19000`/`19001`) and, in a standalone `docker-compose.self-host.yml` stack, the app ports (web `13000`, API `13001`, BullMQ `13002`, preview proxy `18081`). That keeps Postgres (default password), Redis (no auth), and the queue dashboard off other hosts. The production Caddy overlay below removes these published ports entirely and serves everything through Caddy on `80`/`443`. For a standalone self-host stack, front the web app with a reverse proxy on the same host (or add an explicit port override) rather than binding to all interfaces. The BullMQ `/admin` queue dashboard (Bull Board, with write access to every queue) is served **only when `DASHBOARD_PASSWORD` is set**, behind HTTP basic auth as user `admin`. This is gated on the password, not on `NODE_ENV`, so the `development`-flavored self-host services are still protected. With no `DASHBOARD_PASSWORD`, `/admin` returns `503` and the dashboard is disabled while the queue workers keep running. `PREVIEW_PROXY_BASE_URL` and `PREVIEW_DOMAINS` no longer block startup when unset: live previews simply report as not configured until you set the values via env or from the preview pane's admin setup on any task page. `SETUP_TOKEN` protects the pre-auth `/setup` bootstrap wizard, which admits the deployment's first admin. Until initial setup completes, the wizard rejects visitors who do not present the token via `/setup?token=` or the token entry step. **It is required on every non-local deployment** (any `R_APP_ENV` other than `development`): if it is unset, first-admin bootstrap stays closed and `/setup` blocks on the token step until you configure one — this prevents an attacker who reaches the URL before you from claiming the founding admin account. Only local development bootstraps without a token. Edit `.env.production`, then start: ```sh docker compose --env-file .env.production \ -f docker-compose.yml \ -f docker-compose.self-host.yml \ -f docker-compose.compute-docker.yml \ -f docker-compose.production.yml \ up -d --build ``` Caddy is the only public entrypoint in this overlay. It serves the app/API domain, the preview domain, and one-label preview subdomains. Its certificates and ACME account state live in the `caddy_data` and `caddy_config` Docker volumes. MinIO has no direct host port; Caddy only proxies the configured artifact bucket path for presigned S3 requests. Stop the stack: ```sh docker compose --env-file .env.production \ -f docker-compose.yml \ -f docker-compose.self-host.yml \ -f docker-compose.compute-docker.yml \ -f docker-compose.production.yml \ down ``` ## Compute Provider `DEFAULT_COMPUTE_PROVIDER=docker` runs task workers as sibling Docker containers on the same host. Include `docker-compose.compute-docker.yml` when using Docker sandboxes. That overlay: - builds the `DOCKER_WORKER_IMAGE` from `apps/worker/Dockerfile`; - mounts `/var/run/docker.sock` into the controller; - sets `DOCKER_WORKER_NETWORK=roomote_worker` as the trusted-service discovery network; each worker receives its own network containing only that worker, the API, and the optional preview proxy when enabled, never sibling tasks, Postgres, Redis, or MinIO; - enforces CPU, memory, PID, supported writable-layer quotas, and log limits and, under the default `internet` egress policy, blackholes private and cloud metadata ranges plus drops packets destined to the Docker bridge gateway (host hairpin) while still allowing public egress via that gateway as next-hop. The gateway drop automatically selects an nftables or legacy `iptables` backend supported by the host kernel; sandbox provisioning fails closed when neither backend is available; - points `DOCKER_WORKER_RELEASE_PATH` at the controller image's packaged worker release archive. Docker tasks fail closed when the host storage driver cannot enforce `DOCKER_WORKER_DISK_LIMIT`. Set `DOCKER_WORKER_ALLOW_UNBOUNDED_DISK=true` only when the host already provides an equivalent storage quota outside Docker. Docker sandboxes are the simplest single-host deployment path: ```sh DEFAULT_COMPUTE_PROVIDER=docker DOCKER_WORKER_IMAGE=roomote-worker:local # Optional: defaults to the host architecture (linux/amd64 or linux/arm64) DOCKER_WORKER_PLATFORM=linux/amd64 ``` If you do not want the controller to access the host Docker socket, choose a hosted provider instead and omit `docker-compose.compute-docker.yml`: ```sh # Modal DEFAULT_COMPUTE_PROVIDER=modal MODAL_TOKEN_ID=... MODAL_TOKEN_SECRET=... # Optional on published app images — see the worker image note below. MODAL_BASE_IMAGE_REF=... # E2B DEFAULT_COMPUTE_PROVIDER=e2b E2B_API_KEY=... E2B_TEMPLATE_ID=... # Daytona DEFAULT_COMPUTE_PROVIDER=daytona DAYTONA_API_KEY=... DAYTONA_SNAPSHOT_NAME=... # Blaxel DEFAULT_COMPUTE_PROVIDER=blaxel BL_API_KEY=... BL_WORKSPACE=... # Optional prebuilt Blaxel image override BLAXEL_IMAGE=sandbox/roomote-worker: # Box (preview; no setup-time worker artifact and no environment snapshots) DEFAULT_COMPUTE_PROVIDER=box BOX_API_KEY=... # Optional: BOX_API_BASE_URL, BOX_MACHINE_TYPE (small/default/large), # BOX_TIMEOUT_MS, BOX_STANDBY_MAX_COUNT, BOX_STANDBY_MAX_AGE_HOURS # Azure Container Apps (preview) DEFAULT_COMPUTE_PROVIDER=azure AZURE_SUBSCRIPTION_ID=... AZURE_RESOURCE_GROUP=... AZURE_SANDBOX_GROUP=... AZURE_SANDBOX_REGION=... AZURE_SANDBOX_DISK_IMAGE=... # Optional: client ID for a user-assigned managed identity (Azure-hosted # controllers) OR the service principal app/client ID when paired with # AZURE_TENANT_ID + AZURE_CLIENT_SECRET. Omit to use the ambient chain # (az login locally, system-assigned identity deployed). AZURE_CLIENT_ID=... # Optional (recommended for Docker installs): service principal auth — # all three together. az login inside containers is impractical, so # Docker/compose deployments should use this or a managed identity. AZURE_TENANT_ID=... AZURE_CLIENT_SECRET=... # Optional: pull credentials for baking the worker disk image from a private # registry (GHCR: token owner's GitHub username + PAT with read:packages) AZURE_SANDBOX_REGISTRY_USERNAME=... AZURE_SANDBOX_REGISTRY_TOKEN=... ``` Azure auth uses the ambient Azure credential chain instead of an API key: `az login` for local runs, or a managed identity (optionally user-assigned via `AZURE_CLIENT_ID`) when the controller itself runs in Azure. Containerized installs (docker compose, including `deploy/install.sh`) cannot use `az login` inside the container — configure the service principal triple (`AZURE_TENANT_ID`/`AZURE_CLIENT_ID`/`AZURE_CLIENT_SECRET`), created with `az ad sp create-for-rbac --name --skip-assignment` plus a `Container Apps SandboxGroup Data Owner` role assignment on the sandbox group, or run the controller on an Azure VM with a managed identity. One-time sandbox group bootstrap: `aca sandboxgroup create --name --location --set-config` — the calling principal is granted the Container Apps SandboxGroup Data Owner role automatically; grant it explicitly to any additional principal (for example a deployed controller's managed identity). Azure Container Apps sandboxes are in public preview, so expect API drift. `E2B_TEMPLATE_ID`, `DAYTONA_SNAPSHOT_NAME`, `BLAXEL_IMAGE`, and `AZURE_SANDBOX_DISK_IMAGE` can also be provisioned automatically during setup when a registry-qualified `DOCKER_WORKER_IMAGE` is configured — the setup wizard and the Settings → Sandboxes page build the worker base artifact in your provider account after credentials are saved. For quick-install and V1 deployer-managed hosts, leave `MODAL_BASE_IMAGE_REF` blank unless you need a custom Modal image. The installer and deployer sync it to the matching `DOCKER_WORKER_IMAGE` (the published worker image doubles as the Modal base image) and preserve a different non-empty value as an explicit override. When running the published `ghcr.io/roocodeinc/roomote-app` image, both `DOCKER_WORKER_IMAGE` and `MODAL_BASE_IMAGE_REF` are optional: if unset, the app derives `ghcr.io/roocodeinc/roomote-worker:` from the release version baked into the app image, which the publish workflow tags in lockstep with the worker image. Set `ROOMOTE_WORKER_IMAGE_REPO` to derive from a different repository (forks, registry mirrors). Explicit values always win, and local source builds (where no published tag exists) still require the variables as before. Modal can also use: ```sh MODAL_ENDPOINT=... MODAL_ENVIRONMENT=... MODAL_APP_NAME=... MODAL_ECR_OIDC_ROLE_ARN=... MODAL_ECR_REGION=... # Optional sandbox placement regions (comma-separated Modal tokens) MODAL_REGIONS=us ``` ## Declarative Environments Environments are normally created through the `/setup` wizard or Settings → Environments. Deployments that are managed as infrastructure as code can instead provision environments from declarative definitions at startup, without any human intervention: - `ROOMOTE_ENVIRONMENTS_DIR` — a directory of environment definition files (`*.yaml`, `*.yml`, or `*.json`), one environment per file. Mount it into the api container: ```yaml # docker-compose override for the api service volumes: - ./environments:/roomote/environments:ro ``` ```sh ROOMOTE_ENVIRONMENTS_DIR=/roomote/environments ``` - `ROOMOTE_ENVIRONMENTS_YAML` — one or more inline YAML documents (separated by `---`) in a single env var, for platforms where mounting files is awkward (Railway, Render, Fly, and similar). Each definition uses exactly the same YAML format as the environment editor's YAML view (`name`, `repositories`, `services`, `ports`, commands, and so on), so definitions copy/paste between the UI and the files. See [`.roomote/environments/roomote.yaml`](.roomote/environments/roomote.yaml) in this repository — Roomote's own environment definition — for a complete working example. Semantics: - The definition set is applied on every API startup. The environment name is the identity key: missing environments are created, existing ones are updated. Identical re-applies are no-ops. - **The declarative definition wins.** Environments provisioned this way stay editable in the UI (they show a "Managed from file" badge), but edits are overwritten the next time the deployment restarts and re-applies the file. Every overwrite is recorded in the environment's config version history. - **Nothing is ever deleted.** Removing a definition returns its environment to normal manual management; it keeps all data and loses only the badge. - Renaming `name` inside a definition creates a new environment under the new name and orphans the old one. - Definitions may reference repositories that are not linked to the deployment yet (for example before the GitHub App is installed). The environment is created anyway and repository mappings backfill on the next startup after the repositories are linked. - Invalid definitions are skipped with a logged error; they never prevent the API from starting or other definitions from applying. A missing or unreadable definitions directory is skipped the same way, so inline `ROOMOTE_ENVIRONMENTS_YAML` definitions still apply. While any definition fails to read or validate, the "removed definition" reconciliation above is deferred, so a temporarily broken file never strips its environment's managed marker. - Keep secrets out of definition files: the per-environment `env` map is stored in plaintext. Use deployment environment variables or Settings → Environment Variables for secret values. If you keep the definitions in a git repository, have your deploy pipeline check it out and mount the directory (or inject the content into `ROOMOTE_ENVIRONMENTS_YAML`). Roomote deliberately does not fetch remote config itself. ## Verification Checklist After startup: 1. Open the public app URL. 2. Sign in with the configured provider. 3. Visit `/setup` and confirm GitHub is connected. 4. Confirm repositories appear in the environment picker. 5. Start a small task, such as asking for the current time. 6. Open the task terminal and logs panes. 7. If Slack is configured, mention the app in Slack and confirm it creates or resumes a task. 8. Run `pnpm run doctor` from the repository root when testing locally. ## Operations Logs: ```sh docker compose --env-file .env.production \ -f docker-compose.yml \ -f docker-compose.self-host.yml \ -f docker-compose.compute-docker.yml \ -f docker-compose.production.yml \ logs -f web api controller bullmq preview-proxy caddy ``` Restart one service: ```sh docker compose --env-file .env.production \ -f docker-compose.yml \ -f docker-compose.self-host.yml \ -f docker-compose.compute-docker.yml \ -f docker-compose.production.yml \ restart controller ``` Upgrade: ```sh git pull --ff-only pnpm install docker compose --env-file .env.production \ -f docker-compose.yml \ -f docker-compose.self-host.yml \ -f docker-compose.compute-docker.yml \ -f docker-compose.production.yml \ up -d --build ``` Backups should include at minimum: - The Postgres database or `postgres_data` volume. - The Redis volume if you need queue/session recovery across host loss. - The MinIO `minio_data` volume, or the external object store bucket if you replace bundled MinIO. - The `gbrain_data` volume AND the `gbrain` Postgres database when the Brain is enabled. The volume holds the memory system of record (markdown files); the database holds the index, extracted facts, and durable jobs — and the volume's storage-layout marker means gbrain will not rebuild the database on its own after a restore, so both must travel together (`roomote backup` bundles both). - `.env.production` in your secret store. - Caddy volumes `caddy_data` and `caddy_config`. - Any Docker worker containers you intentionally keep for debugging are ephemeral task runtime state, not source-of-truth data. ## License And Seats Roomote is licensed under the Fair Core License 1.0 (FCL-1.0-ALv2, see [LICENSE](LICENSE)). A deployment is **free for up to 10 users** — every registered user account counts toward the limit, whichever sign-in path or surface it uses. Removing a user frees their seat. To go beyond 10 users, [contact Roomote for a self-hosted license](mailto:help@roomote.dev) and apply its key in either of these ways: - Enter it in **Settings → Users → License** as an admin, or - Set `R_LICENSE_KEY` in the deployment environment (for example `.env.production` / Compose). When set, the env var takes precedence over any key stored in Settings. Purchased keys are issued annually. While the subscription is active, refresh the key in your existing license account and replace the value in Settings or `R_LICENSE_KEY` before its displayed expiry date. Keys are verified offline; usage reporting does not activate or validate them. Licensed deployments report the license ID and current registered-user count to Roomote's Ping service for subscription billing, even when anonymous analytics is disabled. Other instance statistics remain controlled by the anonymous analytics setting. Contact [help@roomote.dev](mailto:help@roomote.dev) for teams larger than 100 users. When a deployment is at its seat limit, existing users are unaffected — only new sign-ups are blocked until a seat is freed (Settings → Users → remove a user) or a license key with more seats is added. Per the license terms, the license key functionality may not be disabled or circumvented. ## Current Limits - The first supported production shape is one self-managed host. - Docker sandboxes are supported for that single-host shape. It is not a multi-host scheduler and it relies on trusted controller access to the host Docker socket. - Production monitoring, backup automation, and secret rotation are operator responsibilities. - Hosted preview/prod deployment automation is not part of this repository yet.