| description | Hosting the headless openhuman-core in the cloud - DigitalOcean App Platform, Fly.io, or Docker Compose on any VPS. |
|---|---|
| icon | cloud |
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:
- DigitalOcean App Platform: one-click
- DigitalOcean App Platform: manual via doctl
- Any VPS via Docker Compose
- Fly.io
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.
Every /rpc call carries Authorization: Bearer <token>. The core has two
ways to load that token at startup (src/core/auth.rs):
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.
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
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 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.
| 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.
Click the button below to create a new 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 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 for a self-host alternative with persistent volumes out of the box.
If you'd rather not 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:v1.2.4 # pinned by GitHub Release tag
docker pull ghcr.io/tinyhumansai/openhuman-core:1.2.4 # pinned by SemVerThe 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.
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 (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):
# 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=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 --versionThen run openhuman-core serve under your service manager of choice
(systemd, supervisord, …) with the same environment variables documented
above.
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"in yourupdate.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. 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.
Use Caddy, nginx, or Traefik as a reverse proxy in front of :7788. A minimal
Caddyfile:
core.example.com {
reverse_proxy localhost:7788
}In the desktop app's environment file (app/.env.local):
# 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=<the same token you set on the server>For a private tailnet-only VM with no public IP, use the tailnet URL instead:
OPENHUMAN_CORE_RUN_MODE=external
OPENHUMAN_CORE_RPC_URL=http://100.x.x.x:7788/rpc
OPENHUMAN_CORE_TOKEN=<the same token you set on the server>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.
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:
- 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 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.
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.
- flyctl installed and authenticated (
fly auth login) - A Fly.io account
fly launch --no-deploy --config .fly/fly.tomlFly.io detects 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 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.
fly deploy --config .fly/fly.tomlVerify the core is healthy:
curl -fsS https://<your-app-name>.fly.dev/healthIn app/.env.local:
OPENHUMAN_CORE_RUN_MODE=external
OPENHUMAN_CORE_RPC_URL=https://<your-app-name>.fly.dev/rpc
OPENHUMAN_CORE_TOKEN=<the token you set in Step 4>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.
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:v1.2.4"fly logs --config .fly/fly.tomlIf 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:
fly ssh console --config .fly/fly.toml
chown -R openhuman:openhuman /home/openhuman/.openhuman/
exit
fly machine restart --config .fly/fly.tomlThe repo ships .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— setsOPENHUMAN_CORE_TOKENand 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— omitsOPENHUMAN_CORE_TOKENand mounts a fresh anonymous volume at/home/openhuman/.openhuman. Reproduces the exact failure mode of issue #2065 and asserts that/healthreturns 200 and thatPermission denied (os error 13)is absent from the logs.
To run the same check locally:
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