| description | 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 storage each one needs. |
|---|---|
| icon | cloud |
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:
- DigitalOcean App Platform: one-click
- DigitalOcean App Platform: manual via doctl
- Any VPS via Docker Compose
- Fly.io
- Oracle Cloud Infrastructure: Terraform on Always Free
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.
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:
ssh -L 1420:127.0.0.1:1420 -L 7788:127.0.0.1:7788 user@serverOpen 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:
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.
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):
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 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.
| 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.
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.
If you prefer not to click through the UI:
# 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 <app-id> --type build --followUpdate an existing app after editing the spec:
doctl apps update <app-id> --spec .do/app.yamlWorks 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:
docker pull ghcr.io/tinyhumansai/openhuman-core:latest # tracks the latest prod cut
docker pull ghcr.io/tinyhumansai/openhuman-core:v<version> # pinned by GitHub Release tag
docker pull ghcr.io/tinyhumansai/openhuman-core:<version> # pinned by SemVerTake <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:
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:latestOr 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.
# 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=<openssl rand -hex 32>
# OPENHUMAN_APP_ENV=production
# Build and start:
docker compose up -d
# Verify:
docker compose ps
curl -fsS http://localhost:7788/healthIf you can't run Docker on the host, grab the standalone CLI tarball attached to the latest GitHub Release:
# 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=<version> # the release you want, from the Releases page
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 --versionThen 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 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:
[update]
restart_strategy = "supervisor"
rpc_mutations_enabled = falseor the equivalent env vars:
OPENHUMAN_AUTO_UPDATE_RESTART_STRATEGY=supervisor
OPENHUMAN_AUTO_UPDATE_RPC_MUTATIONS_ENABLED=falseRecommended systemd stance:
Restart=always
ExecReload=/bin/kill -HUP $MAINPIDOperator 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.
git pull
docker compose build
docker compose up -dFor 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.
docker compose logs -f openhuman-coreOPENHUMAN_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:
# 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. Re-paste the new token on every desktop client, in
# Settings > Core connection. Clients that still hold the old token get
# HTTP 401 on the next /rpc call, which is expected rather than 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 token file to delete. The environment variable is the only state.
Use Caddy, nginx, or Traefik as a reverse proxy in front of :7788. A minimal
Caddyfile:
core.example.com {
reverse_proxy localhost:7788
}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.
{% hint style="warning" %}
A private address is not encryption. Over plain http://, every /rpc
request carries the bearer token in cleartext, and that token is the only
credential the core checks. Anyone who can observe the link can read it and
replay it, and "private" covers a shared office LAN, a hotel network and a
coffee-shop Wi-Fi subnet as readily as it covers your own machine. Treat plain
HTTP as safe only on a link you control end to end, such as loopback or an
encrypted overlay like Tailscale or WireGuard. Use HTTPS for anything else.
{% endhint %}
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.
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".
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.
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.
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:volumes: - name: master-key-secret secret: secretName: openhuman-master-key defaultMode: 0400 - name: master-key emptyDir: {} initContainers: - name: prepare-master-key image: busybox:1.36 command: ["sh", "-c", "cp /source/master.key /target/master.key && chown 10001:10001 /target/master.key && chmod 0400 /target/master.key"] volumeMounts: - name: master-key-secret mountPath: /source readOnly: true - name: master-key mountPath: /target containers: - name: openhuman securityContext: runAsUser: 10001 runAsGroup: 10001 runAsNonRoot: true env: - name: OPENHUMAN_KEYRING_MASTER_KEY_FILE value: /run/openhuman-master-key/master.key volumeMounts: - name: master-key mountPath: /run/openhuman-master-key readOnly: true
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.
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.
Fly.io suits openhuman-core. It handles TLS automatically, supports persistent volumes on all tiers, and can stop idle machines to cut costs.
- flyctl installed and authenticated (
fly auth login) - A Fly.io account
fly launch --no-deploy --config .fly/fly.tomlFly.io finds the Dockerfile automatically. Choose a region close to your users and skip the first deploy when prompted. This generates a config file.
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:
app = '<your-app-name>'
primary_region = '<your-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 = 1fly volumes create openhuman_workspace --size 5 --region <your-region> --config .fly/fly.tomlMount the workspace on a persistent volume, or data is lost on every redeploy.
# 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://<key>@o<org>.ingest.sentry.io/<project>"
fly secrets set OPENHUMAN_ANALYTICS_ENABLED="true"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.
fly deploy --config .fly/fly.tomlVerify the core is healthy:
curl -fsS https://<your-app-name>.fly.dev/healthIn 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.
To redeploy automatically on every push to main, add a workflow file at
.github/workflows/fly-deploy.yml:
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.
fly deploy --config .fly/fly.tomlFor version-pinned deployments, update the image tag in .fly/fly.toml and
redeploy:
[build]
image = "ghcr.io/tinyhumansai/openhuman-core:v<version>"fly logs --config .fly/fly.tomlIf 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:
fly ssh console --config .fly/fly.toml
chown -R openhuman:openhuman /home/openhuman/.openhuman/
exit
fly machine restart --config .fly/fly.tomlThe Docker equivalent derives the ids from the container instead of hard-coding them, so it stays correct whichever image you run:
docker exec -u 0 openhuman-core sh -c \
'chown -Rh "$(id -u openhuman):$(id -g openhuman)" /home/openhuman/.openhuman'
docker restart openhuman-coreTo 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:
docker exec openhuman-core sh -c 'id openhuman; ls -ln /home/openhuman/.openhuman/config.toml'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:
docker build -t openhuman-core:smoke .
# Optional: tune build profile and Cargo parallelism.
# Keep CARGO_BUILD_JOBS=1 on constrained builders; raise it on larger machines.
docker build --build-arg CARGO_PROFILE=release --build-arg CARGO_BUILD_JOBS=4 -t openhuman-core:release .
# 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-testA 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.
- 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.