Cloud deployment
May 23, 2026 · View on GitHub
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.
Remote UI choices
OpenHuman's supported remote deployment is core remote, UI local: run
openhuman-core on a Linux server and point a desktop client at that RPC URL.
The deployed core does not serve the full React/Tauri UI as a production web
app yet. Desktop-only features still need the Tauri shell, including tray
controls, native deep links, CEF account scanners, OS keychain integration, and
window/screen affordances.
For a browser-accessible UI on a private server today, use the Vite web build as a development/preview surface against the remote core:
# 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:
ssh -L 1420:127.0.0.1:1420 -L 7788:127.0.0.1:7788 user@server
Open http://127.0.0.1:1420, choose the remote/core option on the first-run
screen, and enter http://127.0.0.1:7788/rpc plus the
OPENHUMAN_CORE_TOKEN value from the server.
If you serve the browser UI from a non-loopback origin, add that exact origin to the core's CORS allowlist:
export OPENHUMAN_CORE_ALLOWED_ORIGINS="https://openhuman-ui.example.com"
Loopback Vite origins such as http://127.0.0.1:1420 and
http://localhost:1420 are allowed automatically. Public http:// origins are
not recommended because every RPC call carries the bearer token.
Single source of truth for the bearer token
Every /rpc call carries Authorization: Bearer <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.
What you need before you start
| Setting | Required | Notes |
|---|---|---|
OPENHUMAN_CORE_TOKEN | yes | Bearer token clients send to /rpc. Generate with openssl rand -hex 32. Anyone with this token can drive the core. |
BACKEND_URL | yes | Tinyhumans backend the core talks to (https://api.tinyhumans.ai for prod). |
OPENHUMAN_APP_ENV | no | production or staging. Defaults to production. |
OPENHUMAN_CORE_HOST | no | Defaults to 0.0.0.0 in the container. |
OPENHUMAN_CORE_PORT | no | Defaults to 7788. |
RUST_LOG | no | info is fine; debug for triage. |
Endpoints exposed by the running container:
GET /health, public liveness probe. Used by every deploy path's healthcheck.POST /rpc, bearer-protected JSON-RPC entrypoint.GET /events,GET /ws/dictation, public streaming channels.
The OPENHUMAN_WORKSPACE directory (/home/openhuman/.openhuman inside the
container) holds the core's config, sqlite databases, and skill state. Mount
it on a persistent volume in every production deploy or you will lose data on
restart.
1. DigitalOcean App Platform: one-click
Click the button below to create a new App Platform application from this
repository's .do/app.yaml:
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.
2. DigitalOcean App Platform: manual via doctl
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 --follow
Update an existing app after editing the spec:
doctl apps update <app-id> --spec .do/app.yaml
3. Any VPS via Docker Compose
Works on any host with Docker Engine ≥ 24 and the Compose plugin. DigitalOcean Droplet, Hetzner, Linode, EC2, a home server.
Each production release publishes a multi-tagged image to GHCR:
docker pull ghcr.io/tinyhumansai/openhuman-core:latest # tracks the latest prod cut
docker pull ghcr.io/tinyhumansai/openhuman-core:v1.2.4 # pinned by GitHub Release tag
docker pull ghcr.io/tinyhumansai/openhuman-core:1.2.4 # pinned by SemVer
The image is linux/amd64. arm64 hosts pull the standalone tarball
attached to the same GitHub Release (openhuman-core-<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:latest
Or use the in-repo Compose file (still builds the image locally from
Dockerfile; switch the image: field to ghcr.io/tinyhumansai/openhuman-core:latest
in docker-compose.yml to consume the published image instead):
# 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/health
Headless install without Docker
If you can't run Docker on the host, grab the standalone CLI tarball attached to the latest GitHub Release:
# Pick the tarball that matches your host arch.
ARCH="$(uname -m)"
case "$ARCH" in
x86_64) TARGET=x86_64-unknown-linux-gnu ;;
aarch64) TARGET=aarch64-unknown-linux-gnu ;;
*) echo "Unsupported arch: $ARCH"; exit 1 ;;
esac
VERSION=1.2.4 # set to the release you want
curl -fsSL "https://github.com/tinyhumansai/openhuman/releases/download/v${VERSION}/openhuman-core-${VERSION}-${TARGET}.tar.gz" \
| tar -xz -C /usr/local/bin
openhuman-core --version
Then run openhuman-core serve under your service manager of choice
(systemd, supervisord, …) with the same environment variables documented
above.
Headless self-update contract
Headless deployments should treat openhuman.update_apply as the safe primitive:
it downloads the release asset, writes it atomically next to the current binary,
and returns. Nothing exits automatically.
openhuman.update_run follows config.update.restart_strategy:
self_replace(default): stage the binary, publish an in-process restart request, and let the running core respawn itself.supervisor: stage the binary and returnrestart_requested=false. Your outer service manager must restart the process.
For long-running Linux services, set:
[update]
restart_strategy = "supervisor"
rpc_mutations_enabled = false
or the equivalent env vars:
OPENHUMAN_AUTO_UPDATE_RESTART_STRATEGY=supervisor
OPENHUMAN_AUTO_UPDATE_RPC_MUTATIONS_ENABLED=false
Recommended systemd stance:
Restart=always
ExecReload=/bin/kill -HUP $MAINPID
Operator 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.
Updating
git pull
docker compose build
docker compose up -d
For RPC-exposed production deployments, prefer leaving mutating update RPCs
disabled (OPENHUMAN_AUTO_UPDATE_RPC_MUTATIONS_ENABLED=false) and perform
rollouts through your existing image tag or package-management flow instead.
Logs
docker compose logs -f openhuman-core
Rotating the bearer token
OPENHUMAN_CORE_TOKEN is the only thing standing between the public internet
and full RPC access. Rotate it on a schedule and after any suspected leak:
# 1. Generate a new token and update the server-side .env.
openssl rand -hex 32 > /tmp/new-token
sed -i.bak "s|^OPENHUMAN_CORE_TOKEN=.*|OPENHUMAN_CORE_TOKEN=$(cat /tmp/new-token)|" .env
rm /tmp/new-token .env.bak
# 2. Restart the container so the new value reaches the core process.
docker compose up -d --force-recreate openhuman-core
# 3. Confirm the running container is using the new token (redacted).
docker compose exec openhuman-core /bin/sh -c \
'echo -n "$OPENHUMAN_CORE_TOKEN" | head -c 8; echo "…"'
# 4. Update every desktop client (Switch mode → re-paste in the picker, or
# edit OPENHUMAN_CORE_TOKEN in app/.env.local and relaunch). Clients that
# still hold the old token will get HTTP 401 on the next /rpc call — that
# is expected, not a regression.
For App Platform, do the same in Settings → App-Level Environment
Variables: edit the OPENHUMAN_CORE_TOKEN secret and let App Platform
redeploy. There is no separate token file to delete; the env var is the only
state.
Putting it behind TLS
Use Caddy, nginx, or Traefik as a reverse proxy in front of :7788. A minimal
Caddyfile:
core.example.com {
reverse_proxy localhost:7788
}
Pointing the desktop app at a hosted core
In the desktop app's environment file (app/.env.local):
# 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.
Named-volume ownership and the Docker entrypoint
Docker creates named volumes owned root:root by default. Because the core
runs as the non-root openhuman user (UID 10001), the first write after the
banner — init_rpc_token → write_token_file into $OPENHUMAN_WORKSPACE —
would raise Permission denied (os error 13) if nothing fixes the ownership
first.
The image ships a dedicated entrypoint at
/usr/local/bin/docker-entrypoint-core.sh that:
- 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.
4. Fly.io
Fly.io is a good fit for openhuman-core: it handles TLS
automatically, supports persistent volumes on all tiers, and can auto-stop
idle machines to cut costs.
Prerequisites
- flyctl installed and authenticated (
fly auth login) - A Fly.io account
Step 1 — Launch the app
fly launch --no-deploy --config .fly/fly.toml
Fly.io detects the Dockerfile automatically. Choose a region close to your
users and skip the first deploy when prompted. This generates a config file.
Step 2 — Configure .fly/fly.toml
The repo ships a template at .fly/fly.toml. 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 = 1
Step 3 — Create a persistent volume
fly volumes create openhuman_workspace --size 5 --region <your-region> --config .fly/fly.toml
Mount the workspace on a persistent volume or data is lost on every redeploy.
Step 4 — Set secrets
# 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.
Step 5 — Deploy
fly deploy --config .fly/fly.toml
Verify the core is healthy:
curl -fsS https://<your-app-name>.fly.dev/health
Step 6 — Point the desktop app at the hosted core
In 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.
Continuous deployment
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.
Updating
fly deploy --config .fly/fly.toml
For version-pinned deployments, update the image tag in .fly/fly.toml and
redeploy:
[build]
image = "ghcr.io/tinyhumansai/openhuman-core:v1.2.4"
Logs
fly logs --config .fly/fly.toml
Known gotcha — UID mismatch on volumes
If you switch between building from Dockerfile (which creates the
openhuman user at UID 10001) and pulling the pre-built GHCR image (which
uses UID 1000), files already written to the persistent volume will be owned
by the old UID and produce Permission denied (os error 13) on startup.
Fix by SSH-ing in and re-owning the workspace:
fly ssh console --config .fly/fly.toml
chown -R openhuman:openhuman /home/openhuman/.openhuman/
exit
fly machine restart --config .fly/fly.toml
Smoke test
The repo ships .github/workflows/deploy-smoke.yml,
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 .
# 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-test