> ## Documentation Index
> Fetch the complete documentation index at: https://midplane.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Operations

> Take a self-hosted Midplane from a working install to production.

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](/docs/self-host/deploy) 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](/docs/self-host/deploy#configuration).
2. **Back up your control-plane Postgres.** One Postgres holds your projects and their **encrypted** DSNs, your policies, and the indexed [audit log](/docs/concepts/audit-trail). 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`](/docs/concepts/masking), 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](/docs/reference/cli#midplane-policy) and [policy schema](/docs/reference/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](#alert-on-denials).
8. **Decide on telemetry** — keep it on or disable it, see [telemetry and privacy](#telemetry-and-privacy).
9. **Verify with the demo prompts.** Connect [your agent](/docs/agents/overview) to a project, run a happy-path read and the `delete all users` denial, and confirm both attempts land in the dashboard [audit log](/docs/concepts/audit-trail).

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

```bash theme={null}
MIDPLANE_DENY_WEBHOOK=https://hooks.slack.com/services/T000/B000/xxx
```

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](/docs/how-it-works) 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.

```bash theme={null}
MIDPLANE_DENY_WEBHOOK_RULES=table_access,multi_statement
```

Each denial is a single JSON `POST`:

```json theme={null}
{
  "event": "denial",
  "policy_rule": "table_access",
  "reason": "Midplane denied this query because writes to table `users` are not allowed by the table-access policy.",
  "agent_name": "claude-code",
  "agent_intent": "investigating slow user lookup",
  "statement_type": "DELETE",
  "tables_touched": ["public.users"],
  "sql_preview": "DELETE FROM users WHERE id = 42"
}
```

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](/docs/reference/environment-variables#deny-webhook).

## 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`](https://github.com/midplaneai/midplane/blob/main/engine/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:

```bash theme={null}
MIDPLANE_TELEMETRY=0        # also accepts: off, false
DO_NOT_TRACK=1              # cross-tool Console Do Not Track convention
```

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.

<Columns cols={2}>
  <Card title="Deny webhook reference" icon="webhook" href="/docs/reference/environment-variables#deny-webhook" horizontal>
    The full payload schema, every field, and version history.
  </Card>

  <Card title="Full telemetry contract" icon="file-text" href="https://github.com/midplaneai/midplane/blob/main/engine/TELEMETRY.md" horizontal>
    The complete field-by-field schema and install-ID lifecycle.
  </Card>
</Columns>
