For the complete documentation index, see llms.txt. This page is also available as Markdown.

Cloud Deploy

Run the headless openhuman-core on a server: DigitalOcean App Platform, Fly.io, Docker Compose on any VPS, or an Oracle Cloud Terraform stack, with the bearer token, workspace volume and secret storag

This page is for operators who want to run the core as a server process. Desktop users never need it, because the desktop app already runs a core in-process.

OpenHuman is a desktop app, but its Rust core (openhuman-core) is also a headless JSON-RPC server that you can host in the cloud. Hosting the core separately is useful for:

  • Reaching the same core from several devices.

  • Internal testers without a local Rust toolchain.

  • Long-running cron jobs and webhooks that should outlive a laptop session.

This guide covers five deploy paths, easiest first:

Every path deploys 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 can talk to a remote core directly. Settings > Core connection saves the RPC URL and bearer token, so you don't need an environment file on the desktop.


Remote UI choices

The supported remote setup is core remote, UI local. Run openhuman-core on a Linux server and point a desktop client at its RPC URL. The deployed core does not serve the full UI as a production web app yet. Desktop-only features still need the desktop app: tray controls, native deep links, the native iMessage scanner, OS keychain integration and window and screen features.

For a browser UI on a private server today, use the Vite web build as a development and preview surface against the remote core:

# 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:

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:

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.


The bearer token

Every /rpc call carries Authorization: Bearer <token>. The core can load that token in two ways at startup:

  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.

For any remote or Docker deploy, always set OPENHUMAN_CORE_TOKEN. Do not rely on core.token in a container. Ephemeral filesystems lose it on redeploy, and a client reading the file from outside the container gets a stale or empty value. The two paths are mutually exclusive on purpose. Mixing them is the most common cause of "the dashboard gets 401 after I redeployed".

To check what the running core is using, run scripts/print-core-token.sh on the host (or inside the container with docker compose exec):

The desktop app's first-run picker and Settings > Core connection both have a Test connection button next to the Core RPC URL and token fields. It sends core.ping to the URL you typed with the token you typed, and reports one of three results before anything is saved: connected, auth failed or unreachable.


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_KEYRING_MASTER_KEY

yes, if no OS keychain

64 hex characters (openssl rand -hex 32). The encrypted_file keyring is now the default even when OPENHUMAN_APP_ENV is unset. Inject the key from your secret manager; losing it orphans every stored secret.

OPENHUMAN_KEYRING_MASTER_KEY_FILE

alternative

Path to a file holding the same value, for Docker/Kubernetes secret mounts. Set one of the two, not both. Without either, encrypted_file needs an OS keychain.

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.

The running container exposes these endpoints:

Endpoint
Auth

GET /health

None. The liveness probe every deploy path's healthcheck uses.

GET /

None. Info page naming the other routes.

GET /schema

None. Read-only schema discovery.

POST /rpc

The bearer token. This is the executable surface.

GET /events

Exempt from the header check, because a browser EventSource cannot set headers, but the handler enforces its own credential. Not an open stream.

GET /ws/dictation

The bearer token, as a header or ?token=, checked before the WebSocket upgrade.

GET /oauth/mcp/callback

Exempt from the bearer middleware: an external authorization server posts back here with no bearer. The one-time state, minted in oauth_begin, is the guard.

GET /dev/connect

Exempt from the middleware, guarded in the handler instead: a debug build or an explicit opt-in, a loopback app origin and Host, and no cross-site navigation. It should not be reachable on a deployed core.

POST /v1/chat/completions, GET /v1/models

The OpenAI-compatible surface, behind core's http-server gate. These accept the core bearer or an external provider key, so they are executable too, not just /rpc.

