--- description: Hosting the headless openhuman-core in the cloud - DigitalOcean App Platform, Fly.io, or Docker Compose on any VPS. icon: cloud --- # Cloud deployment OpenHuman is a desktop app, but its **Rust core** (`openhuman-core`) is a headless JSON-RPC server that can be hosted in the cloud. Deploying the core separately is useful for: - Multi-device access, point several desktop clients at the same hosted core - Internal testers without local Rust toolchains - Long-running cron jobs / webhooks that should outlive a laptop session This guide covers four deploy paths, easiest first: 1. [DigitalOcean App Platform: one-click](#1-digitalocean-app-platform-one-click) 2. [DigitalOcean App Platform: manual via doctl](#2-digitalocean-app-platform-manual-via-doctl) 3. [Any VPS via Docker Compose](#3-any-vps-via-docker-compose) 4. [Fly.io](#4-flyio) What gets deployed in every path: a single container running `openhuman-core serve` on port `7788`. Public hosts should sit behind the provider's TLS, for example `https://core.example.com/rpc`. Private-only hosts on localhost, RFC1918 networks, or tailnets such as Tailscale can use plain HTTP, for example `http://100.x.x.x:7788/rpc`, when the core is not reachable from the public internet. The desktop app already knows how to talk to a remote core; set `OPENHUMAN_CORE_RPC_URL` and `OPENHUMAN_CORE_TOKEN=...` in `app/.env.local` and launch. --- ## Remote UI choices OpenHuman's supported remote deployment is **core remote, UI local**: run `openhuman-core` on a Linux server and point a desktop client at that RPC URL. The deployed core does not serve the full React/Tauri UI as a production web app yet. Desktop-only features still need the Tauri shell, including tray controls, native deep links, CEF account scanners, OS keychain integration, and window/screen affordances. For a browser-accessible UI on a private server today, use the Vite web build as a development/preview surface against the remote core: ```bash # On the server, run the core with an explicit token. export OPENHUMAN_CORE_HOST=0.0.0.0 export OPENHUMAN_CORE_PORT=7788 export OPENHUMAN_CORE_TOKEN="$(openssl rand -hex 32)" openhuman-core serve # In another shell on the server, serve the UI only on loopback. pnpm --dir app dev -- --host 127.0.0.1 --port 1420 ``` Then tunnel both ports from your workstation: ```bash ssh -L 1420:127.0.0.1:1420 -L 7788:127.0.0.1:7788 user@server ``` Open `http://127.0.0.1:1420`, choose the remote/core option on the first-run screen, and enter `http://127.0.0.1:7788/rpc` plus the `OPENHUMAN_CORE_TOKEN` value from the server. If you serve the browser UI from a non-loopback origin, add that exact origin to the core's CORS allowlist: ```bash export OPENHUMAN_CORE_ALLOWED_ORIGINS="https://openhuman-ui.example.com" ``` Loopback Vite origins such as `http://127.0.0.1:1420` and `http://localhost:1420` are allowed automatically. Public `http://` origins are not recommended because every RPC call carries the bearer token. --- ## Single source of truth for the bearer token Every `/rpc` call carries `Authorization: Bearer `. The core has two ways to load that token at startup ([`src/core/auth.rs`](../../src/core/auth.rs)): 1. **`OPENHUMAN_CORE_TOKEN` environment variable** — pre-seeded by the caller (Tauri shell, Docker, App Platform, systemd unit, …). The core uses this value as-is and **never** writes a file. 2. **`{workspace}/core.token` file** — generated by the core on first boot *only when `OPENHUMAN_CORE_TOKEN` is unset*. Standalone `openhuman core run` uses this so CLI clients can `cat` the file. **Rule of thumb for any remote / dockerized deploy: always set `OPENHUMAN_CORE_TOKEN`.** Do not rely on `core.token` in a container — ephemeral filesystems lose it on redeploy, and any client trying to read the file from outside the container will get a stale or empty value. The two paths are deliberately mutually exclusive at startup; mixing them is the most common reason behind "the dashboard gets 401 after I redeployed". To check what the *running* core is using, run [`scripts/print-core-token.sh`](../../scripts/print-core-token.sh) on the host (or inside the container with `docker compose exec`): ```bash scripts/print-core-token.sh --where # prints 'env' or 'file:/path' scripts/print-core-token.sh --redact # first 8 hex chars + '…' (safe for logs) scripts/print-core-token.sh # full value (pipe straight into a client) ``` The desktop app's first-run picker also exposes a **Test connection** button next to the Core RPC URL + token fields, which fires `core.ping` against the URL with the typed token and reports `Connected ✓` / `Auth failed` / `Unreachable` inline before persisting the configuration. --- ## What you need before you start | Setting | Required | Notes | |----------------------------|----------|-----------------------------------------------------------------------| | `OPENHUMAN_CORE_TOKEN` | yes | Bearer token clients send to `/rpc`. Generate with `openssl rand -hex 32`. **Anyone with this token can drive the core.** | | `BACKEND_URL` | yes | Tinyhumans backend the core talks to (`https://api.tinyhumans.ai` for prod). | | `OPENHUMAN_APP_ENV` | no | `production` or `staging`. Defaults to `production`. | | `OPENHUMAN_CORE_HOST` | no | Defaults to `0.0.0.0` in the container. | | `OPENHUMAN_CORE_PORT` | no | Defaults to `7788`. | | `RUST_LOG` | no | `info` is fine; `debug` for triage. | Endpoints exposed by the running container: - `GET /health`, public liveness probe. Used by every deploy path's healthcheck. - `POST /rpc`, bearer-protected JSON-RPC entrypoint. - `GET /events`, `GET /ws/dictation`, public streaming channels. The `OPENHUMAN_WORKSPACE` directory (`/home/openhuman/.openhuman` inside the container) holds the core's config, sqlite databases, and skill state. **Mount it on a persistent volume** in every production deploy or you will lose data on restart. --- ## 1. DigitalOcean App Platform: one-click Click the button below to create a new App Platform application from this repository's [`.do/app.yaml`](../../.do/app.yaml): [![Deploy to DO](https://www.deploytodo.com/do-btn-blue.svg)](https://cloud.digitalocean.com/apps/new?repo=https://github.com/tinyhumansai/openhuman/tree/main) Then, in the App Platform UI, **before the first deploy completes**: 1. Open the **Settings → App-Level Environment Variables** tab. 2. Replace the placeholder `OPENHUMAN_CORE_TOKEN` value with a strong secret (`openssl rand -hex 32`). Mark it encrypted. 3. If you are deploying staging, change `OPENHUMAN_APP_ENV` to `staging` and `BACKEND_URL` to `https://staging-api.tinyhumans.ai`. 4. Hit **Save**. App Platform redeploys with the new secret. App Platform handles TLS, restart-on-crash, log streaming, and rolling redeploys on `git push` (set `deploy_on_push: true` in `.do/app.yaml` to opt-in). > **Persistence note:** App Platform Basic does not provide block storage. The > core's workspace lives in the container's ephemeral filesystem and is lost > on redeploy. For durable storage, attach a managed database or upgrade to a > tier that supports volumes. See the [Compose path](#3-any-vps-via-docker-compose) > for a self-host alternative with persistent volumes out of the box. --- ## 2. DigitalOcean App Platform: manual via doctl If you'd rather not click through the UI: ```bash # One-time: install doctl and authenticate. doctl auth init # Edit .do/app.yaml - set OPENHUMAN_CORE_TOKEN to a real value (or pass it in # at create time via --spec with envsubst). Then: doctl apps create --spec .do/app.yaml # Watch the build: doctl apps list doctl apps logs --type build --follow ``` Update an existing app after editing the spec: ```bash doctl apps update --spec .do/app.yaml ``` --- ## 3. Any VPS via Docker Compose Works on any host with Docker Engine ≥ 24 and the Compose plugin. DigitalOcean Droplet, Hetzner, Linode, EC2, a home server. Each production release publishes a multi-tagged image to GHCR: ```bash docker pull ghcr.io/tinyhumansai/openhuman-core:latest # tracks the latest prod cut docker pull ghcr.io/tinyhumansai/openhuman-core:v1.2.4 # pinned by GitHub Release tag docker pull ghcr.io/tinyhumansai/openhuman-core:1.2.4 # pinned by SemVer ``` The image is `linux/amd64`. arm64 hosts pull the standalone tarball attached to the same GitHub Release (`openhuman-core--aarch64-unknown-linux-gnu.tar.gz`) or build the image from source on an arm64 builder. Quick run with a published image: ```bash docker run -d --name openhuman-core -p 7788:7788 \ -e OPENHUMAN_CORE_TOKEN="$(openssl rand -hex 32)" \ -e BACKEND_URL=https://api.tinyhumans.ai \ -e OPENHUMAN_APP_ENV=production \ -v openhuman-workspace:/home/openhuman/.openhuman \ ghcr.io/tinyhumansai/openhuman-core:latest ``` Or use the in-repo Compose file (still builds the image locally from `Dockerfile`; switch the `image:` field to `ghcr.io/tinyhumansai/openhuman-core:latest` in `docker-compose.yml` to consume the published image instead): ```bash # On the server: git clone https://github.com/tinyhumansai/openhuman.git cd openhuman # Configure secrets: cp .env.example .env # Edit .env - at minimum: # BACKEND_URL=https://api.tinyhumans.ai # OPENHUMAN_CORE_TOKEN= # OPENHUMAN_APP_ENV=production # Build and start: docker compose up -d # Verify: docker compose ps curl -fsS http://localhost:7788/health ``` ### Headless install without Docker If you can't run Docker on the host, grab the standalone CLI tarball attached to the latest [GitHub Release](https://github.com/tinyhumansai/openhuman/releases/latest): ```bash # Pick the tarball that matches your host arch. ARCH="$(uname -m)" case "$ARCH" in x86_64) TARGET=x86_64-unknown-linux-gnu ;; aarch64) TARGET=aarch64-unknown-linux-gnu ;; *) echo "Unsupported arch: $ARCH"; exit 1 ;; esac VERSION=1.2.4 # set to the release you want curl -fsSL "https://github.com/tinyhumansai/openhuman/releases/download/v${VERSION}/openhuman-core-${VERSION}-${TARGET}.tar.gz" \ | tar -xz -C /usr/local/bin openhuman-core --version ``` Then run `openhuman-core serve` under your service manager of choice (systemd, supervisord, …) with the same environment variables documented above. ### Headless self-update contract Headless deployments should treat `openhuman.update_apply` as the safe primitive: it downloads the release asset, writes it atomically next to the current binary, and returns. Nothing exits automatically. `openhuman.update_run` follows `config.update.restart_strategy`: - `self_replace` (default): stage the binary, publish an in-process restart request, and let the running core respawn itself. - `supervisor`: stage the binary and return `restart_requested=false`. Your outer service manager must restart the process. For long-running Linux services, set: ```toml [update] restart_strategy = "supervisor" rpc_mutations_enabled = false ``` or the equivalent env vars: ```bash OPENHUMAN_AUTO_UPDATE_RESTART_STRATEGY=supervisor OPENHUMAN_AUTO_UPDATE_RPC_MUTATIONS_ENABLED=false ``` Recommended `systemd` stance: ```ini Restart=always ExecReload=/bin/kill -HUP $MAINPID ``` Operator flow: 1. Call `openhuman.update_check` to discover a release. 2. Configure `restart_strategy = "supervisor"` in your `update.toml` (or set `OPENHUMAN_AUTO_UPDATE_RESTART_STRATEGY=supervisor`) so the core stages the new binary without trying to re-exec itself, then call `openhuman.update_apply` or `openhuman.update_run`. `restart_strategy` is a configuration setting, not an RPC parameter. 3. Restart the unit explicitly: `systemctl restart openhuman`. If download or staging fails, the running binary is left in place and no restart is requested. If a staged binary proves bad after restart, roll back by restoring the previous binary from your package manager, image tag, or release artifact and restarting the supervisor again. The Compose file ([`docker-compose.yml`](../../docker-compose.yml)) maps the core on `:7788`, mounts a named volume `openhuman-workspace` for persistence, and sets `restart: unless-stopped` so the core comes back after host reboots. ### Updating ```bash git pull docker compose build docker compose up -d ``` For RPC-exposed production deployments, prefer leaving mutating update RPCs disabled (`OPENHUMAN_AUTO_UPDATE_RPC_MUTATIONS_ENABLED=false`) and perform rollouts through your existing image tag or package-management flow instead. ### Logs ```bash docker compose logs -f openhuman-core ``` ### Rotating the bearer token `OPENHUMAN_CORE_TOKEN` is the only thing standing between the public internet and full RPC access. Rotate it on a schedule and after any suspected leak: ```bash # 1. Generate a new token and update the server-side .env. openssl rand -hex 32 > /tmp/new-token sed -i.bak "s|^OPENHUMAN_CORE_TOKEN=.*|OPENHUMAN_CORE_TOKEN=$(cat /tmp/new-token)|" .env rm /tmp/new-token .env.bak # 2. Restart the container so the new value reaches the core process. docker compose up -d --force-recreate openhuman-core # 3. Confirm the running container is using the new token (redacted). docker compose exec openhuman-core /bin/sh -c \ 'echo -n "$OPENHUMAN_CORE_TOKEN" | head -c 8; echo "…"' # 4. Update every desktop client (Switch mode → re-paste in the picker, or # edit OPENHUMAN_CORE_TOKEN in app/.env.local and relaunch). Clients that # still hold the old token will get HTTP 401 on the next /rpc call — that # is expected, not a regression. ``` For App Platform, do the same in **Settings → App-Level Environment Variables**: edit the `OPENHUMAN_CORE_TOKEN` secret and let App Platform redeploy. There is no separate token file to delete; the env var is the only state. ### Putting it behind TLS Use Caddy, nginx, or Traefik as a reverse proxy in front of `:7788`. A minimal `Caddyfile`: ```caddy core.example.com { reverse_proxy localhost:7788 } ``` --- ## Pointing the desktop app at a hosted core In the desktop app's environment file (`app/.env.local`): ```bash # Use the hosted core instead of spawning a local sidecar. OPENHUMAN_CORE_RUN_MODE=external OPENHUMAN_CORE_RPC_URL=https://core.example.com/rpc OPENHUMAN_CORE_TOKEN= ``` For a private tailnet-only VM with no public IP, use the tailnet URL instead: ```bash OPENHUMAN_CORE_RUN_MODE=external OPENHUMAN_CORE_RPC_URL=http://100.x.x.x:7788/rpc OPENHUMAN_CORE_TOKEN= ``` Restart the desktop app. The provider chain in `App.tsx` will route all RPC calls to the remote core; nothing else changes. Public `http://` hosts are rejected by the app picker; use HTTPS for any publicly reachable core. --- ## Named-volume ownership and the Docker entrypoint Docker creates named volumes owned `root:root` by default. Because the core runs as the non-root `openhuman` user (UID 10001), the first write after the banner — `init_rpc_token → write_token_file` into `$OPENHUMAN_WORKSPACE` — would raise `Permission denied (os error 13)` if nothing fixes the ownership first. The image ships a dedicated entrypoint at `/usr/local/bin/docker-entrypoint-core.sh` that: 1. Starts as `root`. 2. Runs `mkdir -p` + `chown openhuman:openhuman` on both `$OPENHUMAN_WORKSPACE` and `$HOME/.openhuman` (the directory `core.token` is written to when `OPENHUMAN_CORE_TOKEN` is unset). 3. Calls `exec gosu openhuman openhuman-core "$@"` to drop privileges and hand off to the binary. This is **idempotent**: on a freshly-created volume the chown heals the root-owned directory; on a volume that was already healed the chown is a no-op. No manual `docker volume rm` is required when upgrading from images predating this fix. The entrypoint is named `docker-entrypoint-core.sh` and wired **only** into the root `Dockerfile`. The E2E image (`e2e/docker-entrypoint.sh`) is unaffected. --- ## 4. Fly.io [Fly.io](https://fly.io) is a good fit for `openhuman-core`: it handles TLS automatically, supports persistent volumes on all tiers, and can auto-stop idle machines to cut costs. ### Prerequisites - [flyctl](https://fly.io/docs/flyctl/install/) installed and authenticated (`fly auth login`) - A Fly.io account ### Step 1 — Launch the app ```bash fly launch --no-deploy --config .fly/fly.toml ``` Fly.io detects the `Dockerfile` automatically. Choose a region close to your users and skip the first deploy when prompted. This generates a config file. ### Step 2 — Configure `.fly/fly.toml` The repo ships a template at [`.fly/fly.toml`](../../.fly/fly.toml). Fill in `` and `` with the values you chose during `fly launch`: ```toml app = '' primary_region = '' [build] dockerfile = "Dockerfile" [env] OPENHUMAN_CORE_HOST = "0.0.0.0" OPENHUMAN_CORE_PORT = "7788" OPENHUMAN_WORKSPACE = "/home/openhuman/.openhuman" RUST_LOG = "info" [[mounts]] source = "openhuman_workspace" destination = "/home/openhuman/.openhuman" [http_service] internal_port = 7788 force_https = true auto_stop_machines = 'stop' auto_start_machines = true # min_machines_running = 0 fully stops the machine when idle (cheapest), but # the first request after idle pays a cold-start penalty (container boot + # Rust binary init — several seconds). Set to 1 to keep one machine warm. min_machines_running = 0 processes = ['app'] [[http_service.checks]] interval = "30s" timeout = "5s" grace_period = "10s" method = "GET" path = "/health" [[vm]] memory = '1gb' cpus = 1 ``` ### Step 3 — Create a persistent volume ```bash fly volumes create openhuman_workspace --size 5 --region --config .fly/fly.toml ``` **Mount the workspace on a persistent volume** or data is lost on every redeploy. ### Step 4 — Set secrets ```bash # Required fly secrets set OPENHUMAN_CORE_TOKEN="$(openssl rand -hex 32)" fly secrets set BACKEND_URL="https://api.tinyhumans.ai" fly secrets set OPENHUMAN_APP_ENV="production" # Recommended for any publicly-reachable deployment: fly secrets set OPENHUMAN_AUTO_UPDATE_RPC_MUTATIONS_ENABLED="false" fly secrets set OPENHUMAN_AUTO_UPDATE_RESTART_STRATEGY="supervisor" # Optional — error reporting and analytics: fly secrets set OPENHUMAN_CORE_SENTRY_DSN="https://@o.ingest.sentry.io/" fly secrets set OPENHUMAN_ANALYTICS_ENABLED="true" ``` Save the value of `OPENHUMAN_CORE_TOKEN` — you will need it to connect the desktop app later. **Anyone with this token can drive the core**; treat it like a password and rotate it with `fly secrets set OPENHUMAN_CORE_TOKEN="$(openssl rand -hex 32)"` after any suspected leak. ### Step 5 — Deploy ```bash fly deploy --config .fly/fly.toml ``` Verify the core is healthy: ```bash curl -fsS https://.fly.dev/health ``` ### Step 6 — Point the desktop app at the hosted core In `app/.env.local`: ```bash OPENHUMAN_CORE_RUN_MODE=external OPENHUMAN_CORE_RPC_URL=https://.fly.dev/rpc OPENHUMAN_CORE_TOKEN= ``` Or use the **first-run picker** in the desktop app (Core RPC URL + token fields with a **Test connection** button) to configure without editing files. ### Continuous deployment To redeploy automatically on every push to `main`, add a workflow file at `.github/workflows/fly-deploy.yml`: ```yaml name: Fly Deploy on: push: branches: - main paths: - 'src/**' - 'Cargo.toml' - 'Cargo.lock' - 'Dockerfile' - '.fly/fly.toml' - 'scripts/docker-entrypoint-core.sh' jobs: deploy: name: Deploy openhuman-core runs-on: ubuntu-latest concurrency: deploy-group steps: - uses: actions/checkout@v4 # Pin the Fly action to a tagged release (or a full commit SHA) rather # than `@master` — tracking a moving branch trusts every future commit # pushed there, including any made by a compromised maintainer account. - uses: superfly/flyctl-actions/setup-flyctl@1.5 - run: flyctl deploy --remote-only --config .fly/fly.toml env: FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} ``` Generate a deploy token with `fly tokens create deploy` and add it as a repository secret named `FLY_API_TOKEN`. ### Updating ```bash fly deploy --config .fly/fly.toml ``` For version-pinned deployments, update the image tag in `.fly/fly.toml` and redeploy: ```toml [build] image = "ghcr.io/tinyhumansai/openhuman-core:v1.2.4" ``` ### Logs ```bash fly logs --config .fly/fly.toml ``` ### Known gotcha — UID mismatch on volumes If you switch between building from `Dockerfile` (which creates the `openhuman` user at UID 10001) and pulling the pre-built GHCR image (which uses UID 1000), files already written to the persistent volume will be owned by the old UID and produce `Permission denied (os error 13)` on startup. Fix by SSH-ing in and re-owning the workspace: ```bash fly ssh console --config .fly/fly.toml chown -R openhuman:openhuman /home/openhuman/.openhuman/ exit fly machine restart --config .fly/fly.toml ``` --- ## Smoke test The repo ships [`.github/workflows/deploy-smoke.yml`](../../.github/workflows/deploy-smoke.yml), which runs on every PR that touches the deploy artifacts. It builds the Docker image, boots it, and polls `/health`, so a regression in the cloud deploy path fails CI before it lands on `main`. The workflow contains two jobs: - **`docker-image`** — sets `OPENHUMAN_CORE_TOKEN` and mounts no volume. Protects the DigitalOcean App Platform path (`.do/app.yaml`) where the token is always pre-set and no persistent volume is used. - **`docker-volume-permissions`** — omits `OPENHUMAN_CORE_TOKEN` and mounts a fresh anonymous volume at `/home/openhuman/.openhuman`. Reproduces the exact failure mode of issue #2065 and asserts that `/health` returns 200 and that `Permission denied (os error 13)` is absent from the logs. To run the same check locally: ```bash docker build -t openhuman-core:smoke . # Token-set path (App Platform): docker run -d --name oh-smoke -p 7788:7788 \ -e OPENHUMAN_CORE_TOKEN=smoke-test-token \ openhuman-core:smoke curl -fsS http://localhost:7788/health docker rm -f oh-smoke # Fresh-volume / no-token path (Docker Compose, VPS): docker volume create oh-vol-test docker run -d --name oh-vol-smoke -p 7789:7788 \ -v oh-vol-test:/home/openhuman/.openhuman \ openhuman-core:smoke curl -fsS http://localhost:7789/health docker rm -f oh-vol-smoke docker volume rm oh-vol-test ```