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

# Deploy

> Run the Midplane control plane yourself with Docker — the dashboard, projects, policy editor, and audit log on your own infrastructure.

Self-host runs the **Midplane control plane** in your own environment — the same app that powers Cloud, started with `MIDPLANE_SELF_HOST=1`. You get the dashboard, [projects](/docs/cloud/projects), the policy editor, and the [audit log](/docs/concepts/audit-trail), all on your own infrastructure. Your database credentials are encrypted at rest with a local KMS key and never leave your network. It's free and uncapped — unlimited projects, tokens, and seats, with full audit history.

In self-host mode the control plane is keyless, single-owner, and runs against one Postgres. See [open core & licensing](/docs/cloud-vs-self-host#licensing) for what's in the community build.

<Warning>
  **Never put a DSN or password on a command line with inline `-e`.** `-e DATABASE_URL=postgres://user:pass@...` leaks the secret to `ps aux` (visible to any user on the host) and to your shell history. Keep `DATABASE_URL` in `.env.self-host` and run with `--env-file` only. We won't show you the inline `-e` pattern anywhere in these docs.
</Warning>

## Prerequisites

* **Docker** (with Compose v2) for the one-command and containerized paths. The published image ships everything — the control plane, the engine binary, and its own runtime.
* **Bun ≥ 1.3** only if you [run from source](#run-from-source-contributors).
* **A Postgres for the control plane** — its metadata store, separate from the databases you protect. The compose stack bundles one; or point `DATABASE_URL` at a Postgres you already run.

The engine is **not a separate container** in self-host — it runs as a loopback subprocess wherever the control plane runs. See [the engine topology](#how-the-engine-runs) below.

## One command (recommended)

Docker is the only prerequisite:

```bash theme={null}
git clone https://github.com/midplaneai/midplane && cd midplane
./bin/self-host up            # → dashboard at http://localhost:3000
```

`./bin/self-host up`:

1. Creates `.env.self-host` from the example and fills the three generated secrets (`BETTER_AUTH_SECRET`, `MIDPLANE_KMS_DEV_KEY_EU`, `MIDPLANE_TOKEN_PEPPER_EU_V1`) — **once**, then reuses them on every boot.
2. Brings up Postgres and the web container (`docker compose --env-file .env.self-host …`), which **applies migrations on boot** and serves on `http://localhost:3000`. The first email + password signup becomes the owner.

```bash theme={null}
./bin/self-host down       # stop the stack (the Postgres data volume is kept)
./bin/self-host upgrade    # pull newer images and restart
```

<Warning>
  **Secrets are persisted, not ephemeral.** `MIDPLANE_KMS_DEV_KEY_EU` encrypts project DSNs at rest and `MIDPLANE_TOKEN_PEPPER_EU_V1` backs machine-token verification. They live in `.env.self-host` and are reused on every boot — regenerating either bricks every stored credential and token. Back up `.env.self-host` and never rotate these once data exists.
</Warning>

### Driving compose directly

`bin/self-host` is a thin wrapper. If you'd rather run compose yourself, always pass `--env-file .env.self-host` — raw `docker compose` reads `.env`, not `.env.self-host`, so without the flag the secrets and the port overrides are silently ignored:

```bash theme={null}
docker compose --env-file .env.self-host -f docker-compose.self-host.yml up -d --wait
docker compose --env-file .env.self-host -f docker-compose.self-host.yml down
```

The compose stack uses a published multi-arch image (`ghcr.io/midplaneai/midplane-self-host:latest`, amd64 + arm64) with a `build:` fallback, so a fork or an unpublished checkout builds `Dockerfile.self-host` locally instead.

### Migrating as a separate step (optional)

The web container migrates on boot, so you normally don't run migrations by hand. If you want them as a discrete step, the compose file ships a one-shot `migrate` service (behind a profile so it stays out of the default `up`):

```bash theme={null}
docker compose --env-file .env.self-host -f docker-compose.self-host.yml \
  --profile migrate run --rm migrate
```

### If a default port is taken

* **3000, the dashboard** — nothing to do on a fresh install: `./bin/self-host up` **auto-selects a free port**, trying 3000 first and ascending, then persists the chosen `WEB_PORT` and a matching `BETTER_AUTH_URL` to `.env.self-host` and prints the URL it picked. To pin a specific port instead, set `WEB_PORT` yourself and `BETTER_AUTH_URL` to the same port (e.g. `http://localhost:3210`) — it drives auth callbacks **and** the MCP endpoint URL the dashboard shows agents. A pinned port is honored exactly and never auto-moved; if it's taken the script stops and tells you, rather than handing out a URL nothing is listening on.
* **5432, the bundled Postgres** — not auto-moved (it isn't a browser-facing URL): set `POSTGRES_PORT` to a free port in `.env.self-host` and re-run. The web container reaches Postgres over the compose network regardless.

Only your host mapping moves — inside the containers, nothing changes.

## Single container, your own Postgres

The self-host image is the whole control plane — web dashboard plus the engine binary baked at `/usr/local/bin/midplane`, with `MIDPLANE_ENGINE_BIN` and `MIDPLANE_SELF_HOST=1` preset. It applies migrations on boot (a failure aborts boot loudly), so pointing it at an empty database is fine. Set `DATABASE_URL` and the three secrets in `.env.self-host`, then:

```bash theme={null}
docker run -d \
  --name midplane \
  --env-file .env.self-host \
  -p 3000:3000 \
  ghcr.io/midplaneai/midplane-self-host:latest
```

Or build it yourself from a checkout: `docker build -f Dockerfile.self-host -t midplane/self-host:local .`

<Warning>
  Inside the container, `localhost` is the **container**, not your host. For a Postgres running on your host, set `DATABASE_URL` in `.env.self-host` to `host.docker.internal` (Docker Desktop) or put the database on the same Docker network — and the same rule applies to the project DSNs you add later in the dashboard.
</Warning>

## Run from source (contributors)

The from-source path needs **Bun ≥ 1.3** and runs the app with `bun run dev` against the compose Postgres. Install dependencies first — a fresh clone has no `node_modules`:

<Steps>
  <Step title="Install and configure">
    ```bash theme={null}
    bun install
    cp .env.self-host.example .env.self-host
    ```

    Fill in three secrets in `.env.self-host` (or run `./bin/self-host up` once — it fills them for you):

    ```bash theme={null}
    openssl rand -base64 32   # → BETTER_AUTH_SECRET
    openssl rand -hex 32      # → MIDPLANE_KMS_DEV_KEY_EU
    openssl rand -base64 32   # → MIDPLANE_TOKEN_PEPPER_EU_V1
    ```

    See [Configuration](#configuration) for every variable.
  </Step>

  <Step title="Bring up the database">
    ```bash theme={null}
    docker compose --env-file .env.self-host -f docker-compose.self-host.yml up -d postgres
    bun run migrate:self-host
    ```

    Or set `DATABASE_URL` to your own Postgres and run only the migration.
  </Step>

  <Step title="Build the engine binary">
    ```bash theme={null}
    bun run build:engine-binary   # → engine/dist/midplane
    export MIDPLANE_ENGINE_BIN="$PWD/engine/dist/midplane"
    ```

    (The container image bakes this in — the binary build is only for the from-source path.)
  </Step>

  <Step title="Run the control plane">
    ```bash theme={null}
    bun --env-file=.env.self-host run dev
    ```

    The dashboard comes up at `http://localhost:3000`. For production, build the web app and run the standalone server with the same env:

    ```bash theme={null}
    bun --filter '@midplane-cloud/web' build
    ```
  </Step>
</Steps>

<Note>
  The from-source `DATABASE_URL` points at `localhost:${POSTGRES_PORT:-5432}` — the compose Postgres mapped to your host. The containerized `web` service ignores that and talks to the in-network `postgres` service instead, so you don't edit `DATABASE_URL` when switching to the `bin/self-host` path.
</Note>

## Just the engine (no dashboard)

The MIT query-path engine also ships on its own as the `midplane/midplane` Docker image — the lightest install for guarding a **single** database or a CI pipeline from a terminal, with no control plane, no dashboard, and no Postgres of its own:

```bash theme={null}
curl -O https://raw.githubusercontent.com/midplaneai/midplane/main/engine/.env.example
mv .env.example .env   # set DATABASE_URL in the file — never inline with -e
docker run --env-file .env -p 8080:8080 -v midplane-audit:/data midplane/midplane:latest
```

The MCP endpoint comes up at `http://localhost:8080/mcp` — point [your agent](/docs/agents/overview) at it. Access rules come from a [policy file](/docs/policies/overview); audit lands in the mounted volume.

## Configuration

A self-hosted Midplane is configured through environment variables in `.env.self-host` — the file the container, compose (`--env-file`), and `bun --env-file` all read. [`./bin/self-host up`](#one-command-recommended) creates it from the shipped example and fills the three secrets; to assemble it by hand, `cp .env.self-host.example .env.self-host`. Keep it out of version control and back it up.

`MIDPLANE_SELF_HOST=1` flips the shared codebase into the standalone build and auto-selects the subprocess spawner (see [how the engine runs](#how-the-engine-runs)). `DATABASE_URL` is the single Postgres the control plane uses for its **own** metadata — projects, policies, audit, and auth — not a database you protect; the databases your agents reach are added in the dashboard as [projects](/docs/cloud/projects), each with its own encrypted DSN.

The first email + password to sign up becomes the **owner**; later public sign-ups are rejected, and teammates join through a shareable invite link. Auth needs `BETTER_AUTH_SECRET` (session signing, 32+ bytes — the app refuses to boot on a missing or too-short value) and `BETTER_AUTH_URL` (your deployment's base URL, which drives the cookie domain and OAuth callbacks).

Self-host encrypts every project DSN with a local AES key in **env mode** — no AWS. Three secrets **can't be regenerated** without bricking stored data, so treat them as permanent: `MIDPLANE_KMS_DEV_KEY_EU` (the AES key encrypting each DSN at rest), `MIDPLANE_TOKEN_PEPPER_EU_V1` (the machine-token hash pepper), and `BETTER_AUTH_SECRET` above. The `_EU` suffix is a fixed internal pin, not a regional choice.

If any project declares [column masks](/docs/concepts/masking), set `MIDPLANE_MASK_SALT_MASTER` in `.env.self-host` first: the control plane HMACs it with each project's id to derive a per-project salt and injects that as `MIDPLANE_MASK_SALT` into the engine it spawns — so you set one master and never touch the per-engine value. A project with masks and no master **refuses to spawn**. If you aren't masking, leave it unset.

| Variable                      | Default                | What it does                                                                                                                                               |
| ----------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `MIDPLANE_SELF_HOST`          | `1` (in the example)   | Run the standalone self-host control plane.                                                                                                                |
| `DATABASE_URL`                | (required)             | The single Postgres for control-plane metadata.                                                                                                            |
| `BETTER_AUTH_SECRET`          | (required)             | Session signing secret, 32+ bytes. `openssl rand -base64 32`.                                                                                              |
| `BETTER_AUTH_URL`             | (required)             | Base URL; drives the cookie domain, OAuth callbacks, and the MCP endpoint URL the dashboard displays, e.g. `http://localhost:3000`.                        |
| `MIDPLANE_KMS_MODE`           | `env` (in the example) | Selects local env-mode credential encryption.                                                                                                              |
| `MIDPLANE_KMS_DEV_KEY_EU`     | (required)             | Local AES encryption key. `openssl rand -hex 32`.                                                                                                          |
| `MIDPLANE_TOKEN_PEPPER_EU_V1` | (required)             | Pepper for hashing machine tokens. `openssl rand -base64 32`.                                                                                              |
| `MIDPLANE_ENGINE_BIN`         | (looks on `PATH`)      | Path to the compiled engine binary. Preset in the container image.                                                                                         |
| `MIDPLANE_MASK_SALT_MASTER`   | (none)                 | Master secret for column masking; the control plane derives a per-project salt from it. Required once any column mask is declared. `openssl rand -hex 32`. |
| `POSTGRES_PASSWORD`           | (compose default)      | Password for the bundled compose Postgres only.                                                                                                            |
| `POSTGRES_PORT`               | (compose default)      | Published port for the bundled compose Postgres only.                                                                                                      |
| `WEB_PORT`                    | `3000`                 | Published host port for the dashboard container only — inside, it still listens on 3000. Keep `BETTER_AUTH_URL` in sync.                                   |

Engine-side options — the deny webhook, telemetry, and log level — are passed through to the engine subprocess; see [operations](/docs/self-host/operations) and the [environment variables reference](/docs/reference/environment-variables).

## How the engine runs

Self-host runs the engine as a local subprocess, one per project — the control plane spawns the compiled `midplane` binary bound to a loopback port with that project's decrypted DSN, and proxies the project's `/mcp` endpoint to it. It idle-stops after 30 minutes. So self-host needs **no Docker daemon, no Docker socket, and no host networking** — only that the `midplane` binary is present: at `MIDPLANE_ENGINE_BIN`, on `PATH`, or baked into the self-host image at `/usr/local/bin/midplane`.

## Audit

The control plane indexes audit into your Postgres, and you read it in the **dashboard** — exactly like Cloud. See [reading the audit log](/docs/concepts/audit-trail).

## Next steps

<Columns cols={2}>
  <Card title="Operations" icon="list-checks" href="/docs/self-host/operations" horizontal>
    Go-live checklist, denial alerting, and telemetry controls.
  </Card>

  <Card title="Connect your agent" icon="plug" href="/docs/agents/overview" horizontal>
    Wire up Cursor, Claude Code, or Claude Desktop to a project.
  </Card>

  <Card title="Audit log" icon="scroll-text" href="/docs/concepts/audit-trail" horizontal>
    Read denials and queries in the dashboard.
  </Card>

  <Card title="All environment variables" icon="list" href="/docs/reference/environment-variables" horizontal>
    The full, exhaustive reference, including engine-side options.
  </Card>
</Columns>
