Skip to content

Latest commit

 

History

History
792 lines (589 loc) · 37.5 KB

cloud-deploy.md

File metadata and controls

792 lines (589 loc) · 37.5 KB
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

Cloud deploy

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:

  1. DigitalOcean App Platform: one-click
  2. DigitalOcean App Platform: manual via doctl
  3. Any VPS via Docker Compose
  4. Fly.io
  5. 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.


Remote UI choices

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

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

# On the server, run the core with an explicit token.
export OPENHUMAN_CORE_HOST=0.0.0.0
export OPENHUMAN_CORE_PORT=7788
export OPENHUMAN_CORE_TOKEN="$(openssl rand -hex 32)"
openhuman-core serve

# In another shell on the server, serve the UI only on loopback.
pnpm --dir app dev -- --host 127.0.0.1 --port 1420

Then tunnel both ports from your workstation:

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.


The bearer token

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

  1. OPENHUMAN_CORE_TOKEN environment variable: pre-seeded by the caller (Tauri shell, Docker, App Platform, systemd unit, …). The core uses this value as-is and never writes a file.
  2. {workspace}/core.token file: generated by the core on first boot only when OPENHUMAN_CORE_TOKEN is unset. Standalone openhuman-core run uses this so CLI clients can cat the file.

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

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

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.


What you need before you start

