# Self-hosting Oya Browser Everything the wizard asks about, and everything it writes. For day-two operations, multi-replica, governance, session lifecycle, roles and recovery, see [control-plane.md](control-plane.md). ```bash curl -fsSL https://raw.githubusercontent.com/OyadotAI/oya-browser/main/install.sh | sh ``` It needs git, Docker (running) and Node 20+, and tells you which one is missing. It clones the repo into `~/oya-browser` (set `OYA_DIR` to change that) and starts the wizard there. Arguments pass through: `... | sh -s -- --dry-run`. From a checkout you already have, `make wizard` does the same. ## Updating ```bash curl -fsSL https://raw.githubusercontent.com/OyadotAI/oya-browser/main/update.sh | sh ``` For an install on Docker Compose (what `install.sh` sets up by default). It pulls the latest code into `~/oya-browser` (or `OYA_DIR`), rebuilds the server and browser images (and the governed browser image, `oya-browser:local`, when there is one), restarts everything with the settings already in `.env`, keeps the number of browser workers running now, and waits for `/readyz`. It asks nothing. Already up to date, it says so and stops; `... | sh -s -- --force` rebuilds and restarts anyway. If the checkout has local edits it stops before changing anything and says how to move them aside, so an update never quietly leaves the old code running. Governed browser containers already running finish their sessions on the old image; new sessions use the new one. Kubernetes fleets pin the browser image by digest at install, so run `install.sh` again to move them. No questions at all, for agents, CI or anyone who wants the default: ```bash curl -fsSL https://raw.githubusercontent.com/OyadotAI/oya-browser/main/install.sh | sh -s -- --yes ``` That is SQLite, the server and one browser worker in Docker on this machine, at `http://localhost:3100`. An LLM is set up only if `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` is in the environment. It ends with `OYA_BASE_URL=` and `OYA_API_KEY=` lines. With no terminal to ask on (an agent's shell), `install.sh` adds `--yes` itself; `OYA_YES=1` forces it. On macOS it starts Docker Desktop if it is installed but not running. Six questions, then it writes the config, builds the images, brings the stack up and waits for `/readyz` before telling you it worked. It ends by printing an API key. ``` ? Where should the control plane run? › Docker on this machine ? Which database? › SQLite (zero config, one replica) ? Where should browsers run? › Docker browser workers here ? Which LLM should agents use? › Anthropic (Claude) ? Public URL of this control plane? › http://localhost:3100 ? Configure optional services now? › No ✔ wrote .env waiting for /readyz… ready ✔ http://localhost:3100 is up. ``` `--dry-run` shows the plan and writes nothing. Every answer is saved to `oya-install.json`, no secrets, so `make wizard ARGS="--config oya-install.json"` reproduces the same deployment without prompting, which is the CI path. Credentials come from the environment there. The manual route still works if you would rather write `.env` yourself: copy [`server/.env.example`](../server/.env.example) and run `docker compose up`. ## Try it ```bash npm install -g @oya-ai/cli # The wizard printed a key. Your shell may export OYA_API_KEY for the hosted # service, and an exported value beats a saved one: so clear it for a local stack. unset OYA_API_KEY oya login --url http://localhost:3100 --key oya ls # the browser workers that enrolled oya goto https://example.com # drive one oya status # health, commands, what it has been doing ``` Or open `http://localhost:3100` and sign in with the same key. `docker compose down` stops everything; the `oya-data` volume holds your personas and cookies, so keep it. ## What you can choose | | Options | |:---|:---| | Control plane | Docker. For production on one Docker host, Amazon ECS, Kubernetes or Google Cloud, see [`deployments/`](../deployments): each has a `deploy.sh`. | | Database | `OYA_STORAGE`: SQLite (default), Postgres (`DATABASE_URL`, Supabase's included) or JSON files. | | Browsers | Docker workers, governed Docker (one container per session), a Kubernetes fleet (one pod per session), Oya Cloud, Browserbase, Steel, Anchor, Browser Use, or your own Chrome over CDP. | | LLM | Anthropic, OpenAI, Gemini, Gemini Enterprise (ex-Vertex AI), any OpenAI-compatible endpoint, or a local model (Ollama, vLLM, LM Studio). | Options the wizard cannot yet finish are listed and dimmed rather than hidden, so the menu never promises something that does not work. ## Databases `OYA_STORAGE` picks where every table lives (personas and their cookies, credentials and MFA factors, API keys, settings, usage, the audit trail and the control plane): | `OYA_STORAGE` | Where | Replicas | |:---|:---|:---| | `sqlite` (default) | `storage.sqlite` and `control.sqlite` in `OYA_DATA_DIR` | one | | `postgres` | the `DATABASE_URL` database | many | | `file` | a JSON file per table in `OYA_DATA_DIR/storage`, readable by hand | one | `postgres` needs `DATABASE_URL`, and `DATABASE_URL` needs `OYA_STORAGE=postgres`: the server refuses to start with one and not the other, so a deployment can never write to local files while it thinks it is on Postgres. Supabase is only ever a Postgres here; point `DATABASE_URL` at its connection string. Pick Postgres in the wizard and it applies the schema for you; to run it by hand: ```bash DATABASE_URL=postgres://user:pass@host:5432/oya make migrate ``` Migrations are tracked in `public.schema_migrations`, applied one transaction per file together with their own bookkeeping, and safe to re-run. The container applies them on every start. On plain Postgres the accounts tables (profiles, and the policies keyed on Supabase Auth users) are skipped, so authentication is `API_KEYS` and the dashboard takes an API key instead of an email. Upgrading: data files from earlier versions (`personas.json`, `cookies.json`, `credentials.json`, `mfa.json`, `key-settings.json`, `config.json`) are read into the configured storage on first start, seeds and all, and renamed `.imported`. A row already in storage wins over the file. **A Postgres deployment must add `OYA_STORAGE=postgres` before upgrading**; the templates in `deployments/` set it. ### Audit trail Every audit row is linked to the one before it by an HMAC (`OYA_AUDIT_HMAC_KEY`), and the server checks the links on start. Three things make that evidence rather than a convention: - **Anchors.** Set `OYA_AUDIT_ANCHOR_BUCKET` to a private Supabase Storage bucket and every 15 minutes (`OYA_AUDIT_ANCHOR_INTERVAL_MS`), and at shutdown, each replica adds the head of its chain to it, sealed with the chain key, never overwriting an object. On start the newest anchor of every chain from the last 30 days is compared with the database, so rows cut from the end of a chain, or a chain deleted whole, are reported. This needs `SUPABASE_URL` and `SUPABASE_SERVICE_KEY`. **The bucket must refuse overwrite and delete to the server's key**, and a storage policy cannot do that: the service role key bypasses them. `deployments/sql/audit-anchor-bucket.sql` creates the bucket and a trigger on `storage.objects` that refuses both, whoever asks. For anchors that outlive the Supabase project, replicate the bucket to S3 or GCS with a retention lock. A bucket the server can delete from proves nothing. - **An insert-only role.** `deployments/sql/audit-insert-only.sql` creates the role the server should sign in as: everything it needs, but only `INSERT` and `SELECT` on `audit_log`, which stays owned by another role. Run it once as the owning role, then point `DATABASE_URL` at the new role and `OYA_MIGRATE_DATABASE_URL` at the old URL, so migrations still run as the owner. The script's header has the steps. - **The verdict.** `GET /operator/audit/verify` (operator token) answers the same check on demand; `?project=` also checks that project's control event chain. When storage cannot keep up, audit rows go to `data/audit-overflow.jsonl` instead of being dropped. Load them with `node src/platform/audit-import.ts [file]` from the server directory, with the server's environment; it is safe to run twice. Control events (`control_events`) are chained too, per project, inside the database. That chain is plain SHA-256 and is not anchored: it finds edited events and ones removed from the middle, not a writer who recomputes every link after an edit. ## Configuration Everything is optional except the secrets you want to survive a restart. [`server/.env.example`](../server/.env.example) documents all of it; the ones that matter most: | Variable | Description | |:---|:---| | `OYA_PROFILE_SECRET` | Key for encrypting cookies, tokens and TOTP seeds at rest (AES-256-GCM). Left unset, the server generates one into the data volume, so keep that volume, or every stored credential becomes unreadable. | | `API_KEYS` | Comma-separated tenant keys. The whole control plane works with nothing but these. | | `OYA_OPERATOR_TOKEN` | Bearer token for `/metrics`, fleet drain and host config. | | `OYA_STORAGE` | `sqlite` (default), `postgres` or `file`: where every table is kept. See [Databases](#databases). | | `DATABASE_URL` | The Postgres connection string, for `OYA_STORAGE=postgres`. | | `SUPABASE_URL` / `SUPABASE_SERVICE_KEY` | Email, Google and GitHub sign-in through Supabase Auth. Sign-in only, and it needs `OYA_STORAGE=postgres` with `DATABASE_URL` at the project's Postgres. | | `TURNSTILE_SITE_KEY` / `TURNSTILE_SECRET_KEY` | A Cloudflare Turnstile captcha on email sign-in and sign-up. The console shows the widget with the site key; the server checks each token with the secret, and lets requests through if Cloudflare cannot be reached. Unset means no captcha. Google and GitHub sign-in also need this deployment's `/auth/callback` URL in the Supabase project's redirect allowlist. | | `OPENAI_API_KEY` / `OPENAI_BASE_URL` / `CHAT_MODEL` | The deployment-wide LLM default. Any API key that sets its own overrides it. A private or `http://` base URL works here but is rejected from the dashboard, because tenants can set that field too. | | `OYA_FLEET_RUNTIME` | `docker` or `k8s`, what starts a governed browser. | | `OYA_MANAGED_*` | The governed runtime: network or NetworkPolicy, image, control URL, egress proxy. On Kubernetes the image must be digest-pinned; a tag can move between verification and scheduling. | | `OYA_CLOUD_API_KEY` / `OYA_CLOUD_SNAPSHOT` / `OYA_PUBLIC_WS_URL` | Oya Cloud sandboxes on Daytona, and the public URL every cloud browser dials back to. | | `OYA_CLOUD_RUNTIME` | What runs Oya Cloud browsers: `daytona` (default), `docker`, `k8s` or `ecs`. See [Cloud browser runtimes](#cloud-browser-runtimes). | | `OYA_RESIDENTIAL_PROXY_URL` | A residential vendor gateway every Oya Cloud browser uses unless its persona has its own proxy. Never sent to desktop browsers, which could extract the credentials. `{session}` and `{geo}` in the username become a sticky per-persona session and its country. Traffic is counted in the sandbox, both directions, and metered per key as `residential_proxy_bytes`. | | `POSTHOG_KEY` / `POSTHOG_HOST` | Product analytics. Both must be set or nothing is sent; there is no default host. The key is a PostHog project write token, which the console shows to every visitor; never a personal PostHog key. | | `SLACK_OPS_WEBHOOK_SIGNUPS` / `SLACK_OPS_WEBHOOK_EVENTS` | Slack incoming webhooks for one-line ops messages: signups, keys, desktop downloads and desktop connections in the first; saved playbooks, CDP attaches and server errors in the second. Unset means no message. | | `SLACK_OPS_WEBHOOK_PRODUCT` | A Slack incoming webhook for product cards: one short block per event worth reading (event, who, what happened, when), such as a sign-up and how it was made, a key's first browser, a session of a minute or more and how long it ran, a playbook saved or replayed, a desktop installed or updated, and server errors. Routine events (update checks, reconnects, MCP tool calls) never post. Unset means no card. | | `RB2B_ID` | An RB2B account id. Loads RB2B's visitor identification on the public pages (landing, docs, sign-in, sign-up), never on the console or the live view. Unset means it never loads. | | `META_PIXEL_ID` | A Meta pixel id. Loads Meta's ad pixel on the same public pages, counts their page views and reports each sign-up as `CompleteRegistration`. Never on the console, the live view, the admin page or a claim link. Unset means it never loads. | ## Cloud browser runtimes Oya Cloud browsers (`provider: 'oya-cloud'`) run the image built from [`browser/Dockerfile`](../browser/Dockerfile) on one of four runtimes. Every runtime gets the same browser, labels and enrolment environment; the browser dials back to `OYA_PUBLIC_WS_URL` over the normal WebSocket, so no port is ever opened into it. | `OYA_CLOUD_RUNTIME` | Settings | Stops an abandoned browser | |:---|:---|:---| | `daytona` | `OYA_CLOUD_API_KEY`, `OYA_CLOUD_SNAPSHOT` (a snapshot of the browser image), optional `OYA_CLOUD_API_URL`, `OYA_CLOUD_TARGET` | Idle stop after `OYA_CLOUD_SANDBOX_TTL_MINUTES` (default 60), hard TTL 10 minutes later | | `docker` | `OYA_CLOUD_IMAGE`, optional `OYA_CLOUD_DOCKER_NETWORK`, and `OYA_CLOUD_DOCKER_PLATFORM` (e.g. `linux/amd64`) for an image with no build for the host's architecture. Uses the daemon the server's `docker` CLI reaches; pull the image first. | Hard lifetime of TTL + 10 minutes; the container is removed when it exits | | `k8s` | `OYA_CLOUD_IMAGE`, optional `OYA_K8S_NAMESPACE` (default `oya-browsers`) and `OYA_K8S_CONTEXT`. One pod per browser; its credentials sit in a Secret the pod owns. | `activeDeadlineSeconds` of TTL + 10 minutes | | `ecs` | `OYA_ECS_CLUSTER`, `OYA_ECS_TASK_DEFINITION` (whose container, `OYA_ECS_CONTAINER`, default `browser`, runs the image), `OYA_ECS_SUBNETS`, optional `OYA_ECS_SECURITY_GROUPS`, `OYA_ECS_ASSIGN_PUBLIC_IP=true`, `AWS_REGION`, and `OYA_ECS_AUTH` (below). One Fargate task per browser. | Hard lifetime of TTL + 10 minutes | Only Daytona has an idle stop; the others stop at the hard lifetime whether idle or not. **ECS authentication** (`OYA_ECS_AUTH`): | Value | Settings | Signs in with | |:---|:---|:---| | `default` (unset) | none | The AWS SDK's default chain: env keys, `AWS_PROFILE` (SSO profiles included), or an instance, task or IRSA role. | | `iam` | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, optional `AWS_SESSION_TOKEN` | An access key. | | `role` | `OYA_ECS_ROLE_ARN`, optional `OYA_ECS_EXTERNAL_ID` | The role, assumed from the default chain's identity and refreshed before it expires. | | `sso` | `OYA_ECS_SSO_PROFILE` | IAM Identity Center, through a profile in `~/.aws/config`, after `aws sso login` on the server's host. | **Per key.** A key can pick its own runtime, and for ECS its own account, through the SDK or `oya settings`: ```ts await oya.config.set({ sandbox_runtime: 'ecs', ecs: { cluster: 'browsers', taskDefinition: 'oya-browser', subnets: ['subnet-0abc'], securityGroups: ['sg-0abc'], assignPublicIp: true, region: 'us-east-1', auth: { type: 'role', roleArn: 'arn:aws:iam::123456789012:role/oya-browsers' }, }, }); ``` `ecs.auth` is one of: - `{ type: 'iam', accessKeyId, secretAccessKey, sessionToken? }` - `{ type: 'role', roleArn }`. The server assumes the role from its own AWS identity. The role's trust policy must trust the server's AWS account, with `sts:ExternalId` set to `config.get().ecs.externalId`. That value is generated per API key and can't be set, so one key can't make the server assume a role another customer created. It changes if the API key changes. - `{ type: 'sso', accessToken, accountId, roleName, ssoRegion? }`. An IAM Identity Center access token, exchanged for that role's credentials as they expire. The token itself expires (typically 1 to 12 hours). After that, starts fail with `sso_token_expired` until a fresh token is set. A key can't name an SSO profile, because that would use this host's own SSO sessions. `config.get()` returns `ecs` with its credentials masked. Sending it back unchanged keeps the stored credentials. With `ecs.auth` set, the key's ECS browsers run on its own account, and none of the deployment's ECS or AWS settings are mixed in. A key that sets `daytona_api_key` runs on its own Daytona account in the same way. Without these, a key runs on the deployment's account, and any cluster it names is ignored. `docker` and `k8s` run on the deployment's own daemon or cluster, so a key can choose them but can never set their image, host, namespace or kubeconfig. On ECS the browser's environment, which includes the key's Oya API key, is visible to anyone in that AWS account with `ecs:DescribeTasks`, the same as a Daytona sandbox's env vars are to its account. ## License and scale A self-hosted server runs up to 5 cloud browsers (browsers it launches in Docker, Kubernetes, ECS or Daytona) at once for free. The sixth start is refused with a 402 that says so. The desktop app and browsers you connect yourself are not counted. For more, write to **sales@getoya.ai** for a license key and set it as `OYA_LICENSE_KEY`. See the [license](../LICENSE.md). ## Telemetry **The daily ping.** A self-hosted server sends Oya one small report a day to `https://oyabrowser.com/api/telemetry/ping`: a random install id it makes once, the version, how many browsers are connected, the most cloud browsers it ran at once since the last ping, and the license id if it has one. Nothing else: no addresses, keys, users, URLs or page content. It cannot be turned off; it is a condition of the [license](../LICENSE.md). A ping that fails is dropped and changes nothing. Apart from that ping, the server sends nothing to anyone unless you set the variables below; the CLI and SDK never send anything, and the desktop app tells only the server it connects to its version (when it connects and when it checks for an update). With `POSTHOG_KEY` and `POSTHOG_HOST` both set, it reports product events to that PostHog: `account_signed_up`, `api_key_created`, `browser_started`, `browser_stopped`, `playbook_saved`, `playbook_replayed`, `mcp_tool_called`, `cdp_attached`, `desktop_connected` (with the app version), `desktop_updated`, `download_served`, `update_checked`, `persona_created` and `server_error`, each with a few properties such as provider, step count, platform, version or the error reference. Downloads and update checks are counted under a fingerprint of the address and user agent, never the address itself, and create no person. The console reports pageviews, time on page, and on public pages the clicks it tags (download and call-to-action buttons, by name only). With `SLACK_OPS_WEBHOOK_SIGNUPS` or `SLACK_OPS_WEBHOOK_EVENTS` set, it posts one Slack line per signup, key created, desktop download, first desktop connection, playbook saved, CDP attach and server error; with `SLACK_OPS_WEBHOOK_PRODUCT` set, a short card for those and for a key's first browser, sessions of a minute or more, replays and desktop updates. Visited URLs, page content, cookies, API keys, key labels, persona and playbook names never leave the server this way; the sign-up email goes to PostHog's identify call and the Slack lines, nowhere else. Sending is best effort and never delays a request. Unset the variables and it stops. Health: `/livez` is liveness, `/readyz` is readiness. `/api/health` answers `ok` unconditionally and is not a readiness probe.