Skip to main content
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, the policy editor, and the audit log, 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 for what’s in the community build.
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.

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.
  • 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 below. Docker is the only prerequisite:
./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.
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.

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

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:
Or build it yourself from a checkout: docker build -f Dockerfile.self-host -t midplane/self-host:local .
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.

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

Install and configure

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

Bring up the database

Or set DATABASE_URL to your own Postgres and run only the migration.
3

Build the engine binary

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

Run the control plane

The dashboard comes up at http://localhost:3000. For production, build the web app and run the standalone server with the same env:
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.

Just the engine (no dashboard)

The MIT query-path engine also ships on its own — as the midplane npm package your MCP client spawns over stdio, and as the midplane/midplane Docker image serving Streamable HTTP. Both guard a single database with no control plane, no dashboard, and no Postgres of their own, auditing to a local SQLite file. That’s the lightest way to run Midplane, and it has its own page: the standalone engine.

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 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). 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, 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, 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. Write approvals work the same way and are opt-in: ./bin/self-host up doesn’t generate MIDPLANE_APPROVAL_SECRET, so set it (plus MIDPLANE_APP_ORIGIN) before you turn approvals on for a database. The control plane derives a per-project gate token from it, so one project’s engine can’t collect another’s grants. Engine-side options — the deny webhook, telemetry, and log level — are passed through to the engine subprocess; see operations and the environment variables reference.

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.

Upgrading

That pulls the newer image, restarts the stack, and applies any database migrations on boot. Your secrets in .env.self-host are left alone — the script never regenerates a value that’s already set — and the Postgres data volume is kept. The engine ships inside the image, so its version moves with the upgrade. Two things to know before you take one:
  • A new enforcement rule can start denying queries that used to run. Every release note in the changelog calls out a behavior change of that kind. Read it before upgrading a deployment that grants writes.
  • Downgrades can fail loudly, by design. A policy using a feature the older engine doesn’t implement carries a requires_features marker, and that engine refuses the policy rather than silently not enforcing it. Roll the policy back with the image.

Audit

The control plane indexes audit into your Postgres, and you read it in the dashboard — exactly like Cloud. See reading the audit log.

Next steps

Operations

Go-live checklist, denial alerting, and telemetry controls.

Connect your agent

Wire up Cursor, Claude Code, or Claude Desktop to a project.

Audit log

Read denials and queries in the dashboard.

All environment variables

The full, exhaustive reference, including engine-side options.