Node + Docker (no Cloudflare)
If you want openma without a Cloudflare account — on a VPS, your laptop, fly.io, your k8s cluster, or anywhere else that runs Linux/macOS — this is the path. Same code. The harness, the event log, the recovery logic, the API, the Console UI: identical to the Cloudflare deploy. Only the I/O bindings change.
Two backends, one image
Section titled “Two backends, one image”| SQLite + LocalFs (default) | Postgres + LocalFs | |
|---|---|---|
| Compose file | docker-compose.yml | docker-compose.postgres.yml |
| Best for | Single instance, ≤10 GB sessions, dev, demo | Multi-instance, large tables, share an existing PG cluster |
| Concurrency | One writer per process | Many writers — HA-able |
| Backups | cp ./data/*.db or litestream → S3 | pg_dump / managed PG snapshots |
| Extra services | None | + postgres:16-alpine (or external PG) |
Same openma/main-node:dev image either way — DATABASE_URL env at
runtime decides. SQLite needs only DATABASE_PATH; Postgres needs
DATABASE_URL=postgres://.... better-auth’s small auth.db stays SQLite
on both backends regardless.
Quickstart (Docker, SQLite)
Section titled “Quickstart (Docker, SQLite)”git clone https://github.com/open-ma/open-managed-agents.gitcd open-managed-agentscp .env.example .env
# Two secrets are required before first boot — both generated locally:# BETTER_AUTH_SECRET — signs Console sessions# PLATFORM_ROOT_SECRET — encrypts credentials, model-card API keys, integration tokens# (lose it and every encrypted row is unreadable — back it up)$EDITOR .env# BETTER_AUTH_SECRET=$(openssl rand -hex 32)# PLATFORM_ROOT_SECRET=$(openssl rand -base64 32)# ANTHROPIC_API_KEY is optional — in production, prefer a per-tenant Model Card.
# First time builds the image; subsequent runs skip --builddocker compose up -d --build
# Sanitycurl localhost:8787/health# → {"status":"ok","backends":{"db":"sqlite ..."},...}
# Console UI on the same port — sign up with email + password,# you'll be in the app immediately (no email-OTP gate by default)open http://localhost:8787Drive the harness end-to-end:
AID=$(curl -s -X POST localhost:8787/v1/agents -H 'content-type: application/json' \ -d '{"name":"hello","model":"claude-sonnet-4-6","tools":[{"type":"agent_toolset_20260401"}]}' \ | jq -r .id)
SID=$(curl -s -X POST localhost:8787/v1/sessions -H 'content-type: application/json' \ -d "{\"agent\":\"$AID\"}" | jq -r .id)
curl -N localhost:8787/v1/sessions/$SID/events/stream &
curl -s -X POST localhost:8787/v1/sessions/$SID/events -H 'content-type: application/json' \ -d '{"events":[{"type":"user.message","content":[{"type":"text","text":"Run uname -a"}]}]}'The SSE stream prints agent.tool_use → agent.tool_result → agent.message
→ session.status_idle.
Postgres backend
Section titled “Postgres backend”# Same .env (compose sets DATABASE_URL internally to the bundled postgres)docker compose -f docker-compose.postgres.yml up -d --build
curl localhost:8787/health# → {"backends":{"agents":"postgres","events":"postgres","db":"postgres ..."}, ...}Brings up postgres:16-alpine on a named volume oma-pgdata. No host
port published — only oma-server reaches it on the bridge network.
For a managed PG (RDS / Neon / Supabase / your cluster), drop the
postgres service block and set DATABASE_URL=postgres://user:pw@host:5432/oma
in .env.
Default self-host has no email server, so the auth path is plain email + password — sign-up creates a session immediately, no OTP screen.
| Mode | When | How to enable |
|---|---|---|
| email + password (default) | Single user / team / dev / demo | Just docker compose up, sign up via Console |
| AUTH_DISABLED=1 | ”I’m the only user, skip auth entirely” | echo "AUTH_DISABLED=1" >> .env, restart. Every request becomes tenant_id=default. |
| email + OTP gate | Real prod, you wired up SMTP | AUTH_REQUIRE_EMAIL_VERIFY=1, override sendVerificationOTP in apps/main-node/src/auth/config.ts to call Resend / SES / SMTP / Postmark / etc. |
| Google OAuth | Team SSO | GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET in .env. Console renders Google button automatically. |
Sandbox modes
Section titled “Sandbox modes”The agent’s bash / read / write / etc. tools execute in a sandbox.
SANDBOX_PROVIDER env picks the backend.
| Mode | When to pick | Required env |
|---|---|---|
subprocess (default) | Local dev, trusted agent code | None — host child_process under ./data/sandboxes/<sid>/. Zero isolation; the agent has the host’s filesystem. |
litebox | Local hardware isolation, no daemon | SANDBOX_PROVIDER=litebox. Host needs /dev/kvm (Linux) or Apple Silicon (macOS). Per-VM Firecracker-equivalent via libkrun. |
boxrun | Multi-host with KVM, OMA pods on managed k8s | SANDBOX_PROVIDER=boxrun BOXRUN_URL=http://host:8100/v1/default. Run boxlite serve on a separate KVM host; OMA pods reach it over HTTP. |
daytona | Production / untrusted code | SANDBOX_PROVIDER=daytona DAYTONA_API_KEY=... (+ optional DAYTONA_API_URL). Daytona-managed Linux VMs, one per session. |
e2b | E2B SaaS / E2B Infra self-host / Aliyun Agent Sandbox (e2b-compatible) | SANDBOX_PROVIDER=e2b E2B_API_KEY=... (+ optional E2B_API_URL for non-default endpoint). Firecracker microVMs. |
Remote sandboxes (Daytona, E2B, BoxRun) sharing memory mounts use
MEMORY_S3_* env (any S3-compatible store — AWS / R2 / OSS / MinIO).
Vault sidecar (outbound credential injection)
Section titled “Vault sidecar (outbound credential injection)”docker compose up brings up an oma-vault container alongside
oma-server. It’s a mockttp HTTPS MITM proxy — when the sandbox makes
outbound calls to a host you registered a credential for, the proxy
injects the matching Authorization header. The agent process never
sees the secret value; it lives only in the proxy’s lookup table.
Enable per-vault:
# Create a vaultVID=$(curl -s -X POST localhost:8787/v1/vaults \ -H 'content-type: application/json' -H "x-api-key: $YOUR_KEY" \ -d '{"name":"github-prod"}' | jq -r .id)
# Add a credential bound to api.github.comcurl -s -X POST localhost:8787/v1/vaults/$VID/credentials \ -H 'content-type: application/json' -H "x-api-key: $YOUR_KEY" \ -d '{ "display_name":"github-pat", "auth":{"type":"static_bearer","token":"ghp_xxx","mcp_server_url":"https://api.github.com"} }'
# Bind to a session — agent's `curl https://api.github.com/...` then sees# the Authorization header injected at the network layerSkip the sidecar if you don’t need it: docker compose up oma-server.
Operator gotchas
Section titled “Operator gotchas”AUTH_DISABLED=1belongs in.env, not the shell. Compose substitutes${AUTH_DISABLED}from the.envfile at the compose dir — exporting it only in your shell is silently ignored.docker compose restartdoes NOT re-read env. After editing.env, usedocker compose up -d --force-recreate oma-server.oma-vaultandoma-serverMUST agree on the backend. If you setDATABASE_URLto a Postgres URL onoma-serveronly, the vault sidecar will fall back to its own SQLite and never see the credentials you wrote. The bundled compose handles this for you; if you customise, mirror the env.- First boot from an old
oma.db.main-noderuns idempotentALTER TABLEfor the unified-runtime turn marker columns on every start, so existing dbs upgrade in place. If you ever seeno such column: turn_id, you’re on a pre-migration build — pull a newer image.
Migrating between backends
Section titled “Migrating between backends”SQLite → Postgres (one-way):
# Exportsqlite3 ./data/oma.db ".dump agents agent_versions sessions session_events session_streams \ memories memory_stores memory_versions vaults credentials session_memory_stores \ tenant membership" > /tmp/oma-dump.sql
# Strip SQLite-specific pragmassed -i '/^PRAGMA\|sqlite_sequence/d' /tmp/oma-dump.sql
# Import into Postgresdocker exec -i oma-postgres psql -U oma -d oma < /tmp/oma-dump.sqlagent.id, session.id are TEXT, BIGINT timestamps work in both,
JSON columns are TEXT not jsonb — no schema translation needed beyond
the pragma strip.
Backups
Section titled “Backups”| SQLite | Postgres | |
|---|---|---|
| Hot backup | litestream replicates ./data/*.db to S3 continuously | pg_dump cron / managed PG snapshots / WAL streaming |
| Restore | Stop server, copy db back, restart | pg_restore into fresh PG, point DATABASE_URL at it |
| Sandbox workdirs | Always on local FS — back up ./data/sandboxes/ separately | Same |
| Memory blobs | ./data/memory-blobs/ — back up separately or set MEMORY_S3_* | Same |
| auth.db | Always SQLite — back up ./data/auth.db | Same |
Both backends share the same crash-recovery test surface (55 tests across adapter, recovery logic, real-process SIGKILL bootstrap, CF DO eviction).
Crash recovery demo
Section titled “Crash recovery demo”main-node persists session state on every write — kill -9 doesn’t
lose the conversation.
# 1. Drop a few synthetic eventsSID=$(curl -s -X POST localhost:8787/v1/sessions -d '{}' | jq -r .id)for i in 1 2 3; do curl -s -X POST localhost:8787/v1/sessions/$SID/_test_emit \ -H 'content-type: application/json' -d "{\"text\":\"event-$i\"}"done
# 2. SSE shows ids 1, 2, 3curl -N localhost:8787/v1/sessions/$SID/events/stream# Ctrl-C
# 3. Hard-kill — no graceful shutdowndocker kill -s SIGKILL oma-server
# 4. Restartdocker compose up -d
# 5. Resume from where you left offcurl -N -H 'Last-Event-ID: 3' localhost:8787/v1/sessions/$SID/events/stream