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.
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_URLat a Postgres you already run.
One command (recommended)
Docker is the only prerequisite:./bin/self-host up:
- Creates
.env.self-hostfrom 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. - Brings up Postgres and the web container (
docker compose --env-file .env.self-host …), which applies migrations on boot and serves onhttp://localhost:3000. The first email + password signup becomes the owner.
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:
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-shotmigrate 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 upauto-selects a free port, trying 3000 first and ascending, then persists the chosenWEB_PORTand a matchingBETTER_AUTH_URLto.env.self-hostand prints the URL it picked. To pin a specific port instead, setWEB_PORTyourself andBETTER_AUTH_URLto 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_PORTto a free port in.env.self-hostand re-run. The web container reaches Postgres over the compose network regardless.
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:
docker build -f Dockerfile.self-host -t midplane/self-host:local .
Run from source (contributors)
The from-source path needs Bun ≥ 1.3 and runs the app withbun run dev against the compose Postgres. Install dependencies first — a fresh clone has no node_modules:
1
Install and configure
.env.self-host (or run ./bin/self-host up once — it fills them for you):2
Bring up the database
DATABASE_URL to your own Postgres and run only the migration.3
Build the engine binary
4
Run the control plane
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 themidplane 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 compiledmidplane 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
.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_featuresmarker, 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.