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

# Self-hosted

> Run Manifest on your own machine with Docker. Covers the quick installer, docker-compose, bringing your own PostgreSQL, signed images, and telemetry opt-out.

Run the full Manifest stack on your own machine. No Node.js required, just Docker.

All three paths end in the same place: a running stack at [http://localhost:2099](http://localhost:2099) where you sign up. The first account you create becomes the admin. No demo credentials are pre-seeded.

<Note>
  The bundled compose file binds port 2099 to `127.0.0.1` only, so the dashboard is reachable on the host machine but not over the LAN. See [Exposing on the LAN](#exposing-on-the-lan) to change this.
</Note>

## Installation

<Tabs>
  <Tab title="Quick install (recommended)">
    One command. The installer downloads the compose file into `~/manifest`, generates a secret, and brings up the stack. First boot pulls the app image and Postgres, so give it up to a couple of minutes.

    ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
    bash <(curl -sSL https://raw.githubusercontent.com/mnfst/manifest/main/docker/install.sh)
    ```

    <Accordion title="Prefer to review the script before running it?">
      ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
      curl -sSLO https://raw.githubusercontent.com/mnfst/manifest/main/docker/install.sh
      less install.sh
      bash install.sh
      ```
    </Accordion>

    Useful flags: `--dir <path>` to install elsewhere, `--dry-run` to preview, `--yes` to skip the confirmation prompt.

    When the installer finishes, open [http://localhost:2099](http://localhost:2099) and sign up for an account. Then head to the [Routing](http://localhost:2099/routing) page to add an LLM provider (OpenAI, Anthropic, Gemini, etc.) with your API key.
  </Tab>

  <Tab title="Docker Compose">
    Same underlying flow as the install script, but you drive it yourself so you can edit the config before booting the stack.

    <Steps>
      <Step title="Download the compose file and the env template">
        ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
        curl -O https://raw.githubusercontent.com/mnfst/manifest/main/docker/docker-compose.yml
        curl -O https://raw.githubusercontent.com/mnfst/manifest/main/docker/.env.example
        cp .env.example .env
        ```
      </Step>

      <Step title="Set a real BETTER_AUTH_SECRET">
        Open `.env` in your editor and set `BETTER_AUTH_SECRET` to a random string. You can generate one with:

        ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
        openssl rand -hex 32
        ```

        Optional: to use a stronger database password, set both `POSTGRES_PASSWORD` and `DATABASE_URL` in `.env` — they must agree, and any special characters in the password need to be percent-encoded in the URL.
      </Step>

      <Step title="Start the stack">
        ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
        docker compose up -d
        ```

        Give it up to a couple of minutes on a cold pull — you can watch startup with `docker compose logs -f manifest`.
      </Step>

      <Step title="Create your admin account">
        Go to [http://localhost:2099](http://localhost:2099) and sign up. The first account you create becomes the admin.
      </Step>

      <Step title="Connect a provider">
        Open the [Routing](http://localhost:2099/routing) page and add an LLM provider (OpenAI, Anthropic, Gemini, etc.) with your API key.
      </Step>
    </Steps>

    <Warning>
      Before exposing this instance beyond localhost, double-check that `BETTER_AUTH_SECRET` is a real secret (not the placeholder), and if you enable email verification, set `BETTER_AUTH_URL` to a reachable public URL so the verification links resolve.
    </Warning>
  </Tab>

  <Tab title="Docker Run (BYO PostgreSQL)">
    If you already have a PostgreSQL instance, replace `user`, `pass`, and `host` with your actual database credentials:

    ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
    docker run -d \
      -p 2099:2099 \
      -e DATABASE_URL=postgresql://user:pass@host:5432/manifest \
      -e BETTER_AUTH_SECRET=$(openssl rand -hex 32) \
      -e BETTER_AUTH_URL=http://localhost:2099 \
      manifestdotbuild/manifest
    ```

    <Accordion title="Windows (PowerShell)">
      ```powershell theme={"theme":{"light":"github-light","dark":"github-dark"}}
      $secret = -join ((48..57 + 97..122) | Get-Random -Count 64 | ForEach-Object { [char]$_ })

      docker run -d `
        -p 2099:2099 `
        -e DATABASE_URL=postgresql://user:pass@host:5432/manifest `
        -e BETTER_AUTH_SECRET=$secret `
        -e BETTER_AUTH_URL=http://localhost:2099 `
        manifestdotbuild/manifest
      ```
    </Accordion>

    <Accordion title="Windows (CMD)">
      Generate a 64-character hex secret with any tool you trust, then:

      ```cmd theme={"theme":{"light":"github-light","dark":"github-dark"}}
      docker run -d ^
        -p 2099:2099 ^
        -e DATABASE_URL=postgresql://user:pass@host:5432/manifest ^
        -e BETTER_AUTH_SECRET=<your-64-char-secret> ^
        -e BETTER_AUTH_URL=http://localhost:2099 ^
        manifestdotbuild/manifest
      ```
    </Accordion>
  </Tab>
</Tabs>

## Verify

After connecting a provider, send a test request and watch it land in the dashboard. Grab your Manifest API key from the dashboard (it starts with `mnfst_`) and run:

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
curl -X POST http://localhost:2099/v1/chat/completions \
  -H "Authorization: Bearer mnfst_YOUR_KEY_HERE" \
  -H "Content-Type: application/json" \
  -d '{"model": "auto", "messages": [{"role": "user", "content": "Hello"}]}'
```

If the response comes back with `That doesn't look like a Manifest key`, you're still using the placeholder — replace `mnfst_YOUR_KEY_HERE` with the real key from the dashboard.

## Verifying the image signature

Published images are signed with cosign keyless signing (Sigstore). Verify before pulling:

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
cosign verify manifestdotbuild/manifest:<version> \
  --certificate-identity-regexp="^https://github.com/mnfst/manifest/" \
  --certificate-oidc-issuer="https://token.actions.githubusercontent.com"
```

## Custom port

If port 2099 is taken, change both the mapping and `BETTER_AUTH_URL`:

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
docker run -d \
  -p 8080:2099 \
  -e BETTER_AUTH_URL=http://localhost:8080 \
  ...
```

Or in `docker-compose.yml`:

```yaml theme={"theme":{"light":"github-light","dark":"github-dark"}}
ports:
  - '127.0.0.1:8080:2099'
```

...and in `.env`:

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
BETTER_AUTH_URL=http://localhost:8080
```

<Warning>If you see an "Invalid origin" error on the login page, `BETTER_AUTH_URL` doesn't match the URL you're accessing the dashboard on. The host matters as much as the port.</Warning>

<Note>
  **Upgrading from a pre-2099 install?** Your existing stack keeps running on port 3001 with no changes — the backend's own fallback is still `3001`, so the new image works against your old compose file. If you want to refresh your compose file but stay on the legacy port (to avoid reconfiguring OAuth callbacks, reverse proxies, or bookmarks), set `PORT=3001` in `.env` and the bundled compose file will honour it for both the host binding and the internal listener.
</Note>

## Exposing on the LAN

By default the compose file binds port 2099 to `127.0.0.1` only. The dashboard is reachable from the host but not from other machines on the network. To expose it on the LAN:

<Steps>
  <Step title="Change the port binding">
    Edit `docker-compose.yml` and change the `ports` line from `"127.0.0.1:2099:2099"` to `"2099:2099"`.
  </Step>

  <Step title="Set BETTER_AUTH_URL">
    In `.env`, set `BETTER_AUTH_URL` to the host you'll reach the dashboard on, e.g. `http://192.168.1.20:2099` or `https://manifest.mydomain.com`. This must match the URL in the browser or Better Auth will reject the login with "Invalid origin".
  </Step>

  <Step title="Apply">
    ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
    docker compose up -d
    ```
  </Step>
</Steps>

## Image tags

Every release is published with the following tags:

| Tag                 | Example  | Description                       |
| ------------------- | -------- | --------------------------------- |
| `major.minor.patch` | `5.46.0` | Fully pinned                      |
| `major.minor`       | `5.46`   | Latest patch within a minor       |
| `major`             | `5`      | Latest minor+patch within a major |
| `latest`            | —        | Latest stable release             |
| `sha-<short>`       | —        | Exact commit for rollback         |

Images are built for both `linux/amd64` and `linux/arm64`.

## Upgrading

Manifest ships a new image on every release. To upgrade an existing compose install:

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
docker compose pull
docker compose up -d
```

Database migrations run automatically on boot, no manual steps. Your data in the `pgdata` volume is preserved across upgrades. Pin to a specific major version (e.g. `manifestdotbuild/manifest:5`) in `docker-compose.yml` if you want control over when major upgrades happen.

## Backup and persistence

All state lives in the `pgdata` named volume mounted at `/var/lib/postgresql/data` in the `postgres` service. Nothing else in the Manifest container is stateful.

Back up (from the host, with the stack running):

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
docker compose exec -T postgres pg_dump -U manifest manifest > manifest-backup-$(date +%F).sql
```

Restore into a fresh stack:

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
docker compose up -d postgres
cat manifest-backup.sql | docker compose exec -T postgres psql -U manifest manifest
docker compose up -d
```

To list or remove the volume manually:

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
docker volume ls | grep pgdata
docker compose down -v    # destroys all data
```

## Environment variables

**Core**

| Variable             | Required | Default                 | Description                                   |
| -------------------- | -------- | ----------------------- | --------------------------------------------- |
| `DATABASE_URL`       | Yes      | —                       | PostgreSQL connection string                  |
| `BETTER_AUTH_SECRET` | Yes      | —                       | Session signing secret (min 32 chars)         |
| `BETTER_AUTH_URL`    | No       | `http://localhost:2099` | Public URL. Set this when using a custom port |
| `PORT`               | No       | `2099`                  | Internal server port                          |
| `NODE_ENV`           | No       | `production`            | Node environment                              |
| `SEED_DATA`          | No       | `false`                 | Seed demo data on startup                     |

<Accordion title="Additional environment variables">
  **Network and security**

  | Variable       | Default     | Description         |
  | -------------- | ----------- | ------------------- |
  | `BIND_ADDRESS` | `127.0.0.1` | Bind address        |
  | `CORS_ORIGIN`  | —           | Allowed CORS origin |
  | `API_KEY`      | —           | Internal API key    |

  **Rate limiting**

  | Variable         | Default | Description             |
  | ---------------- | ------- | ----------------------- |
  | `THROTTLE_TTL`   | `60000` | Rate limit window in ms |
  | `THROTTLE_LIMIT` | `100`   | Max requests per window |

  Default: 100 requests per 60-second window.

  **LLM proxy**

  | Variable              | Default  | Description                                                                                                                                                                                                                                                   |
  | --------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
  | `PROVIDER_TIMEOUT_MS` | `180000` | Per-attempt timeout (ms) for upstream provider requests. Set strictly below your client's timeout so the [fallback chain](/fallback) has room to run. Slow local models may need this raised. Non-numeric, zero, or negative values fall back to the default. |

  **Email alerts (Mailgun)**

  | Variable          | Description               |
  | ----------------- | ------------------------- |
  | `MAILGUN_API_KEY` | Mailgun API key           |
  | `MAILGUN_DOMAIN`  | Mailgun domain            |
  | `MAILGUN_FROM`    | Sender address for alerts |

  **OAuth providers**

  | Variable                                      | Description   |
  | --------------------------------------------- | ------------- |
  | `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`   | Google OAuth  |
  | `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET`   | GitHub OAuth  |
  | `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` | Discord OAuth |
</Accordion>

Full env var reference: [github.com/mnfst/manifest](https://github.com/mnfst/manifest)

## Stop and clean up

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
docker compose down       # Stop services (keeps data)
docker compose down -v    # Stop and delete all data
```

## Data and privacy

Manifest keeps metadata about each request: model, provider, tier, token counts, cost, latency, and a scrubbed error message. It does not keep the message bodies. Prompts and completions are never written to the database, inline image data is stripped out, and error text is scrubbed for secrets before it lands.

Your agents' conversations stay between you and the provider. The usage and cost views in the dashboard are built from that metadata alone.

## Telemetry

Once a day, each install sends us a small anonymous report. That's how we know whether anyone's actually using the thing, and which providers are popular enough to deserve more work. It's aggregates, never content: no prompts, no messages, no keys, nothing tied to a user. Fifteen fields total.

### What gets sent

| Field                               | Example                                 | Purpose                                                                                                                                                                                   |
| ----------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `schema_version`                    | `1`                                     | So the shape can grow without breaking old clients. Stays `1` for additive changes; bumps on breaking ones                                                                                |
| `install_id`                        | random UUIDv4                           | Count distinct installs. Generated once on first boot, persisted, never rotated                                                                                                           |
| `manifest_version`                  | `5.47.0`                                | Version adoption across the fleet                                                                                                                                                         |
| `messages_total`                    | `1284`                                  | Daily activity per install                                                                                                                                                                |
| `messages_by_provider`              | `{"anthropic": 700, "openai": 500}`     | Provider mix. Anything we don't recognize collapses to `"custom"`, so self-hosted provider names and URLs stay local                                                                      |
| `messages_by_tier`                  | `{"default": 900, "batch": 300, ...}`   | Routing tier usage                                                                                                                                                                        |
| `messages_by_auth_type`             | `{"api_key": 1200, "subscription": 84}` | API key vs. paid-subscription usage                                                                                                                                                       |
| `tokens_input_total`                | `1_450_000`                             | Volume-weighted signal                                                                                                                                                                    |
| `tokens_output_total`               | `890_000`                               | Same                                                                                                                                                                                      |
| `cost_usd_total` <sup>†</sup>       | `47.83`                                 | Sum of `cost_usd` Manifest computed at routing time, rounded to cents. Lets us see real dollar throughput instead of guessing from token counts. `0` for Ollama-only / free-API installs  |
| `cost_usd_by_provider` <sup>†</sup> | `{"anthropic": 30.50, "openai": 17.33}` | Per-provider split of `cost_usd_total`, rounded to cents. Same `"custom"` collapse rule as `messages_by_provider` — admin-configured BYOK pricing is never keyed by the raw provider name |
| `agents_total`                      | `4`                                     | Configuration scale                                                                                                                                                                       |
| `agents_by_platform`                | `{"openclaw": 3, "hermes": 1}`          | Which agent clients people use                                                                                                                                                            |
| `platform`                          | `linux` / `darwin` / `windows`          | OS distribution                                                                                                                                                                           |
| `arch`                              | `x64` / `arm64`                         | Architecture distribution                                                                                                                                                                 |

<sup>†</sup> *Optional. Installs running older Manifest versions omit these fields; receivers should feature-detect on presence rather than on `schema_version`. Cost values are derived from the same `input_tokens` / `output_tokens` we already ship, multiplied by Manifest's per-model pricing table — no new data leaves the box, just a rolled-up dollar figure for what's already disclosed.*

### Never sent

Tenant IDs, user IDs, emails, API keys, prompts, message contents, model names, custom provider URLs, OAuth client IDs, hostnames, raw IPs. The ingest takes a SHA-256 of your IP and throws the original away; we keep the hash so we can rate-limit bad actors without knowing where they actually live.

### When

* Once every 24 hours, per install.
* The first report is delayed by a random 0–24h offset, so a fleet of containers rebooted together doesn't all hit the endpoint at the same minute.
* Off by default when `NODE_ENV != production`. Dev machines are never going to accidentally send.
* If the endpoint is down, we log it and try again on the next hourly tick. Your proxy keeps serving requests — the sender never gets in the way.

### Turning it off

Put this in your `.env` (or `docker-compose.yml`) and restart the container:

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
MANIFEST_TELEMETRY_DISABLED=1
```

The sender checks the flag before doing anything else. No database read, no DNS lookup, no request leaves the box.

### Sending it somewhere else

If you'd rather run your own fleet dashboard, point `TELEMETRY_ENDPOINT` at a URL you control:

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
TELEMETRY_ENDPOINT=https://telemetry.mycompany.internal/v1/report
```

## Docker Hub

The image is available at [manifestdotbuild/manifest](https://hub.docker.com/r/manifestdotbuild/manifest) on Docker Hub.
