orbotodocs
Self-hosting

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 A records 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 80 and 443 open 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.prod

Fill 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_RUSTFS

JWT_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.

VarDefaultPurpose
PRE_MIGRATE_SNAPSHOTonSet to off to skip the automatic snapshot (not recommended).
PRE_MIGRATE_SNAPSHOT_KEEP5How many snapshots to keep.
PG_BACKUP_RETENTION_DAYS14How 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 -d

The 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 publicly

4. 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)

  1. Install Caddy: caddyserver.com/docs/install

  2. Create /etc/caddy/Caddyfile, replacing orboto.example.com with 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
    }
  3. sudo systemctl reload caddy (or caddy reload if 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 - /health is a pure liveness probe. /ready checks Postgres + storage and returns 503 on 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 with RATE_LIMIT_MAX.
  • Backups - see Backups for the in-app scheduler; keep the pg_backup sidecar running alongside it.
  • Audit log - entries older than a year are pruned automatically; adjust audit_log_retention_days in 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 mcp

You 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 worker

All 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 web

Each 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_connections

On 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 RAMPG_SHARED_BUFFERSPG_EFFECTIVE_CACHE_SIZEPG_WORK_MEMPG_MAINTENANCE_WORK_MEM
2 GB512MB1280MB8MB128MB
4 GB1GB3GB16MB256MB
8 GB2GB5GB24MB512MB
16 GB4GB11GB32MB1GB

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.

On this page