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/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:
http://localhost:8080/mcp — point your agent at it. Access rules come from a policy file; 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 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.
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.
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.