Setting Required Notes
OPENHUMAN_CORE_TOKEN yes Bearer token clients send to /rpc. Generate with openssl rand -hex 32. Anyone with this token can drive the core.
BACKEND_URL yes Tinyhumans backend the core talks to (https://api.tinyhumans.ai for prod).
OPENHUMAN_APP_ENV no production or staging. Defaults to production.
OPENHUMAN_KEYRING_MASTER_KEY yes, if no OS keychain 64 hex characters (openssl rand -hex 32). The encrypted_file keyring is now the default even when OPENHUMAN_APP_ENV is unset. Inject the key from your secret manager; losing it orphans every stored secret.
OPENHUMAN_KEYRING_MASTER_KEY_FILE alternative Path to a file holding the same value, for Docker/Kubernetes secret mounts. Set one of the two, not both. Without either, encrypted_file needs an OS keychain.
OPENHUMAN_CORE_HOST no Defaults to 0.0.0.0 in the container.
OPENHUMAN_CORE_PORT no Defaults to 7788.
RUST_LOG no info is fine; debug for triage.

The running container exposes these endpoints:

Endpoint Auth
GET /health None. The liveness probe every deploy path's healthcheck uses.
GET / None. Info page naming the other routes.
GET /schema None. Read-only schema discovery.
POST /rpc The bearer token. This is the executable surface.
GET /events Exempt from the header check, because a browser EventSource cannot set headers, but the handler enforces its own credential. Not an open stream.
GET /ws/dictation The bearer token, as a header or ?token=, checked before the WebSocket upgrade.
GET /oauth/mcp/callback Exempt from the bearer middleware: an external authorization server posts back here with no bearer. The one-time state, minted in oauth_begin, is the guard.
GET /dev/connect Exempt from the middleware, guarded in the handler instead: a debug build or an explicit opt-in, a loopback app origin and Host, and no cross-site navigation. It should not be reachable on a deployed core.
POST /v1/chat/completions, GET /v1/models The OpenAI-compatible surface, behind core's http-server gate. These accept the core bearer or an external provider key, so they are executable too, not just /rpc.

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

The OPENHUMAN_WORKSPACE directory (/home/openhuman/.openhuman inside the container) holds the core's config, SQLite databases and skill state. Mount it on a persistent volume in every production deploy, or you will lose data on restart.


1. DigitalOcean App Platform: one-click

Click the button below to create an App Platform application from this repository's .do/app.yaml:

Deploy to DO

Then, in the App Platform UI, before the first deploy completes:

  1. Open the Settings > App-Level Environment Variables tab.
  2. Replace the placeholder OPENHUMAN_CORE_TOKEN value with a strong secret (openssl rand -hex 32). Mark it encrypted.
  3. If you are deploying staging, change OPENHUMAN_APP_ENV to staging and BACKEND_URL to https://staging-api.tinyhumans.ai.
  4. Hit Save. App Platform redeploys with the new secret.

App Platform handles TLS, restart-on-crash, log streaming, and rolling redeploys on git push (set deploy_on_push: true in .do/app.yaml to opt-in).

Persistence: App Platform Basic has no block storage. The core's workspace lives in the container's ephemeral filesystem and is lost on redeploy. For durable storage, attach a managed database or upgrade to a tier that supports volumes. The Compose path gives you persistent volumes out of the box.


2. DigitalOcean App Platform: manual via doctl

If you prefer not to click through the UI:

# 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:v<version>      # pinned by GitHub Release tag
docker pull ghcr.io/tinyhumansai/openhuman-core:<version>       # pinned by SemVer

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:

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, 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/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=<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 --version

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

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

Headless self-update contract

Headless deployments should treat openhuman.update_apply as the safe primitive: it downloads the release asset, writes it atomically next to the current binary, and returns. Nothing exits automatically.

openhuman.update_run follows config.update.restart_strategy:

  • self_replace (default): stage the binary, publish an in-process restart request, and let the running core respawn itself.
  • supervisor: stage the binary and return restart_requested=false. Your outer service manager must restart the process.

For long-running Linux services, set:

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

  1. Call openhuman.update_check to discover a release.
  2. Configure restart_strategy = "supervisor" under [update] in the core's config.toml (or set OPENHUMAN_AUTO_UPDATE_RESTART_STRATEGY=supervisor) so the core stages the new binary without trying to re-exec itself, then call openhuman.update_apply or openhuman.update_run. restart_strategy is a configuration setting, not an RPC parameter.
  3. Restart the unit explicitly: systemctl restart openhuman.

If download or staging fails, the running binary is left in place and no restart is requested. If a staged binary proves bad after restart, roll back by restoring the previous binary from your package manager, image tag, or release artifact and restarting the supervisor again.

The Compose file (docker-compose.yml) maps the core on :7788, mounts a named volume openhuman-workspace for persistence, and sets restart: unless-stopped so the core comes back after host reboots.

Updating

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. 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.

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

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.

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

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

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

  • Wrong RPC token or URL. A token mismatch shows up as HTTP 401 on /rpc. Paste the token again (see "Rotating the bearer token") and check the URL.
  • Outdated core. Older cores do not have auth.set_credential. The desktop still sends the older auth_store_session name through an alias, but a core older than that alias rejects the call. Update the server to a current release.
  • BACKEND_URL unset or wrong on the server. The remote core no longer validates the session, but every backend call it makes for you (billing, teams, managed inference) still goes there. It must be https://api.tinyhumans.ai for production, or the staging URL.

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

Headless sign-in

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

  • OPENHUMAN_BACKEND_API_KEY=<key>: a TinyHumans API key. This is the recommended option. It has no user identity and nothing to expire.
  • OPENHUMAN_BACKEND_SESSION_TOKEN=<jwt>: a session JWT with a subject claim.

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

Headless without a TinyHumans account

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

  1. Set LOCAL_OPENAI_URL=https://<your endpoint>/v1 in the core's environment (OPENHUMAN_LOCAL_INFERENCE_URL also works and overrides it).
  2. Store the endpoint's bearer key with openhuman.config_update_local_ai_settings (runtime_enabled: true, opt_in_confirmed: true, api_key: "<key>").
  3. Pin the workloads with openhuman.config_update_model_settings: chat_provider, reasoning_provider, agentic_provider, coding_provider (and vision_provider, memory_provider, embeddings_provider if you want them off the managed route) to local-openai:<model-id>, plus default_model: "<model-id>". Those are the whole set the controller accepts.

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

Secret storage in containers

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

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

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

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

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

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


Named-volume ownership and the Docker entrypoint

Docker creates named volumes owned by root:root. The core runs as the non-root openhuman user (UID 10001), so its first write into $OPENHUMAN_WORKSPACE would fail with Permission denied (os error 13) if nothing fixed the ownership first.

The image ships a dedicated entrypoint at /usr/local/bin/docker-entrypoint-core.sh that:

  1. Starts as root.
  2. Runs mkdir -p + chown openhuman:openhuman on both $OPENHUMAN_WORKSPACE and $HOME/.openhuman (the directory core.token is written to when OPENHUMAN_CORE_TOKEN is unset).
  3. Calls exec gosu openhuman openhuman-core "$@" to drop privileges and hand off to the binary.

This is idempotent. On a new volume the chown fixes the root-owned directory. On a volume that was already fixed, it does nothing. You don't need to run docker volume rm when upgrading from an older image.

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


4. Fly.io

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

Prerequisites

  • flyctl installed and authenticated (fly auth login)
  • A Fly.io account

Step 1: Launch the app

fly launch --no-deploy --config .fly/fly.toml

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:

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

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 the desktop app, open Settings > Core connection, switch it to the remote core, and enter:

  • URL: https://<your-app-name>.fly.dev/rpc
  • Token: the value you set in Step 4

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

Continuous deployment

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

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:v<version>"

Logs

fly logs --config .fly/fly.toml

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:

fly ssh console --config .fly/fly.toml
chown -R openhuman:openhuman /home/openhuman/.openhuman/
exit
fly machine restart --config .fly/fly.toml

The 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-core

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:

docker exec openhuman-core sh -c 'id openhuman; ls -ln /home/openhuman/.openhuman/config.toml'

Smoke test

Two smoke checks cover the cloud deploy path:

  • docker-image sets OPENHUMAN_CORE_TOKEN and mounts no volume. It covers the DigitalOcean App Platform path (.do/app.yaml), where the token is always set and no persistent volume is used.
  • docker-volume-permissions omits OPENHUMAN_CORE_TOKEN and mounts a fresh anonymous volume at /home/openhuman/.openhuman. It reproduces the volume ownership failure and asserts that /health returns 200 and that Permission denied (os error 13) is absent from the logs.

Run the smoke check locally:

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

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