Skip to main content
Midplane is safe by default — reads allow, writes deny, and the audit row lands before the query runs. Operations is the rest: locking down a working control plane for production, hearing about denials actively, and controlling what the engine reports home.

Before you go live

Work through these before a self-hosted Midplane fronts a real database:
  1. Secure the three secrets. .env.self-host holds BETTER_AUTH_SECRET, MIDPLANE_KMS_DEV_KEY_EU, and MIDPLANE_TOKEN_PEPPER_EU_V1 — auth, credential encryption, and token hashing. Keep the file out of version control, back it up, and never rotate the key or pepper once data exists (regenerating either bricks every stored credential and token). See configuration.
  2. Back up your control-plane Postgres. One Postgres holds your projects and their encrypted DSNs, your policies, and the indexed audit log. Put it on a regular backup schedule and protect the dumps as sensitive.
  3. Set MIDPLANE_MASK_SALT_MASTER before declaring column masks. If any project declares column_masks, set the master salt first — a masked project refuses to spawn without it. Treat it as a stable secret: rotating it re-randomizes every masked value, so existing hashes and pseudonyms won’t match prior reads.
  4. Terminate TLS at your own ingress. The control plane serves plain HTTP locally; in production route the dashboard and every project /mcp endpoint through HTTPS at your reverse proxy or ingress. This also satisfies agents like Claude Desktop that reject http://.
  5. Give each project DSN a least-privilege database role. Point a project’s postgres:// URL at a role exactly as broad as the agent should be — Midplane’s policy and your database role must both allow a query. Two layers, not one.
  6. Author and lint your policy. Write each project’s policy in the dashboard editor, or lint a YAML file with midplane policy lint (it exits nonzero on any [ERROR] — wire it into CI). Keep guardrails on unless you have a specific reason. See the policy CLI and policy schema.
  7. Wire a deny webhook so blocked queries page you instead of waiting to be found in the log — see alert on denials.
  8. Decide on telemetry — keep it on or disable it, see telemetry and privacy.
  9. Verify with the demo prompts. Connect your agent to a project, run a happy-path read and the delete all users denial, and confirm both attempts land in the dashboard audit log.

Alert on denials

The audit log records every denial, but reading it is a pull. To make a denial page someone, point Midplane at a webhook and it pushes a JSON POST the moment a query is blocked. The deny webhook is an engine feature; in self-host you set it in .env.self-host and the control plane passes it through to each project’s engine subprocess. Set MIDPLANE_DENY_WEBHOOK to any http:// or https:// URL — the payload is generic JSON, so it works with Slack incoming webhooks, Discord, the PagerDuty Events API, or your own endpoint:
A non-HTTP URL fails fast at boot, so a typo can’t silently disable your alerting. By default every denial fires; narrow it with MIDPLANE_DENY_WEBHOOK_RULES, a comma-separated allowlist of rule names — table_access, multi_statement, dangerous_statement, parse_error. A denial whose rule isn’t listed is still written to the audit log; it just doesn’t fire the webhook.
Each denial is a single JSON POST:
The webhook is fire-and-forget by design — a 5-second timeout, no retries, and any 2xx counts as delivered. A failure (network error, 5xx, slow endpoint) never blocks or fails the audit write: the DECIDED row always lands, so treat the audit trail as authoritative and the webhook as a best-effort alert that can miss an event if your receiver is down. Slack renders the JSON as a code block; for richer formatting, point the webhook at a small relay that translates the payload into Block Kit. For the complete field list and schema versioning, see the deny webhook reference.

Telemetry and privacy

The Midplane engine sends anonymous, aggregate usage telemetry by default — a startup ping and a daily heartbeat of counts. It never includes your SQL, your schema, or your credentials. Telemetry is an engine setting; in self-host you control it through .env.self-host, which the control plane passes through to each engine subprocess. TELEMETRY.md in the engine repo is the full source of truth. Disable it completely with any one of these — no startup event, no heartbeats, and the install ID is never generated:
To audit the payload without sending anything, run with MIDPLANE_TELEMETRY=debug: each event that would be transmitted is written to stderr as one line of JSON prefixed [telemetry-debug], and no network call is made. Two kinds of event post to https://t.midplane.ai/v1/events, both carrying a stable random install ID unrelated to your hostname, IP, database, or account:
  • startup — once per process: the install ID, Midplane version, bun version, OS and architecture, the transport, and whether it’s in a container or CI.
  • heartbeat — every 24 hours: per-tool call counts (with allow/deny splits), denials grouped by rule name, coarse statement-type buckets (SELECT, INSERT, …), and Midplane-added latency percentiles (p50/p95/p99). An idle window with zero tool calls sends nothing.
What’s never sent, enforced by a client-side sanitizer and re-checked at the receiver: SQL text (raw or normalized), SQL fingerprints, table/column/schema names (anywhere, including inside error messages), database URLs or any component, query results and row counts, Postgres error messages (only the 2-character SQLSTATE class), agent identity (agent_name / agent_version / agent_intent), and policy file contents.

Deny webhook reference

The full payload schema, every field, and version history.

Full telemetry contract

The complete field-by-field schema and install-ID lifecycle.