> For the complete documentation index, see [llms.txt](https://tinyhumans.gitbook.io/openhuman/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://tinyhumans.gitbook.io/openhuman/features/cloud-deploy.md).

# Cloud Deploy

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:

1. [DigitalOcean App Platform: one-click](#1-digitalocean-app-platform-one-click)
2. [DigitalOcean App Platform: manual via doctl](#2-digitalocean-app-platform-manual-via-doctl)
3. [Any VPS via Docker Compose](#3-any-vps-via-docker-compose)
4. [Fly.io](#4-flyio)

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:

```bash
# 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:

```bash
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:

```bash
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`](https://github.com/tinyhumansai/openhuman/tree/main/src/core/auth.rs)):

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.

**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`](https://github.com/tinyhumansai/openhuman/tree/main/scripts/print-core-token.sh) on the host (or inside the container with `docker compose exec`):

```bash
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`](https://github.com/tinyhumansai/openhuman/tree/main/.do/app.yaml):

[![Deploy to DO](https://www.deploytodo.com/do-btn-blue.svg)](https://cloud.digitalocean.com/apps/new?repo=https://github.com/tinyhumansai/openhuman/tree/main)

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 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](#3-any-vps-via-docker-compose) 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:

```bash
# 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:

```bash
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:

```bash
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:

```bash
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):

```bash
# 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](https://github.com/tinyhumansai/openhuman/releases/latest):

```bash
# 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 return `restart_requested=false`. Your outer service manager must restart the process.

For long-running Linux services, set:

```toml
[update]
restart_strategy = "supervisor"
rpc_mutations_enabled = false
```

or the equivalent env vars:

```bash
OPENHUMAN_AUTO_UPDATE_RESTART_STRATEGY=supervisor
OPENHUMAN_AUTO_UPDATE_RPC_MUTATIONS_ENABLED=false
```

Recommended `systemd` stance:

```ini
Restart=always
ExecReload=/bin/kill -HUP $MAINPID
```

Operator flow:

1. Call `openhuman.update_check` to discover a release.
2. Configure `restart_strategy = "supervisor"` in your `update.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`](https://github.com/tinyhumansai/openhuman/tree/main/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

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

```bash
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:

```bash
# 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`:

```caddy
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`):

```bash
# 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:

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

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

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 (issue #3025).

Root cause is almost always the **remote** core, not the desktop. `auth_store_session` makes the core validate the fresh session token against the backend (`GET /auth/me`) before persisting it; on a cloud runtime that call runs on your server, so it fails if the remote core can't reach/authenticate the backend:

* **`BACKEND_URL` unset or wrong.** It is required (see the env table above) and must be `https://api.tinyhumans.ai` for prod (or the staging URL). A missing value is the most common cause — one reporter's failure was exactly this.
* **Backend unreachable from the server** (egress firewall, DNS, TLS interception) → the core sees a gateway/timeout on `/auth/me`.
* **Outdated core.** Older cores predate `allowPendingBackendValidation` and validate synchronously with no grace; update the server to a current release.
* **Wrong RPC token.** A token mismatch surfaces as HTTP 401 on `/rpc`; re-paste the token (see "Rotating the bearer token").

Check the remote core logs for `Session validation failed (GET /auth/me)` and the status/reason. The desktop now reports a cloud-specific, actionable message for these instead of a generic "try again" (issue #3025).

***

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

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 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](https://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](https://fly.io/docs/flyctl/install/) installed and authenticated (`fly auth login`)
* A Fly.io account

### Step 1: Launch the app

```bash
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`](https://github.com/tinyhumansai/openhuman/tree/main/.fly/fly.toml). Fill in `<your-app-name>` and `<your-region>` with the values you chose during `fly launch`:

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

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

```bash
# 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

```bash
fly deploy --config .fly/fly.toml
```

Verify the core is healthy:

```bash
curl -fsS https://<your-app-name>.fly.dev/health
```

### Step 6: Point the desktop app at the hosted core

In `app/.env.local`:

```bash
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`:

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

```bash
fly deploy --config .fly/fly.toml
```

For version-pinned deployments, update the image tag in `.fly/fly.toml` and redeploy:

```toml
[build]
  image = "ghcr.io/tinyhumansai/openhuman-core:v1.2.4"
```

### Logs

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

The same thing happens to a Docker named volume that outlives an image upgrade, and to any workspace a **root** `docker exec` wrote into — `docker exec` does not run the entrypoint, so it lands as root and leaves a `root:root` `config.toml` behind (the core writes it at mode 0600, so the runtime user then cannot open it at all).

`scripts/docker-entrypoint-core.sh` now repairs this automatically: it chowns the workspace **recursively** on every start, skipping entries already owned correctly. If the repair cannot run — `cap_drop: ALL` without `cap_add: CHOWN` — the entrypoint refuses to start rather than booting a container that answers `/health` with 200 while every config RPC returns `Permission denied (os error 13)`, and prints the exact `chown` to run. Look for `[docker-entrypoint] pre-heal` / `FATAL` lines in the container log.

Fix an older container by SSH-ing in and re-owning the workspace:

```bash
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. Derive the ids from the container rather than hard-coding them, so this stays correct whichever image you are running:

```bash
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. Note `docker exec` defaults to **root**, so ask about the runtime user explicitly rather than trusting a bare `id`:

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

***

## Smoke test

Two failure modes guard the cloud deploy path:

* **`docker-image`**: sets `OPENHUMAN_CORE_TOKEN` and 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`**: omits `OPENHUMAN_CORE_TOKEN` and mounts a fresh anonymous volume at `/home/openhuman/.openhuman`. Reproduces the exact failure mode of issue #2065 and asserts that `/health` returns 200 and that `Permission denied (os error 13)` is absent from the logs.

Run the smoke check locally:

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