Only /health, / and /schema are truly unauthenticated. /events, /oauth/mcp/callback and /dev/connect skip the header check but each has its own guard. The two executable surfaces are /rpc and /v1/*.

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 an App Platform application from this repository's .do/app.yaml:

Deploy to DO

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: App Platform Basic has no 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. The Compose path gives you persistent volumes out of the box.


2. DigitalOcean App Platform: manual via doctl

If you prefer not to click through the UI:

Update an existing app after editing the spec:


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:

Take <version> from the latest release. The two pinned tags are immutable and pushed per release; :latest is promoted only after the release publishes, so it never points at a cut that was rolled back (.github/workflows/release-production.yml).

The image is linux/amd64. arm64 hosts pull the standalone tarball attached to the same GitHub Release (openhuman-core-<version>-aarch64-unknown-linux-gnu.tar.gz) or build the image from source on an arm64 builder.

If you wrap a release tarball in your own image, match the builder it came from. The release matrix builds aarch64 on ubuntu-24.04-arm and x86_64 on ubuntu-22.04 (.github/workflows/release-production.yml), so the aarch64 binary needs glibc 2.39 or newer: ubuntu:24.04 works, while debian:bookworm-slim ships 2.36 and fails at exec with GLIBC_2.39' not found. For x86_64, Ubuntu 22.04 or Debian 12 and newer.

Quick run with a published image:

Or use the in-repo Compose file, which builds the image locally from Dockerfile. To consume the published image instead, replace the service's build: block in docker-compose.yml with image: ghcr.io/tinyhumansai/openhuman-core:latest; leaving build: in place keeps building from source and merely retags the result.

Headless install without Docker

If you can't run Docker on the host, grab the standalone CLI tarball attached to the latest GitHub Release:

Then run openhuman-core serve under your service manager of choice (systemd, supervisord, …) with the same environment variables documented above.

The tarballs are dynamically linked, so the host's glibc has to be at least as new as the builder's: aarch64 needs 2.39 or newer (Ubuntu 24.04, Debian 13), x86_64 wants Ubuntu 22.04 or Debian 12 and newer. Check with ldd --version on the host.

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:

or the equivalent env vars:

Recommended systemd stance:

Operator flow:

  1. Call openhuman.update_check to discover a release.

  2. Configure restart_strategy = "supervisor" under [update] in the core's config.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) 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

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

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:

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 token file to delete. The environment variable is the only state.

Putting it behind TLS

Use Caddy, nginx, or Traefik as a reverse proxy in front of :7788. A minimal Caddyfile:


Pointing the desktop app at a hosted core

Set this up in the app, not in a file. Settings > Core connection takes the core's RPC URL and bearer token. It has a Test connection button that sends core.ping with the values you typed, and it shows live reachability for the active core. The first-run picker accepts the same two fields.

Enter the server's URL and the token you set on it:

Deployment
URL to enter

Public host behind TLS

https://core.example.com/rpc

Private tailnet-only VM

http://100.x.x.x:7788/rpc

Saving stores the choice and restarts the app, so the normal startup checks run against the new core. A URL with no path is treated as the core's base, and /rpc is added for you. A user:pass@host URL is rejected, because the token field is the only way to give a credential.

Plain http:// is accepted without a warning only for loopback and private-network hosts, including RFC1918 ranges and the Tailscale 100.64.0.0/10 range. A public http:// host still saves, but the panel warns you.

VITE_OPENHUMAN_CORE_RPC_URL in app/.env.local is only a build-time fallback. The saved panel value outranks it, so setting it will not move a desktop client onto a remote core.

Troubleshooting: sign-in fails after OAuth on a cloud runtime

The symptom: OAuth in the browser completes ("close this and return to the app"), but the desktop then shows a sign-in error, while the same account signs in fine on the local (embedded) runtime.

The desktop app checks the fresh session token against the backend (GET /auth/me) itself and only then hands the credential to the core (auth.set_credential). On a cloud runtime that handoff goes to your server. The usual causes are:

  • Wrong RPC token or URL. A token mismatch shows up as HTTP 401 on /rpc. Paste the token again (see "Rotating the bearer token") and check the URL.

  • Outdated core. Older cores do not have auth.set_credential. The desktop still sends the older auth_store_session name through an alias, but a core older than that alias rejects the call. Update the server to a current release.

  • BACKEND_URL unset or wrong on the server. The remote core no longer validates the session, but every backend call it makes for you (billing, teams, managed inference) still goes there. It must be https://api.tinyhumans.ai for production, or the staging URL.

Check the desktop log for [session] lines and the remote core log for [credentials][set-credential]. For these cases the desktop shows a specific message instead of a generic "try again".

Headless sign-in

A cloud core has no browser to complete OAuth in. Get a credential elsewhere and hand it to the core at startup:

  • OPENHUMAN_BACKEND_API_KEY=<key>: a TinyHumans API key. This is the recommended option. It has no user identity and nothing to expire.

  • OPENHUMAN_BACKEND_SESSION_TOKEN=<jwt>: a session JWT with a subject claim.

Either one installs the credential only when the store has none of that kind. To rotate, clear it first (openhuman-core auth clear_credential --kind api-key). The same operation is available over RPC as openhuman.auth_clear_credential.

Headless without a TinyHumans account

Custom cloud providers (inference_url and api_key, or an entry in cloud_providers) are built only when a backend session or TinyHumans API key exists. Without one, every turn fails with a SESSION_EXPIRED: backend session not active error. Runtimes you supply yourself are exempt, and local-openai is one of them. So any hosted OpenAI-compatible endpoint can run a headless core with no account:

  1. Set LOCAL_OPENAI_URL=https://<your endpoint>/v1 in the core's environment (OPENHUMAN_LOCAL_INFERENCE_URL also works and overrides it).

  2. Store the endpoint's bearer key with openhuman.config_update_local_ai_settings (runtime_enabled: true, opt_in_confirmed: true, api_key: "<key>").

  3. Pin the workloads with openhuman.config_update_model_settings: chat_provider, reasoning_provider, agentic_provider, coding_provider (and vision_provider, memory_provider, embeddings_provider if you want them off the managed route) to local-openai:<model-id>, plus default_model: "<model-id>". Those are the whole set the controller accepts.

openhuman.inference_agent_chat_simple is the quickest end-to-end check. Managed features (integrations, teams, hosted voice) still need a TinyHumans credential. See Local models and bring your own key.

Secret storage in containers

The keyring backend is encrypted_file unless OPENHUMAN_APP_ENV=dev/development or an explicit backend override selects plaintext. Its master key comes from the OS keychain by default. A container normally has no keychain, so a provider-key write fails with Failed to encrypt api_key ([keyring] set error ... master key unavailable) until you supply OPENHUMAN_KEYRING_MASTER_KEY (64 hex characters) or OPENHUMAN_KEYRING_MASTER_KEY_FILE (a secret mount). Keep that key stable across restarts; losing it makes stored secrets unreadable. Older releases without these variables supported OPENHUMAN_KEYRING_BACKEND=file, which stored plaintext in $OPENHUMAN_WORKSPACE/dev-keychain.json (mode 0600). On upgrade, the encrypted backend writes and reads back secrets.enc before removing that plaintext file. It also removes older dev-keychain.json.migrated copies when their entries match the decrypted store. Copies with different or unreadable contents remain for manual recovery; inspect them before removal.

Mount the key file without group or world write permission. The core rejects group-writable and world-writable files. Read-only group or world access may be accepted for container secret mounts, but any process that can read the file can decrypt the whole keyring. Restrict the mode where the mount is declared:

  • Kubernetes: Secret volumes are root-owned, and runAsUser does not change the owner of a projected Secret. For a non-root core, use an init container running as root to copy the Secret into an emptyDir, chown the copy to the core UID, and chmod 0400 it before the core starts. Mount the original Secret read-only in the init container and the emptyDir read-only in the core container. For example:

    Set the emptyDir medium and the core UID/GID to match the ownership applied by the init container. The defaultMode: 0400 setting is important. For a direct non-root mount, also provide fsGroup (or equivalent group access) matching the core's group so kubelet can make the file readable; the resulting read-only 0440 mode is accepted. The init-container recipe above instead grants access by changing the copied file's owner. An equivalent setup is a secret volume whose defaultMode is 0400 and whose file is mounted with ownership matching the core's runAsUser/runAsGroup.

  • Docker Swarm: set mode: 0400 (with uid/gid for a non-root user) on the service's secret.

  • A plain file: chmod 600 it, owned by the user the core runs as.


Named-volume ownership and the Docker entrypoint

Docker creates named volumes owned by root:root. The core runs as the non-root openhuman user (UID 10001), so its first write into $OPENHUMAN_WORKSPACE would fail with Permission denied (os error 13) if nothing fixed 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 new volume the chown fixes the root-owned directory. On a volume that was already fixed, it does nothing. You don't need to run docker volume rm when upgrading from an older image.

The entrypoint is used only by the root Dockerfile. The E2E image (e2e/docker-entrypoint.sh) is unaffected.


4. Fly.io

Fly.io suits openhuman-core. It handles TLS automatically, supports persistent volumes on all tiers, and can stop idle machines to cut costs.

Prerequisites

  • flyctl installed and authenticated (fly auth login)

  • A Fly.io account

Step 1: Launch the app

Fly.io finds 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. Fill in <your-app-name> and <your-region> with the values you chose during fly launch:

Step 3: Create a persistent volume

Mount the workspace on a persistent volume, or data is lost on every redeploy.

Step 4: Set secrets

Save the value of OPENHUMAN_CORE_TOKEN. You need it to connect the desktop app later. Anyone with this token can drive the core, so 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

Verify the core is healthy:

Step 6: Point the desktop app at the hosted core

In the desktop app, open Settings > Core connection, switch it to the remote core, and enter:

  • URL: https://<your-app-name>.fly.dev/rpc

  • Token: the value you set in Step 4

Press Test connection to confirm before saving. The first-run picker takes the same two fields. See Pointing the desktop app at a hosted core.

Continuous deployment

To redeploy automatically on every push to main, add a workflow file at .github/workflows/fly-deploy.yml:

Generate a deploy token with fly tokens create deploy and add it as a repository secret named FLY_API_TOKEN.

Updating

For version-pinned deployments, update the image tag in .fly/fly.toml and redeploy:

Logs

Known issue: user ID mismatch on volumes

If you switch between building from the Dockerfile (which creates the openhuman user at UID 10001) and pulling the pre-built GHCR image (which uses UID 1000), files already on the persistent volume belong to the old UID. The core then fails at startup with Permission denied (os error 13).

The same thing happens to a Docker named volume that outlives an image upgrade, and to any workspace that a root docker exec wrote into. docker exec does not run the entrypoint, so it runs as root and leaves a root:root config.toml behind. The core writes that file with mode 0600, so the runtime user then cannot open it at all.

scripts/docker-entrypoint-core.sh repairs this automatically. On every start it recursively chowns the workspace and skips entries that already have the right owner. If the repair cannot run (for example cap_drop: ALL without cap_add: CHOWN), the entrypoint refuses to start and prints the exact chown to run. Otherwise you would get a container that answers /health with 200 while every config RPC returns Permission denied (os error 13). Look for [docker-entrypoint] pre-heal and FATAL lines in the container log.

To fix an older container, SSH in and re-own the workspace:

The Docker equivalent derives the ids from the container instead of hard-coding them, so it stays correct whichever image you run:

To see which UID owns what before repairing, ask about the runtime user explicitly. docker exec defaults to root, so a bare id would be misleading:


Smoke test

Two smoke checks cover the cloud deploy path:

  • docker-image sets OPENHUMAN_CORE_TOKEN and mounts no volume. It covers the DigitalOcean App Platform path (.do/app.yaml), where the token is always set and no persistent volume is used.

  • docker-volume-permissions omits OPENHUMAN_CORE_TOKEN and mounts a fresh anonymous volume at /home/openhuman/.openhuman. It reproduces the volume ownership failure and asserts that /health returns 200 and that Permission denied (os error 13) is absent from the logs.

Run the smoke check locally:


5. Oracle Cloud Infrastructure: Terraform on Always Free

A community-maintained Terraform stack runs the headless core on OCI's Always Free tier: an Ampere A1 VM in a private subnet running the release binary in a container, a flexible load balancer that forwards only /rpc, /health and /events, secrets in OCI Vault read by the VM's instance principal, OCI Generative AI as the model provider through the local-openai route above, and an Autonomous Database that agents reach through Oracle's managed Database Tools MCP Server once an operator registers a user access token for that server (the stack's post-apply step; the MCP server authenticates users, not the VM's instance principal).

Repository and run book: https://github.com/kamelhar/openhuman-oci. Like the Fly and DigitalOcean recipes, it sets OPENHUMAN_CORE_TOKEN, mounts the workspace on persistent storage, and keeps the core off the public internet except through the load balancer.


See also

Last updated