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 1420Then 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:
OPENHUMAN_CORE_TOKENenvironment 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.{workspace}/core.tokenfile: generated by the core on first boot only whenOPENHUMAN_CORE_TOKENis unset. Standaloneopenhuman-core runuses this so CLI clients cancatthe 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
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:
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:
Then, in the App Platform UI, before the first deploy completes:
Open the Settings > App-Level Environment Variables tab.
Replace the placeholder
OPENHUMAN_CORE_TOKENvalue with a strong secret (openssl rand -hex 32). Mark it encrypted.If you are deploying staging, change
OPENHUMAN_APP_ENVtostagingandBACKEND_URLtohttps://staging-api.tinyhumans.ai.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 returnrestart_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:
Call
openhuman.update_checkto discover a release.Configure
restart_strategy = "supervisor"under[update]in the core'sconfig.toml(or setOPENHUMAN_AUTO_UPDATE_RESTART_STRATEGY=supervisor) so the core stages the new binary without trying to re-exec itself, then callopenhuman.update_applyoropenhuman.update_run.restart_strategyis a configuration setting, not an RPC parameter.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:
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 olderauth_store_sessionname through an alias, but a core older than that alias rejects the call. Update the server to a current release.BACKEND_URLunset 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 behttps://api.tinyhumans.aifor 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:
Set
LOCAL_OPENAI_URL=https://<your endpoint>/v1in the core's environment (OPENHUMAN_LOCAL_INFERENCE_URLalso works and overrides it).Store the endpoint's bearer key with
openhuman.config_update_local_ai_settings(runtime_enabled: true,opt_in_confirmed: true,api_key: "<key>").Pin the workloads with
openhuman.config_update_model_settings:chat_provider,reasoning_provider,agentic_provider,coding_provider(andvision_provider,memory_provider,embeddings_providerif you want them off the managed route) tolocal-openai:<model-id>, plusdefault_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
runAsUserdoes 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 anemptyDir,chownthe copy to the core UID, andchmod 0400it before the core starts. Mount the original Secret read-only in the init container and theemptyDirread-only in the core container. For example:Set the
emptyDirmedium and the core UID/GID to match the ownership applied by the init container. ThedefaultMode: 0400setting is important. For a direct non-root mount, also providefsGroup(or equivalent group access) matching the core's group so kubelet can make the file readable; the resulting read-only0440mode is accepted. The init-container recipe above instead grants access by changing the copied file's owner. An equivalent setup is a secret volume whosedefaultModeis0400and whose file is mounted with ownership matching the core'srunAsUser/runAsGroup.Docker Swarm: set
mode: 0400(withuid/gidfor a non-root user) on the service's secret.A plain file:
chmod 600it, 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:
Starts as
root.Runs
mkdir -p+chown openhuman:openhumanon both$OPENHUMAN_WORKSPACEand$HOME/.openhuman(the directorycore.tokenis written to whenOPENHUMAN_CORE_TOKENis unset).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/rpcToken: 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-imagesetsOPENHUMAN_CORE_TOKENand 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-permissionsomitsOPENHUMAN_CORE_TOKENand mounts a fresh anonymous volume at/home/openhuman/.openhuman. It reproduces the volume ownership failure and asserts that/healthreturns 200 and thatPermission 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
Privacy and security: the trust model a server-hosted core inherits.
OS keyring and secret storage: why a container needs a keyring master key.
Local models and bring your own key: running a headless core with no TinyHumans account.
Building the Rust core: compiling the binary a non-Docker host runs.
Architecture: what the core owns versus the shell.
Last updated