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

# Environment variables

> Every environment variable the self-host Midplane engine reads.

The self-host engine is configured entirely through environment variables. Copy [`.env.example`](https://github.com/midplaneai/midplane/blob/main/engine/.env.example) to `.env`, fill in your values, and pass it with `--env-file` (or compose).

<Warning>
  Never put a DSN or any secret on a `docker run` command line with inline `-e` — the expanded credential lands in `ps aux` and your shell history. Always use `--env-file` or compose. See [deploy](/docs/self-host/deploy).
</Warning>

The engine refuses to boot on an invalid configuration: the connection, port, and transport values are schema-validated, and `DATABASE_URL` is required unless your policy file supplies a `databases:` block.

## Connection and core

| Variable               | Default          | Required | Purpose                                                                                                                                                                                                            |
| ---------------------- | ---------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `DATABASE_URL`         | —                | Yes¹     | The database connection string (e.g. `postgres://user:pass@host:5432/db`). Use a scoped, least-privilege role. Ignored when the policy file uses a `databases:` block.                                             |
| `MIDPLANE_POLICY_FILE` | *(none)*         | No       | Path to the YAML [policy file](/docs/reference/policy-schema). Mount it into the container. With no file, every write denies and every read allows. May instead carry a `databases:` block to serve multiple databases. |
| `PORT`                 | `8080`           | No       | HTTP port for the MCP endpoint. `compose.yaml` reads this for both the port mapping and the `/health` probe. Must be an integer 1–65535.                                                                           |
| `MIDPLANE_HOST`        | `0.0.0.0`        | No       | HTTP bind host for the MCP endpoint (`0.7.1+`). Set it to `::` to bind dual-stack IPv6 + IPv4 — needed on an IPv6-only network.                                                                                    |
| `DB_PATH`              | `/data/audit.db` | No       | Path to the local SQLite audit log inside the container. Keep it under `/data` so the named volume persists it across restarts.                                                                                    |
| `MIDPLANE_TRANSPORT`   | `http`           | No       | MCP transport: `http` or `stdio`. The Docker image defaults to `http`.                                                                                                                                             |

¹ `DATABASE_URL` is required for the single-database path. It is **not** required when `MIDPLANE_POLICY_FILE` supplies a `databases:` block whose entries carry their own `url`. At least one DSN source must be configured or the engine refuses to boot.

### Examples

```bash theme={null}
DATABASE_URL=postgres://readonly-agent:pass@db.internal:5432/app
MIDPLANE_POLICY_FILE=/etc/midplane/policy.yaml
PORT=8080
DB_PATH=/data/audit.db
MIDPLANE_TRANSPORT=http
```

## Column masking

| Variable                       | Default  | Required    | Purpose                                                                                                                                                                                                                                                                                                                                                                                                                            |
| ------------------------------ | -------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `MIDPLANE_MASK_SALT`           | *(none)* | Conditional | Per-deployment HMAC salt for masking's deterministic transforms (`0.12.0+`). The engine **refuses to boot** any database whose policy declares [`column_masks`](/docs/reference/policy-schema) without it. Rotating it re-randomizes every masked value, so existing hashes and pseudonyms won't match prior reads.                                                                                                                     |
| `MIDPLANE_MASK_SOURCE_REWRITE` | `0`      | No          | Apply masks by **rewriting the query at the source** rather than redacting the result after execution (`0.14.0+`); with it off, the engine uses the earlier post-execution masker, which fails closed on aggregates over masked tables. Per database, `mask_source_rewrite: true` in the [policy](/docs/reference/policy-schema) overrides it. Cloud sets this on for every masked database. See [how masking runs](/docs/concepts/masking). |

```bash theme={null}
# Generate a salt, then write the literal value (--env-file is not a shell).
echo "MIDPLANE_MASK_SALT=$(openssl rand -hex 32)" >> .env
```

<Note>
  `MIDPLANE_MASK_SALT` is the variable the **standalone engine** reads when you run it directly. When a **control plane** manages the engine — Midplane Cloud, or [self-host](/docs/self-host/deploy#configuration) — you instead set `MIDPLANE_MASK_SALT_MASTER` once on the control plane, which derives a per-project salt from it and injects that as `MIDPLANE_MASK_SALT` into each engine it spawns.
</Note>

See [column masking](/docs/concepts/masking) for how the salt keys each transform.

## Audit shipping

| Variable        | Default  | Required | Purpose                                                                                                                                                                                                                 |
| --------------- | -------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `INDEXER_TOKEN` | *(none)* | No       | Bearer token for the audit pull endpoints (`GET /audit/since`, `DELETE /audit/before`) and `POST /admin/policy`. Unset → those routes return `404`. Set only if you're shipping the audit log to an external collector. |

```bash theme={null}
# Generate a token, then write the literal value (--env-file is not a shell).
echo "INDEXER_TOKEN=$(openssl rand -hex 32)" >> .env
```

See [HTTP endpoints](/docs/reference/mcp-tools#http-endpoints) for the audit pull endpoints.

## Deny webhook

| Variable                      | Default       | Required | Purpose                                                                                                                                                                                                                |
| ----------------------------- | ------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `MIDPLANE_DENY_WEBHOOK`       | *(none)*      | No       | An `http(s)://` URL. When set, every policy denial fires a fire-and-forget JSON `POST` to it (5-second timeout). Works with Slack, Discord, PagerDuty, or any HTTP endpoint. A non-`http(s)` value fails fast at boot. |
| `MIDPLANE_DENY_WEBHOOK_RULES` | *(all rules)* | No       | Comma-separated allowlist of `policy_rule` names that fire the webhook. Unset → every denial fires. Recognized rules: `table_access`, `multi_statement`, `dangerous_statement`, `parse_error`.                         |

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

### Payload

`POST {url}` with `content-type: application/json` and this body:

```json theme={null}
{
  "event": "denial",
  "schema_version": 3,
  "ts": 1730000000000,
  "query_id": "01HXXX...",
  "audit_id": "01HXXX...",
  "tenant_id": "__self_host__",
  "agent_name": "claude-code",
  "agent_version": "0.42.1",
  "agent_intent": "investigating slow user lookup",
  "mcp_token_id": "01HZX3KQ7B9YV2RTNGH7MJSPVB",
  "policy_rule": "table_access",
  "reason": "Midplane denied this query because writes to table `users` are not allowed by the table-access policy (`users` resolves to `read`; mark it `read_write` in your MIDPLANE_POLICY_FILE to grant writes).",
  "statement_type": "DELETE",
  "tables_touched": ["public.users"],
  "sql_preview": "DELETE FROM users WHERE id = 42",
  "sql_truncated": false
}
```

<ResponseField name="event" type="string">
  Always `"denial"`.
</ResponseField>

<ResponseField name="schema_version" type="integer">
  Payload version. Currently `3`.
</ResponseField>

<ResponseField name="ts" type="integer">
  Milliseconds since the Unix epoch.
</ResponseField>

<ResponseField name="query_id" type="string (ULID)">
  Groups every audit event for this query.
</ResponseField>

<ResponseField name="audit_id" type="string (ULID)">
  The `id` of the `DECIDED` audit row this notification mirrors.
</ResponseField>

<ResponseField name="tenant_id" type="string">
  `__self_host__` for OSS; the customer ULID for Cloud.
</ResponseField>

<ResponseField name="agent_name" type="string | null">
  The MCP `clientInfo.name`. `null` for non-MCP callers.
</ResponseField>

<ResponseField name="agent_version" type="string | null">
  The MCP `clientInfo.version`. `null` for non-MCP callers.
</ResponseField>

<ResponseField name="agent_intent" type="string | null">
  The `intent` argument from the `query` tool (≤ 500 chars). Always populated for `query` denials; `null` for denials surfaced through other paths.
</ResponseField>

<ResponseField name="mcp_token_id" type="string | null">
  The cloud-issued ULID for the MCP token that opened the session (added `0.6.0`). `null` whenever the `X-Midplane-Token-Id` header is absent or malformed — on self-host, on a malformed header, and on non-MCP callers.
</ResponseField>

<ResponseField name="policy_rule" type="string">
  The rule that denied — `table_access`, `multi_statement`, `dangerous_statement`, or `parse_error`. See [denial reasons](/docs/reference/denial-reasons).
</ResponseField>

<ResponseField name="reason" type="string">
  The human-readable message — the same text the agent received.
</ResponseField>

<ResponseField name="statement_type" type="string | null">
  `SELECT`, `DELETE`, etc. when parsing succeeded; `null` when it didn't (e.g. a `parse_error` denial).
</ResponseField>

<ResponseField name="tables_touched" type="string[]">
  Schema-qualified table names the query referenced.
</ResponseField>

<ResponseField name="sql_preview" type="string">
  The raw SQL, truncated at 1024 characters. Empty when the engine couldn't match an `ATTEMPTED` row to the denial (rare — only under buffer pressure).
</ResponseField>

<ResponseField name="sql_truncated" type="boolean">
  `true` when `sql_preview` was truncated.
</ResponseField>

The `POST` is **fire-and-forget**: a 5-second timeout, no retries, and any `2xx` counts as delivered. A webhook failure never blocks or fails the underlying audit write — the `DECIDED` row is the source of truth and always lands, so if the receiver is down the denial is still recorded, the webhook just doesn't fire.

<Note>
  Receivers pinned to `schema_version: 2` should widen their handler — the `intent_source` key is gone. Receivers on `schema_version: 1` should also drop `agent_identity` and adopt the split `agent_name` / `agent_version` keys. The `0.6.0` `mcp_token_id` addition is additive-nullable and did **not** bump `schema_version`; `v3` receivers that don't know the field just ignore it.
</Note>

## Telemetry

Anonymous usage telemetry is **enabled by default** on self-host. Disable it with either variable below. See [telemetry](/docs/self-host/operations#telemetry-and-privacy) for exactly what's collected.

| Variable                          | Default                           | Required | Purpose                                                                                                                                                                                   |
| --------------------------------- | --------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `MIDPLANE_TELEMETRY`              | *(enabled)*                       | No       | `0`, `off`, `false`, or `no` → disabled. `debug` → write payloads to stderr instead of sending them. `1`, `true`, `on`, or unset → enabled. An unrecognized value fails safe to disabled. |
| `DO_NOT_TRACK`                    | *(unset)*                         | No       | The industry-standard opt-out. `1` or `true` disables telemetry regardless of `MIDPLANE_TELEMETRY`.                                                                                       |
| `MIDPLANE_TELEMETRY_ENDPOINT`     | `https://t.midplane.ai/v1/events` | No       | Override the collector URL — useful when routing telemetry through your own egress proxy.                                                                                                 |
| `MIDPLANE_TELEMETRY_HEARTBEAT_MS` | `86400000` *(24h)*                | No       | Interval between heartbeat events, in milliseconds. Min `1`, max `604800000` (7 days). Out-of-range or non-numeric values fall back to the default. Primarily a test hook.                |

```bash theme={null}
# Disable telemetry (either works):
MIDPLANE_TELEMETRY=0
# or
DO_NOT_TRACK=1
```

## Self-host control plane

The variables above configure the standalone engine. The variables below apply only when you run the **control plane** in self-host mode — it spawns the engine for you as a local subprocess. For the full setup, see [self-host deploy & configuration](/docs/self-host/deploy#configuration).

| Variable                    | Default                | Required    | Purpose                                                                                                                                                                                                                                                                                      |
| --------------------------- | ---------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `MIDPLANE_SELF_HOST`        | *(unset)*              | No          | Set to `1` to run the control plane in self-host mode — keyless, uncapped, a single Postgres, and the engine spawned as a local subprocess.                                                                                                                                                  |
| `MIDPLANE_ENGINE_BIN`       | `midplane` *(on PATH)* | No          | Path to the compiled engine binary the control plane spawns. Defaults to `midplane` resolved on PATH.                                                                                                                                                                                        |
| `MIDPLANE_MASK_SALT_MASTER` | *(none)*               | Conditional | Master secret for [column masking](/docs/concepts/masking). The control plane derives a per-project salt from it and injects that as `MIDPLANE_MASK_SALT` per spawn — so set this, not `MIDPLANE_MASK_SALT`, when the control plane manages the engine. Required once any project declares masks. |

The control plane also reads `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, and the local-KMS key and token pepper — see [self-host configuration](/docs/self-host/deploy#configuration) for that full set.

## Related

<Columns cols={2}>
  <Card title="Policy file schema" icon="file-code-2" href="/docs/reference/policy-schema">
    What `MIDPLANE_POLICY_FILE` points at.
  </Card>

  <Card title="HTTP endpoints" icon="network" href="/docs/reference/mcp-tools#http-endpoints">
    The routes `INDEXER_TOKEN` unlocks.
  </Card>
</Columns>
