Self-hosting
Run your own Opereo AI — prerequisites, environment, the Workers, and first boot.
Self-hosting
Opereo AI is MIT licensed and runs on your own infrastructure. It's a Next.js app plus Postgres; the agents themselves run on boxes it provisions.
What you'll need
- Node 22+ and pnpm
- Postgres — any instance you can reach
- Clerk — auth, organizations, and roles
- One provider — where each engine's box runs. Hetzner is the default and needs an API token plus an SSH key pair; Railway and Upstash Box work instead of it, or alongside it
- Cloudflare — optional, for public box hostnames and for WhatsApp
- Model keys are optional — fresh agents start on Hermes' anonymous free tier, and every user connects their own providers in the app
Install
git clone https://github.com/genlabs-ai/opereo-ai.git
cd opereo-ai/opereo
pnpm installCreate opereo/.env, then:
pnpm db:push # sync the schema to your Postgres
pnpm dev # http://localhost:3000Next loads .env automatically.
There is no pnpm workspace. opereo/, workers/edge/, workers/relay/ and docs/ are
independent packages, each with its own lockfile and its own .env — nothing reads
opereo/.env but the app.
Environment
Set the variables for the features you use. The blocks below mirror
opereo/.env.example.
Database
DATABASE_URL=postgres://...Auth (Clerk)
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY=
CLERK_WEBHOOK_SIGNING_SECRET=
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/
NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/Opereo AI
OPEREO_COMPOSIO_KEY= # powers the Integrations panel (Composio)Providers — a provider is offered when its credentials are set. Engines default to hetzner.
HETZNER_API_TOKEN=
OPEREO_RAILWAY_TOKEN=
OPEREO_RAILWAY_PROJECT_ID=
UPSTASH_BOX_API_KEY=
UPSTASH_BOX_HERMES_SNAPSHOT_ID=
# One ed25519 key pair for the deployment; Hetzner updates run over SSH with it.
# Private key is an OpenSSH PEM with newlines written as \n.
HETZNER_SSH_PRIVATE_KEY=
HETZNER_SSH_PUBLIC_KEY=Box exposure (Cloudflare, optional) — the app writes KV routes itself, so it needs these even though the Worker has its own copy.
BOX_DOMAIN=
CLOUDFLARE_API_TOKEN=
CLOUDFLARE_ACCOUNT_ID=
CLOUDFLARE_KV_NAMESPACE_ID=Edge Worker (workers/edge/.env, optional)
BOX_DOMAIN=
CLOUDFLARE_API_TOKEN=
CLOUDFLARE_ACCOUNT_ID=Relay (workers/relay/.env, optional) — the same three Cloudflare variables plus four
of its own. The duplication is deliberate: each package is self-contained.
BOX_DOMAIN=
CLOUDFLARE_API_TOKEN=
CLOUDFLARE_ACCOUNT_ID=
RELAY_ADMIN_TOKEN= # the bearer the app presents to /admin/*; invent a long random string
KAPSO_API_KEY= # from the Kapso dashboard: the project API key
KAPSO_WEBHOOK_SECRET= # the number's webhook signing secret
KAPSO_PHONE_NUMBER_ID= # the number's idProviders
An engine is one Hermes runtime; the provider decides where it runs. It's picked per engine at create time and defaults to hetzner.
- hetzner — a Hetzner Cloud VM running the official Hermes image behind Caddy. Enabled by
HETZNER_API_TOKENplus the SSH key pair. - railway — the same image as a Railway service with a persistent volume. Enabled by
OPEREO_RAILWAY_TOKENandOPEREO_RAILWAY_PROJECT_ID. - upstash — an Upstash Box restored from a baked snapshot. Enabled by
UPSTASH_BOX_API_KEYandUPSTASH_BOX_HERMES_SNAPSHOT_ID.
The Upstash snapshot
Only the upstash provider uses a snapshot. UPSTASH_BOX_HERMES_SNAPSHOT_ID points at a prebuilt image with the runtime already installed, so provisioning restores it instead of installing from scratch.
To bake your own:
pnpm upstash:bake # ~7 min, prints a new snapshot idPut the printed id in .env. The script verifies the result before snapshotting (runtime version, dashboard build, browser launch) so a broken image fails the bake instead of shipping.
Baking creates a real box and deletes it when it succeeds. If it fails it deliberately leaves the box running so you can inspect the logs — delete it yourself afterward.
Public hostnames (edge Worker)
Optional. With it, every engine answers on https://<name>.<BOX_DOMAIN> (its dashboard)
and https://<name>-<port>.<BOX_DOMAIN> (a profile's API), through a 17-line Worker that
maps a hostname label to an origin in KV. Without it, each box answers on its provider's
own origin.
The Worker is its own package at workers/edge/. Your domain has to be a zone on your
Cloudflare account already, and workers/edge/wrangler.toml carries the route and
BOX_DOMAIN literally — change pattern, zone_name and BOX_DOMAIN to your own domain
before the first setup.
cd workers/edge
pnpm install
cp .env.example .env
pnpm provision # idempotent: KV namespace, wildcard DNS, deploy; prints CLOUDFLARE_KV_NAMESPACE_IDCopy the printed namespace id into opereo/.env.
WhatsApp (the relay)
Optional, and it requires the edge Worker's domain setup — the relay lives at
relay.<BOX_DOMAIN>. It fronts one shared WhatsApp number for every engine: no box holds a
WhatsApp credential and none opens an inbound port. See
Engines for what it gives a user.
The number is created in Kapso's hosted Meta wizard; the free tier covers one number and 2,000 messages.
cd workers/relay
pnpm install
cp .env.example .env
pnpm provision # idempotent: D1, R2, migrations, wrangler.toml, deploy, secretspnpm provision ensures the D1 database opereo-relay and the R2 bucket
opereo-relay-media, applies the migrations, writes the database id, route and
PUBLIC_URL into wrangler.toml from BOX_DOMAIN, deploys, pushes the four secrets, and
prints the webhook URL — https://relay.<BOX_DOMAIN>/webhooks/whatsapp — to paste into
Kapso.
The app needs two variables of its own in opereo/.env to reach the relay; without them
the WhatsApp row in channels reports Unavailable:
RELAY_URL=https://relay.<BOX_DOMAIN>
RELAY_ADMIN_TOKEN= # the same value as workers/relay/.envEvery agent created after that is enrolled automatically; phones are linked from the agent's channels tab.
Deploying the Workers
pnpm provision is the first deploy of each Worker, run locally from its own directory. After
that both deploy through Cloudflare Workers Builds: connect the repository once per
Worker in the Cloudflare dashboard (Worker → Settings → Builds → Connect), with
| Setting | workers/edge | workers/relay |
|---|---|---|
| Root directory | workers/edge | workers/relay |
| Build command | (none) | pnpm build (typecheck + tests) |
| Deploy command | pnpm exec wrangler deploy | pnpm exec wrangler deploy |
| Build watch paths | workers/edge/* | workers/relay/* |
The Worker's name in the dashboard must equal name in that directory's wrangler.toml —
opereo-cloudflare and opereo-relay. A push to main that touches the directory
builds and deploys it. There is no CI in the repository.
Cost
The app itself is one Next.js server plus Postgres, and each engine costs whatever its provider charges for one box. The Cloudflare side is free for the edge Worker; the relay uses Durable Objects, which need Workers Paid ($5/mo) in production. Relay usage at realistic volume stays inside the plan's included allowances — an idle socket is a hibernated object and bills nothing.
Useful commands
pnpm dev / build / start
pnpm typecheck # tsc --noEmit
pnpm lint / format
pnpm db:generate / db:migrate / db:push / db:studio
pnpm db:seed # seed the content we ship
pnpm hermes:api # regenerate the runtime API client
pnpm upstash:bake # bake the Upstash snapshot