> For the complete documentation index, see [llms.txt](https://docs.flowcp.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.flowcp.ai/guides/self-host-platform.md).

# Self-host the platform

FlowCP runs in one of two **deployment modes**:

* **Cloud** — the managed, multi-tenant service at [flowcp.ai](https://flowcp.ai). MCP requests are metered against your plan, and your plan comes from your Stripe subscription.
* **Self-Hosted** — you run the whole FlowCP platform yourself from a single all-in-one Docker image. MCP requests are **unlimited** (no metered quota), and your plan comes from a signed **license key**.

Both modes ship the same product and the same plans (Starter, Team, Business, Enterprise). Features are gated by **plan**, not by mode — see [Plans & features](/reference/plans-and-features.md) for the full matrix.

> **This is not the same as per-server export.** [Export & self-host your MCP server](/guides/self-host-export.md) exports **one published server** as a portable [standard FlowCP folder](/guides/folder-format.md) and runs just its MCP endpoint on the standalone runtime — no dashboard, no database. The guide you're reading now runs the **entire platform** (dashboard + control-plane API + MCP runtime, plus Postgres and Redis) on your own infrastructure, so you can connect apps, generate tools, configure OAuth, and publish servers exactly as you would on Cloud. Pick per-server export when you only need to host a finished server's endpoint; pick the platform self-host when you want the whole product behind your own perimeter.

## Prerequisites

* **Docker** and the **Docker Compose** plugin (`docker compose`).
* A host that can expose three ports (or a reverse proxy in front of them).

No separate Postgres or Redis is required — the all-in-one stack bundles both.

## What ships

A single all-in-one image published to [`ghcr.io/inovastudio/mcp-builder`](https://ghcr.io/inovastudio/mcp-builder) that runs the dashboard, the control-plane API, and the MCP runtime in one container. The accompanying `docker-compose.selfhost.yml` adds bundled **Postgres** and **Redis** services and wires the volumes and environment for you.

## Quickstart

```bash
cp .env.selfhost.example .env     # then edit .env
docker compose -f docker-compose.selfhost.yml up -d
```

Database **migrations run automatically on boot**, so the stack is ready once the container reports healthy. Open the dashboard at <http://localhost:3000>, create your account, and connect your first app.

## Ports

| Port   | Service           | Notes                                        |
| ------ | ----------------- | -------------------------------------------- |
| `3000` | Dashboard (web)   | The UI you sign in to.                       |
| `3001` | Control-plane API | OAuth authorization server + management API. |
| `3002` | MCP runtime       | The public `/mcp/<server-id>` endpoint.      |

Put a reverse proxy / TLS terminator in front of these if you're exposing the platform beyond localhost, and set the public-URL variables below to the externally reachable origins. `API_PUBLIC_URL` and `MCP_RUNTIME_PUBLIC_URL` are read at **runtime**, so OAuth callback, login, embed, git-webhook and MCP-client URLs follow your domain — the published image needs no rebuild for a custom domain.

> **Set the public-URL variables on the dashboard (web) service too.** The dashboard resolves `API_PUBLIC_URL` and `MCP_RUNTIME_PUBLIC_URL` from **its own** environment (via `/api/config`) to render each server's public endpoint and client-config snippets. In the bundled compose stack every service shares one `.env`, so this is automatic — but if you split the web, API and runtime into separate services, a `MCP_RUNTIME_PUBLIC_URL` set only on the runtime/API won't reach the dashboard, and server URLs render as **"MCP runtime URL not configured"** instead of your domain. Set it on the web service and restart (no rebuild needed).

## Environment reference

Copy `.env.selfhost.example` to `.env` and fill in the values you need. The variables most relevant to self-hosting:

| Variable                       | Required | Description                                                                                                                                                                       |
| ------------------------------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `FLOWCP_SELF_HOSTED`           | yes      | Set to `true` to run in Self-Hosted mode (unlimited requests, license-key plans, no Stripe UI).                                                                                   |
| `FLOWCP_LICENSE_KEY`           | no       | Signed license key that activates a paid plan. Blank → free **Starter** plan.                                                                                                     |
| `DATABASE_URL`                 | yes      | Postgres connection string. Points at the bundled `postgres` service by default.                                                                                                  |
| `REDIS_URL`                    | yes      | Redis connection string (BullMQ + rate limiting). Points at the bundled `redis` service.                                                                                          |
| `WEB_PUBLIC_URL`               | yes      | Public URL of the dashboard (e.g. `https://flowcp.example.com`).                                                                                                                  |
| `API_PUBLIC_URL`               | yes      | Public URL of the control-plane API (OAuth authorization-server metadata).                                                                                                        |
| `MCP_RUNTIME_PUBLIC_URL`       | yes      | Public URL of the MCP runtime (OAuth protected-resource metadata + the server endpoint / client-config snippets shown in the dashboard). Must also be present on the web service. |
| `OAUTH_ENCRYPTION_KEY`         | auto     | AES-256-GCM key for secrets at rest. Auto-generated on first boot if unset (see below).                                                                                           |
| `OAUTH_STATE_KEY`              | auto     | AES-256-GCM key for OAuth state / auth codes. Auto-generated on first boot if unset.                                                                                              |
| `SESSION_SECRET`               | auto     | HMAC key for session cookies and CSRF. Auto-generated on first boot if unset.                                                                                                     |
| `PLATFORM_OAUTH_CLIENT_SECRET` | auto     | Secret for the platform's own OAuth wrapper. Auto-generated on first boot if unset.                                                                                               |
| `MCP_ALLOWED_ORIGINS`          | no       | Comma-separated browser origins allowed to call the MCP runtime. Unset accepts every origin. See [Origin validation](#origin-validation) below.                                   |

In Self-Hosted mode no Stripe configuration is shipped or required; the Billing UI is replaced by a **License** panel.

## Origin validation

The MCP specification requires a server to validate the `Origin` header on incoming connections, which is what stops a page on an attacker's site from driving your runtime through a visitor's browser (a DNS-rebinding attack).

Set **`MCP_ALLOWED_ORIGINS`** to a comma-separated list of the browser origins that should be able to reach your runtime:

```
MCP_ALLOWED_ORIGINS=https://chatgpt.com,https://claude.ai
```

Two things are always allowed and never need listing:

* **Requests with no `Origin` header.** Claude Desktop, Cursor, and the FlowCP CLI are not browsers and send none. Origin validation only ever affects browser-initiated requests, so these clients are unaffected.
* **Same-origin requests** — where the `Origin` host matches the host the request arrived on. A custom domain serving its own embedded chat widget works without an entry.

`MCP_RUNTIME_PUBLIC_URL` and `WEB_PUBLIC_URL` are included automatically.

> **Leaving `MCP_ALLOWED_ORIGINS` unset accepts every origin.** That is the default so an upgrade cannot silently cut off a working browser client; the runtime logs a warning at boot while enforcement is off. Set the variable once you know which browser clients you serve.

A rejected request gets HTTP `403` with `{"error":"FORBIDDEN_ORIGIN"}`.

## The `/data` volume and secret auto-generation

The compose file mounts a persistent volume at **`/data`**. On first boot, any of these secrets that you did **not** provide in `.env` are generated automatically and written to **`/data/.flowcp/secrets.env`**:

* `OAUTH_ENCRYPTION_KEY`
* `OAUTH_STATE_KEY`
* `SESSION_SECRET`
* `PLATFORM_OAUTH_CLIENT_SECRET`

On every subsequent boot the stack reads the generated values back from that file, so they stay stable across restarts and upgrades.

> **Warning — back up your `/data` volume.** `OAUTH_ENCRYPTION_KEY` encrypts every secret stored in the database (OAuth client secrets, shared upstream credentials, etc.). If you lose the `/data` volume and didn't pin these keys in `.env` yourself, the auto-generated keys are gone and **previously-encrypted secrets can no longer be decrypted** — you'll have to re-enter them. For production, either back up `/data` or set the four secrets explicitly in `.env` and store them in your own secret manager.

## Activate a paid plan

Self-Hosted derives its plan from `FLOWCP_LICENSE_KEY`:

1. Obtain a signed license key for your plan (Team, Business, or Enterprise).
2. Set `FLOWCP_LICENSE_KEY=<key>` in `.env`.
3. Recreate the stack:

   ```bash
   docker compose -f docker-compose.selfhost.yml up -d
   ```

License keys are verified **offline** against a public key baked into the official image, so activation needs no extra configuration or network calls — just the key itself. (To trust a non-official signing key, set `FLOWCP_LICENSE_PUBLIC_KEY` to your own public PEM; it overrides the baked-in default.)

With no key set, the platform runs on the free **Starter** plan (core features, unlimited requests). The License panel in the dashboard shows the active plan and the features it unlocks. See [Plans & features](/reference/plans-and-features.md) for what each plan includes.

### Issuing license keys (platform operators)

If you run the platform and mint keys for your own installs, sign them with the private key matching the baked-in (or `FLOWCP_LICENSE_PUBLIC_KEY`) public key:

```bash
# Private key supplied at runtime — never commit it.
FLOWCP_LICENSE_PRIVATE_KEY="$(cat flowcp-license.key)" \
  pnpm license:sign --plan team --licensed-to "Acme Inc" --expires 365d
# …or: pnpm license:sign --key-file ./flowcp-license.key --plan enterprise \
#        --licensed-to "Acme Inc" --expires 2027-01-01
```

The printed token is the value end users set as `FLOWCP_LICENSE_KEY`. Generate the keypair offline (`openssl genpkey -algorithm ed25519 -out flowcp-license.key` then `openssl pkey -in flowcp-license.key -pubout`) and keep the private key in a secret manager.

## Upgrade

Pull the latest image and recreate the stack — migrations run automatically on the new boot:

```bash
docker compose -f docker-compose.selfhost.yml pull
docker compose -f docker-compose.selfhost.yml up -d
```

Because all secrets persist in `/data` and the schema migrates forward on boot, upgrades are a pull-and-up with no manual migration step.
