> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mirobody.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Deploy on a Server

> Put the Docker stack behind HTTPS with production authentication, separate secrets, restricted ports and a verification step.

export const OssVersion = ({lang = "en"}) => <p className="text-sm text-gray-500 dark:text-gray-400">
    {lang === "zh" ? "对应 mirobody " : "Written for mirobody "}
    <a href="https://github.com/thetahealth/mirobody/tree/c1aae297b4f3fad5cb87c5fc5bffc5163f7758c9">
      <code>1.5.3</code>
    </a>
  </p>;

export const OssLink = ({path = "", children}) => {
  const base = "https://github.com/thetahealth/mirobody";
  const commit = "c1aae297b4f3fad5cb87c5fc5bffc5163f7758c9";
  const href = !path ? base + "/tree/" + commit : base + (path.endsWith("/") ? "/tree/" : "/blob/") + commit + "/" + path.replace(/\/$/, "");
  return <a href={href}>{children ?? <code>{path}</code>}</a>;
};

export const Fact = ({k, code = false, sep = ", "}) => {
  const facts = {
    "version": "1.5.3",
    "commit": "c1aae297b4f3fad5cb87c5fc5bffc5163f7758c9",
    "commitShort": "c1aae29",
    "repo": "https://github.com/thetahealth/mirobody",
    "python": "3.12",
    "port": 18060,
    "url": "http://localhost:18060",
    "pgPort": 18062,
    "redisPort": null,
    "account": "you@mirobody.ai",
    "accounts": ["you@mirobody.ai", "mom@mirobody.ai"],
    "code": "111111",
    "mcpUrlTtlDays": 10,
    "llmKeys": ["OPENROUTER_API_KEY", "DASHSCOPE_API_KEY", "GOOGLE_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "DEEPSEEK_API_KEY"],
    "cli": ["serve", "dev", "worker", "doctor", "fetch", "parse", "import", "resolve", "device-bundle", "mcp", "migrate-observations", "migrate-genotypes", "recode"],
    "tools": {
      "mcp": ["query_genetic_data", "query_health_indicators", "query_medications", "query_pharmacogenomics", "resolve_indicator", "convert_unit", "normalize_unit"],
      "gated": ["query_genetic_data", "query_pharmacogenomics", "query_health_indicators", "query_medications"]
    },
    "readme": {
      "en": {
        "tagline": "Self-hosted AI health data engine: every source, one standard, answers that cite their source.",
        "stages": [{
          "mark": "①",
          "name": "Collect",
          "what": "Lab reports, wearables, phone photos, genetic files, all pulled in. The source file is kept as it was, so every indicator points back to the page it was read from."
        }, {
          "mark": "②",
          "name": "Translate",
          "what": "One name to one code, one unit to UCUM, offline and deterministic. `A1c`, `HbA1c` and `Glycated Hemoglobin` become the same test here, and `头疼` and `headache` the same complaint (ICPC-3)."
        }, {
          "mark": "③",
          "name": "Agent",
          "what": "Ask over the coded record. Trend a value by minute, hour, day, week or month; get count, min, max, avg or change over any window in one call; compare across labs and devices, because they share one code. It charts the result in its reply, reads medications and genetic variants too, and names the file every number came from."
        }]
      },
      "zh": {
        "tagline": "自托管的 AI 原生健康数据引擎：任何来源，一套标准，每个答案都有出处。",
        "stages": [{
          "mark": "①",
          "name": "收集 Collect",
          "what": "化验单、穿戴设备、手机照片、基因文件，都收进来。源文件原样留下，每一项指标都能指回它被读出来的那一页。"
        }, {
          "mark": "②",
          "name": "转译 Translate",
          "what": "一个名字解析成一个码，一个单位统一到 UCUM，全程离线、结果确定。`A1c`、`HbA1c`、`糖化血红蛋白` 在这一层变成同一项检查，`头疼` 和 `headache` 也成了同一条主诉（ICPC-3）。"
        }, {
          "mark": "③",
          "name": "智能体 Agent",
          "what": "在编码后的记录上提问。按分钟、小时、天、周、月给出趋势，一次调用就能算出计数、最小值、最大值、均值和变化量；同一个码，跨化验所、跨设备直接比较。图表画在回复里，用药记录和基因型数据也读得了，每个数字都说明出自哪份文件。"
        }]
      }
    },
    "source": {
      "cli": "mirobody/cli.py",
      "tools": "mirobody/agent/tools"
    }
  };
  const value = k.split(".").reduce((o, p) => o == null ? undefined : o[p], facts);
  if (value === undefined) return <span>{"[unknown fact " + k + "]"}</span>;
  const items = Array.isArray(value) ? value : [value];
  return <>
      {items.map((item, i) => <span key={i}>
          {i > 0 ? sep : null}
          {code ? <code>{String(item)}</code> : String(item)}
        </span>)}
    </>;
};

<OssVersion lang="en" />

The defaults in the repository are tuned for a local, single-user trial: demo accounts with a published code, placeholder secrets, and the database ports published on the host. This page is the path from there to a deployment reachable by others. The security checklist it follows is <OssLink path="SECURITY.md" />.

## Host requirements

A Linux host with Docker Engine and the Compose plugin **2.24.4 or newer**, Git, and outbound access to the model provider your key belongs to. No GPU is needed. The stack runs three containers: Postgres with pgvector, the server and the worker, plus a one-shot container that fixes upload-volume ownership on each start. Capacity depends on concurrent users and file processing; this release does not publish a measured minimum host size.

## First start

Decide the posture **before** the first start: demo data is seeded on first boot, and the database password is set when its volume is created.

<Steps>
  <Step title="Clone the repository">
    ```bash theme={null}
    git clone https://github.com/thetahealth/mirobody.git && cd mirobody
    ```
  </Step>

  <Step title="Write .env">
    ```bash .env theme={null}
    ENV=prod
    SEED_DEMO_DATA=false
    OPENROUTER_API_KEY=...
    ```

    `ENV` names the overlay, `config.prod.yaml`. Any one of the supported model keys works; see [Configuration](/en/configuration#the-model-key).
  </Step>

  <Step title="Write the overlay">
    Create `config.prod.yaml` with the production posture below. `deploy.sh` wires in an overlay file already present under that name; it does not create one for you. Bind the app port to loopback in a `compose.override.yaml` before starting if the reverse proxy runs on this host.
  </Step>

  <Step title="Start the stack">
    ```bash theme={null}
    ./deploy.sh
    ```

    `deploy.sh` generates `PG_PASSWORD`, `PG_ENCRYPTION_KEY`, `CONFIG_ENCRYPTION_KEY`, `LOG_ENCRYPTION_KEY` and `JWT_KEY` into `.env` when they are missing, pulls the application image (or builds it from this checkout when it cannot), and starts Postgres, the server and the worker. Continue with the checks below before directing users to this host.
  </Step>
</Steps>

<h2 id="after-a-local-demo">
  After a local demo
</h2>

Use a **fresh deployment** for real records. Turning on `PRODUCTION` in the demo checkout stops future demo seeding, but it does not erase existing demo accounts or readings. Its Postgres password was fixed when the original volume was created, and changing encryption keys would make stored content unreadable. Reusing that volume is not a clean production start.

1. If you entered anything you need to keep in the demo, run `shell/backup.sh` there and store the backup and its matching `.env` and config files somewhere protected. This is a safety copy, **not** the starting database for production.
2. Prefer a new Linux host or VM. Clone the engine there and follow [First start](#first-start) from the beginning, including `SEED_DEMO_DATA=false`, the production overlay, `DATABASE_DECRYPTION_KEY` and HTTPS **before** running `./deploy.sh`.
3. If you must reuse the same host, run `docker compose down` in the **demo** checkout first. It stops the demo without deleting its volumes. Clone into a **different directory name** for production, keep `COMPOSE_PROJECT_NAME` unset or distinct, and follow [First start](#first-start) there. Compose prefixes named volumes with the project name; a different project gives the new stack empty Postgres and upload volumes. The old demo remains stopped and separate.
4. After the [deployment checks](#verify-the-deployment), register a new account and enter only the real records you intend to keep. Check that no demo accounts or demo readings appear. Keep the demo backup separate; restoring it into the new stack would bring the demo data back.

If the demo already contains real records you must migrate, preserve its database, uploads and **matching encryption keys**. Plan an explicit data transfer and account review before opening production to users; changing `PRODUCTION` alone is not that transfer.

<h2 id="production-posture">
  Production posture
</h2>

```yaml config.prod.yaml theme={null}
PRODUCTION: true
EMAIL_PREDEFINE_CODES: {}
```

With `PRODUCTION: true` the server refuses to start while any predefined sign-in code or any `REPLACE_THIS_VALUE_IN_PRODUCTION` placeholder remains, and it never seeds demo data; the error names what is left. `EMAIL_PREDEFINE_CODES: {}` removes the two demo accounts, because an overlay replaces the whole value.

People then sign in in one of three ways: by registering with a password in the web client (`POST /password/register`), with an email code once a mail server is configured (`EMAIL_FROM` and `EMAIL_SMTP_HOST`, `EMAIL_SMTP_PORT`, `EMAIL_SMTP_USER`, `EMAIL_SMTP_PASS`), or with Google or Apple sign-in, configured in `config.devices.yaml`.

Set `BOOTSTRAP_SCHEMA: false` as well if you provision the database schema yourself.

<h2 id="secrets">
  Secrets
</h2>

`./deploy.sh` writes `PG_PASSWORD`, `PG_ENCRYPTION_KEY`, `CONFIG_ENCRYPTION_KEY`, `LOG_ENCRYPTION_KEY` and `JWT_KEY` into `.env` on first run, each `openssl rand -hex 32`, and never overwrites a value already there (`.env` itself is written `chmod 600`). Compose passes `.env` to every container through `env_file`, so `PG_PASSWORD` reaches both the `pg` service (`POSTGRES_PASSWORD`) and the app; nothing else needs to carry it.

`DATABASE_DECRYPTION_KEY` is the one secret `deploy.sh` does not generate: it ships in `config.devices.yaml` as a `REPLACE_THIS_VALUE_IN_PRODUCTION` placeholder, and `PRODUCTION: true` refuses to start while that placeholder remains. Set it yourself, in `.env`, where it overrides the config file (environment variables take precedence over every config source):

```bash .env theme={null}
DATABASE_DECRYPTION_KEY=<32 ASCII characters>
```

Generate it with `openssl rand -hex 16`: the resulting **32 ASCII characters** are used directly as a 32-byte AES key, not decoded from hex.

Postgres applies `POSTGRES_PASSWORD` only when it creates an empty data volume. To move `PG_PASSWORD` onto an existing volume, change it with `ALTER USER` first, then update `.env` to match.

Keep `.env` out of version control, and do not reuse any of these keys between environments: data encrypted under one key cannot be read with another. Preserve `.env` alongside database and upload backups so a restore can read encrypted records. `CONFIG_ENCRYPTION_KEY` encrypts secret-shaped values (`_KEY`, `_PASSWORD`, and similar) that you add to the overlay itself, such as a device provider credential in `config.prod.yaml`; it has no effect on `.env`.

<h2 id="public-url-and-https">
  Public URL and HTTPS
</h2>

Terminate TLS in a reverse proxy in front of the server, which listens on port <Fact k="port" />. This example runs Caddy on the same host. Point the DNS name at that host and allow inbound ports 80 and 443 for certificate issuance and HTTPS. In `compose.override.yaml`, merge this service into the override from the Secrets section so the app is reachable only from the host and trusts the host-side proxy at the Compose network's configured gateway. The `!override` tag is essential: without it, Compose retains the base file's public port alongside the loopback port ([merge rules](https://docs.docker.com/reference/compose-file/merge/)).

```yaml compose.override.yaml theme={null}
services:
  mirobody:
    ports: !override
      - "127.0.0.1:18060:18060"
    environment:
      - FORWARDED_ALLOW_IPS=10.108.0.1
```

```caddy Caddyfile theme={null}
health.example.com {
    reverse_proxy 127.0.0.1:18060
}
```

Replace `health.example.com` with your domain. Caddy obtains HTTPS certificates when DNS and ports are ready; it forwards the original `Host` and proxy scheme. See [Caddy's reverse proxy guide](https://caddyserver.com/docs/quick-starts/reverse-proxy). With another proxy or network layout, set `FORWARDED_ALLOW_IPS` to the address **the app container actually sees** for that proxy.

```yaml config.prod.yaml theme={null}
MCP_PUBLIC_URL: https://health.example.com
```

`MCP_PUBLIC_URL` is the deployment's public origin; the server uses it for the links it prints and for file URLs. Restrict CORS with `HTTP_HEADERS` to the origins you actually serve.

<Warning>
  The personal MCP URL starts with the origin of the incoming request. If a proxy is outside the trusted address list, the server ignores its `X-Forwarded-Proto` and may issue an `http://` URL. Verify the URL from the public HTTPS site before sharing it.
</Warning>

Do not expose Postgres. `compose.yaml` already publishes it on the loopback interface only (<Fact k="pgPort" />), for local inspection, so nothing to change there. The application port is public in the base Compose file, so retain the loopback override above. Run `docker compose config` and confirm the effective `mirobody.ports` contains **only** the loopback binding.

## Verify the deployment

From the repository directory, check the containers and the local health response:

```bash theme={null}
docker compose ps
curl -fsS http://127.0.0.1:18060/api/health
```

The `mirobody` service should report `healthy`. The response contains `version` and `agent` fields. Then open the public HTTPS URL, register or sign in with a real account, and confirm that the personal MCP URL also begins with `https://`. Production mode rejects the shipped demo codes; if startup fails, read `docker compose logs --tail 80 mirobody` and [Troubleshooting](/en/troubleshooting).

## Backups and upgrades

The data lives in the Postgres volume and, when uploads are stored on local disk rather than in a bucket, in the upload volume. Use [Verify and Restore a Backup](/en/deployment/restore) for a restore rehearsal and recovery procedure; the engine's [Backup & Restore reference](/en/deployment/backup) explains the volumes in detail.

Use [Upgrade a Deployment](/en/deployment/upgrade) for the sequence: back up data and keys, review the target release, change the checkout, restart and verify. `deploy.sh` runs `docker compose up -d --remove-orphans`, which recreates a changed service in place; plan a maintenance window for the brief restart.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.