Self-hosting with Docker
Run orboto on any host with Docker Compose - fully standalone, no platform required.
orboto self-hosts on any machine that runs Docker - a VPS, a homelab box, bare metal. This guide is fully standalone: plain Docker Compose, your own reverse proxy, no deployment platform required.
Running Coolify? There is a dedicated one-click path that automates most of the steps below. This page is the reference for everyone else - and for understanding what the one-click path does under the hood.
You have two paths:
- Coolify (recommended if you have it) - Coolify auto-generates all secrets, hostnames, and named volumes for you. Jump to the Coolify guide.
- Manual VPS - classic Docker + reverse proxy, below.
Every variable referenced here ships with an inline comment in the compose
file itself, and the full reference lives in docs/env.md in the repo.
Before you start
- Linux with Docker ≥ 24 and Docker Compose v2.
- DNS
Arecords for both your web hostname (e.g.orboto.example.com) and your API hostname (e.g.api.orboto.example.com) pointing at the host - or a single wildcard record. - Ports
80and443open on the firewall. The API and web containers are NOT bound to public ports themselves - only your reverse proxy is.
1. Get the files and set your secrets
git clone https://github.com/orboto/orboto.git /opt/orboto
cd /opt/orboto
cp .env.prod.example .env.prodFill in .env.prod, generating strong secrets:
openssl rand -hex 32 # for SERVICE_PASSWORD_64_JWT and SERVICE_PASSWORD_64_GIT
openssl rand -hex 16 # for SERVICE_PASSWORD_POSTGRES and SERVICE_PASSWORD_RUSTFSJWT_SECRET, GIT_ENC_SECRET, and SSO_ENC_SECRET are required in
production - the API refuses to boot without them, so it can never run on a
public host with the (openly documented) development fallback values.
2. What happens on the first boot
Before the API applies any pending database migration, it automatically
snapshots your database (pg_dump) and streams it to your own S3-compatible
storage under system/pre-migrate/. If the snapshot fails, the boot aborts
and no migration runs - your previous deployment stays live. This is
on by default and needs no setup; the compose file also ships a pg_backup
sidecar that takes an hourly raw dump as a second safety net.
| Var | Default | Purpose |
|---|---|---|
PRE_MIGRATE_SNAPSHOT | on | Set to off to skip the automatic snapshot (not recommended). |
PRE_MIGRATE_SNAPSHOT_KEEP | 5 | How many snapshots to keep. |
PG_BACKUP_RETENTION_DAYS | 14 | How long the hourly sidecar dumps are kept. |
3. Build and start
docker compose --env-file .env.prod build
docker compose --env-file .env.prod up -dThe API container migrates the database automatically before it starts
serving traffic, on every up - including upgrades, so there is no manual
migration step. Check it came up:
curl -f http://localhost:3000/ready # from inside the host; the API is not bound publicly4. Put a reverse proxy in front
Whichever proxy you use, it needs to route four kinds of request to the
right container and nowhere else: /api/* and /git/webhook/* to the API,
WebSocket upgrades on /ws* to the API, /mcp to the MCP container if
you run one, and everything else - the SPA itself - to the web container.
Caddy (simplest - automatic TLS)
-
Install Caddy: caddyserver.com/docs/install
-
Create
/etc/caddy/Caddyfile, replacingorboto.example.comwith your hostname:orboto.example.com { encode gzip zstd header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" X-Frame-Options "DENY" X-Content-Type-Options "nosniff" Referrer-Policy "strict-origin-when-cross-origin" } @websocket { header Connection *Upgrade* header Upgrade websocket path /ws* } reverse_proxy @websocket api:3000 reverse_proxy /api/* api:3000 reverse_proxy /git/webhook/* api:3000 # MCP clients (Claude Desktop, Cursor, ...) - only needed if you run the mcp service reverse_proxy /mcp mcp:3100 { flush_interval -1 } reverse_proxy web:80 } -
sudo systemctl reload caddy(orcaddy reloadif you run it directly, not as a service). Caddy obtains and renews the TLS certificate automatically - nothing else to configure.
Traefik
Add this as a dynamic-config file, or transcribe the equivalent labels
directly onto the api/web/mcp compose services:
http:
routers:
orboto-api:
rule: "Host(`orboto.example.com`) && (PathPrefix(`/api`) || PathPrefix(`/git/webhook`) || PathPrefix(`/ws`))"
entryPoints: [websecure]
service: orboto-api
tls: { certResolver: letsencrypt }
middlewares: [orboto-strip-api, orboto-security-headers]
orboto-mcp:
rule: "Host(`orboto.example.com`) && PathPrefix(`/mcp`)"
entryPoints: [websecure]
service: orboto-mcp
priority: 100
tls: { certResolver: letsencrypt }
middlewares: [orboto-security-headers]
orboto-web:
rule: "Host(`orboto.example.com`)"
entryPoints: [websecure]
service: orboto-web
tls: { certResolver: letsencrypt }
middlewares: [orboto-security-headers]
middlewares:
orboto-strip-api:
stripPrefix: { prefixes: ["/api"] }
orboto-security-headers:
headers:
stsSeconds: 31536000
stsIncludeSubdomains: true
frameDeny: true
contentTypeNosniff: true
referrerPolicy: "strict-origin-when-cross-origin"
services:
orboto-api:
loadBalancer: { servers: [{ url: "http://api:3000" }] }
orboto-mcp:
loadBalancer: { servers: [{ url: "http://mcp:3100" }] }
orboto-web:
loadBalancer: { servers: [{ url: "http://web:80" }] }The orboto-mcp router has a higher priority so it matches before the
catch-all orboto-web router - without it, every request would fall
through to the SPA instead of reaching the MCP container. Drop the
orboto-mcp router and service entirely if you don't run the mcp
service.
Security headers
The shipped web container already sets Content-Security-Policy,
X-Content-Type-Options, X-Frame-Options, Referrer-Policy, and
Permissions-Policy on its own responses - the header blocks in both
samples above add HSTS on top (HSTS has to live at the TLS-terminating
edge, which the container itself isn't). If you front the SPA with a proxy
that isn't the shipped nginx, replicate the container's header set too.
Receiving mail directly (optional)
If you want orboto to receive inbound mail on port 25 instead of a webhook provider, your reverse proxy needs a TCP/stream block in addition to the HTTP routes above - see inbound email for the DNS and TLS walkthrough.
5. Finish setup
Open your web hostname in a browser. See First-run setup for the setup wizard - creating the admin account or restoring a backup.
Want customer-facing invitations on a separate brand domain? See Multiple domains.
6. Day-to-day operations
- Upgrading - see Upgrades.
- Logs -
docker compose logs -f api(JSON; auth headers and passwords are redacted automatically). - Health -
/healthis a pure liveness probe./readychecks Postgres + storage and returns503on failure. See Monitoring for what to hook a monitor up to. - Rate limits -
600/min per IP by default,20/min on auth, setup, and invitation routes. Tune withRATE_LIMIT_MAX. - Backups - see Backups for the in-app scheduler; keep the
pg_backupsidecar running alongside it. - Audit log - entries older than a year are pruned automatically; adjust
audit_log_retention_daysin system settings if you need a different window.
7. Connecting AI clients (MCP)
The shipped compose file includes an mcp service so Claude Desktop,
Cursor, and other MCP-aware clients can talk to your instance natively.
Bring it up alongside the rest:
docker compose --env-file .env.prod up -d mcpYou do not need a separate hostname or DNS record for it: route /mcp
through your existing reverse-proxy config exactly like /api and /ws
above (the Caddy and Traefik samples already include it) - operators then
connect at https://orboto.example.com/mcp, one URL, one certificate,
alongside the rest of your deployment. A dedicated mcp.<your-host>
subdomain is still supported if a particular client needs it, but it's an
opt-in extra, not the default path.
The per-client setup - which API-key scope to mint, where each client expects its config, and the identity/consent model when a client connects via OAuth - is in Connect an AI client. Skip this whole section if your team doesn't use MCP clients.
Running on a different CPU architecture
orboto's images are published for both linux/amd64 and linux/arm64, so
the same image tag runs on an Intel/AMD VPS, an ARM VPS, or Apple-silicon
hardware - Docker picks the right one automatically, no compose changes
needed. Building your own image for a private registry works the same way
with docker buildx build --platform linux/amd64,linux/arm64 ....
Scaling beyond one process (optional)
The default deploy runs a single api container that serves HTTP and
runs all background jobs (API_ROLE=all). Most installs never need to
change this.
More HTTP throughput. Run several api containers with API_ROLE=api
(HTTP only) plus exactly one dedicated worker (command: node dist/worker.js,
API_ROLE=worker) that owns the background jobs - cron schedules, the
embedding backlog, and so on must fire from exactly one process. The
compose file ships a profile-gated worker service for this:
API_ROLE=api docker compose --env-file .env.prod up -d api web
docker compose --env-file .env.prod --profile worker up -d workerAll cores of one box, no extra container. Set API_WORKERS=N and the
api process becomes a small cluster primary that forks N workers
sharing the listen socket. Slot 0 keeps the background jobs; the rest serve
HTTP only. Defaults to 1 (unchanged behaviour) - this is an explicit
opt-in, not automatic:
API_WORKERS=14 docker compose --env-file .env.prod up -d api webEach worker opens its own database connection pool (DB_POOL_MAX, default
10), so size it against Postgres's max_connections (raised to 200 in
the shipped compose file):
N_workers × DB_POOL_MAX + N_workers × 1 (LISTEN) + ~5 (background) + margin < max_connectionsOn a big box, lower DB_POOL_MAX as you raise API_WORKERS rather than
letting the total run away.
Tuning Postgres for the box. The database container ships with stock, small-box defaults so an image pull never silently re-tunes a running instance. Size these to your box's RAM if you're running a dedicated box:
| Box RAM | PG_SHARED_BUFFERS | PG_EFFECTIVE_CACHE_SIZE | PG_WORK_MEM | PG_MAINTENANCE_WORK_MEM |
|---|---|---|---|---|
| 2 GB | 512MB | 1280MB | 8MB | 128MB |
| 4 GB | 1GB | 3GB | 16MB | 256MB |
| 8 GB | 2GB | 5GB | 24MB | 512MB |
| 16 GB | 4GB | 11GB | 32MB | 1GB |
On SSD-backed storage also set PG_RANDOM_PAGE_COST=1.1 - the Postgres
default of 4.0 assumes a spinning disk and discourages index scans.
Troubleshooting
The API container keeps restarting instead of coming up.
Cause: the automatic pre-migrate snapshot failed - usually bad S3
credentials, pg_dump missing, or the database being unreachable.
Fix: run docker compose logs api and look for a line starting
[migrate] FATAL - it names the actual cause. Fix that, or set
PRE_MIGRATE_SNAPSHOT=off to skip the safety net (not recommended).
Nothing loads in the browser at all.
Cause: DNS isn't pointing at the host yet, or ports 80/443 are closed.
Fix: verify your A records and firewall rules match Before you start.
curl .../ready returns 503.
Cause: Postgres or the S3-compatible storage isn't reachable from the API
container.
Fix: confirm those containers are healthy and that DATABASE_URL /
S3_* point at them correctly.
Invitations and notifications never arrive by email. Cause: no email provider is configured yet - this is deliberately not an env var. Fix: sign in as admin → System Settings → Email delivery, pick a provider, and use Send test.