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/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:
The MCP endpoint comes up at 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 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.

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.