# Connect ChatGPT to Postgres over MCP Source: https://midplane.ai/docs/agents/chatgpt Add the gateway's URL to ChatGPT as a custom MCP server, sign in, and choose which Postgres databases it may read or write. [Midplane](/docs/) is a gateway between AI agents and your Postgres databases. ChatGPT queries your data through it, never holds a connection string, and every statement it sends is checked against your policy before it runs. ChatGPT connects from OpenAI's servers, so it reaches a gateway only at a public HTTPS URL. A gateway behind Midplane's relay has one, its Midplane URL, with no tunnel, DNS record or open port: [on a server with Docker](/docs/gateway/deploy#docker), [on a platform](/docs/gateway/deploy#on-a-platform), or [on your computer](/docs/gateway/deploy#your-computer) set up with **Midplane URL**, while the computer is awake. At **This computer only** a gateway on your computer has none, as it answers only on that computer; to use a tunnel or a URL of your own instead, see [hosted agents](/docs/gateway/hosted-agents). **1. Copy the gateway's URL** On the project's **Agents** page, under **Connect an agent**, pick **ChatGPT** and copy the URL, such as `https://gw-7hq2m4xd.eu.midplane.app/mcp`. **2. Add a custom MCP server** In ChatGPT on the web, open **Plugins**, select **+**, then **Add custom MCP server**. Name it `Midplane`, paste the URL as the **Server URL** under **Connection**, and choose **OAuth** for authentication. Read the risk warning, select **I understand and want to continue**, then **Create as a plugin**. Your workspace's settings may not allow custom MCP servers: ask its admin. **3. Sign in and choose what it may reach** Install the plugin. ChatGPT opens Midplane: sign in, set each database to **None**, **Read** or **Read + write**, and **Allow**. **4. Try it** In a new chat, type `@`, pick `Midplane`, and ask which tables it can see. Its statement shows up in the project's **Query log**. > [!TIP] > > You're set: ChatGPT can answer questions from your data, and only what your > policy allows runs. Masked columns stay masked, writes that need approval > wait for a person, and every statement is in the audit log. Results go from > your gateway to OpenAI, never through Midplane Cloud, and the relay can't > read them ([what the relay sees](/docs/security#the-relay)). ## Permissions ChatGPT runs `query`, `list_tables`, `describe_table` and `check_approval` without asking: the gateway marks them read-only. It asks you to confirm each `write` call, and can remember your answer for the rest of the chat; an agent you gave **Read** on every database has no `write` tool at all. Midplane's policy decides on top: a write it holds still waits for a project manager ([what clients ask you to confirm](/docs/agents/tools#what-clients-ask-you-to-confirm)). To cut it off, **Revoke** it under **Connected agents** on the **Agents** page. It has to sign in again within 15 minutes. ## If it doesn't work * **ChatGPT can't connect to the URL**: check that the setup step says **Relay · connected**, or the **Gateways** page **Relay: connected**, or see [cannot reach the relay](/docs/troubleshooting#cannot-reach-the-relay). A gateway on your computer must be running, and the computer awake. * **The consent page says it wants access, with no databases**: you aren't a member of the gateway's project, or the URL isn't registered. Ask an owner or admin of its organization to invite you. * **Through OpenAI's Secure MCP Tunnel** instead of the relay, signing in isn't verified yet: see [hosted agents](/docs/gateway/hosted-agents#chatgpt-and-codex-openai-secure-mcp-tunnel). *Tested by* `apps/gateway/test/setup-relay.test.ts` (a gateway behind a stand-in relay) and `apps/gateway/test/auth.test.ts` for the gateway's side; the client's steps by hand, through the relay. # Connect claude.ai to Postgres over MCP Source: https://midplane.ai/docs/agents/claude-ai Add the gateway's URL to claude.ai as a custom connector, sign in, and choose which Postgres databases Claude may read or write. [Midplane](/docs/) is a gateway between AI agents and your Postgres databases. Claude queries your data through it, never holds a connection string, and every statement it sends is checked against your policy before it runs. claude.ai connects from Anthropic's servers, so it reaches a gateway only at a public HTTPS URL. A gateway behind Midplane's relay has one, its Midplane URL, with no tunnel, DNS record or open port: [on a server with Docker](/docs/gateway/deploy#docker), [on a platform](/docs/gateway/deploy#on-a-platform), or [on your computer](/docs/gateway/deploy#your-computer) set up with **Midplane URL**, while the computer is awake. At **This computer only** a gateway on your computer has none, as it answers only on that computer; to use a tunnel or a URL of your own instead, see [hosted agents](/docs/gateway/hosted-agents). **1. Copy the gateway's URL** On the project's **Agents** page, under **Connect an agent**, pick **claude.ai** and copy the URL, such as `https://gw-7hq2m4xd.eu.midplane.app/mcp`. **2. Add a custom connector** In claude.ai, go to **Customize > Connectors**, click **+ Add**, then **Add custom connector**. Name it `Midplane`, paste the URL and click **Continue**. Keep the sign-in settings it detects, and click **Add**. On a Team or Enterprise plan, an owner adds it for the organization, in **Organization settings > Connectors** (**Add**, then **Custom**, then **Web**). Each member then finds it under **Customize > Connectors** and clicks **Connect**. **3. Sign in and choose what it may reach** claude.ai opens Midplane. Sign in, set each database to **None**, **Read** or **Read + write**, and **Allow**. **4. Try it** In a chat, check that `Midplane` is on under **+ > Connectors**, and ask Claude which tables it can see. Its statement shows up in the project's **Query log**. > [!TIP] > > You're set: Claude can answer questions from your data, and only what your > policy allows runs. Masked columns stay masked, writes that need approval > wait for a person, and every statement is in the audit log. Results go from > your gateway to Anthropic, never through Midplane Cloud, and the relay > can't read them ([what the relay sees](/docs/security#the-relay)). ## Permissions In the connector's settings, claude.ai groups the gateway's tools: `query`, `list_tables`, `describe_table` and `check_approval` are read-only, and `write` is the one write/delete tool. Set the read-only group to **Always allow** so reads run without a prompt, and keep `write` at **Needs approval**, or **Blocked** for an agent that should only read: reads keep working either way. An agent you gave **Read** on every database has no write/delete tool to configure. Midplane's policy decides on top: a write it holds still waits for an owner or admin ([what clients ask you to confirm](/docs/agents/tools#what-clients-ask-you-to-confirm)). To cut it off, **Revoke** it under **Connected agents** on the **Agents** page. It has to sign in again within 15 minutes. ## If it doesn't work * **claude.ai can't connect to the URL**: check that the setup step says **Relay · connected**, or the **Gateways** page **Relay: connected**, or see [cannot reach the relay](/docs/troubleshooting#cannot-reach-the-relay). A gateway on your computer must be running, and the computer awake. * **The consent page says it wants access, with no databases**: you aren't a member of the gateway's project, or the URL isn't registered. Ask an owner or admin of its organization to invite you. * **The Gateways page shows a URL of your own as not registered**: see [troubleshooting](/docs/troubleshooting#not-registered). * **claude.ai won't add another connector**: the Free plan allows one custom connector. *Tested by* `apps/gateway/test/setup-relay.test.ts` (a gateway behind a stand-in relay), `apps/gateway/test/urls.e2e.test.ts` (several URLs, and a tunnel that rewrites Host) and `apps/gateway/test/auth.test.ts` for the gateway's side; the client's steps by hand, through the relay. # Connect Claude Code to Postgres over MCP Source: https://midplane.ai/docs/agents/claude-code Add the gateway to Claude Code, sign in, and choose which Postgres databases it may read or write. [Midplane](/docs/) is a gateway between AI agents and your Postgres databases. Claude Code queries your data through it, never holds a connection string, and every statement it sends is checked against your policy before it runs. You need a running gateway ([get started](/docs/get-started)). Its URL is on the project's **Agents** page, under **Connect an agent**. **1. Add the gateway** ```sh theme={null} claude mcp add --transport http midplane ``` **2. Sign in** Start `claude`, run `/mcp`, pick `midplane` and choose **Authenticate**. **3. Choose what it may reach** Set each database to **None**, **Read** or **Read + write**, and **Allow**. **4. Try it** Ask Claude Code which tables it can see. Its statement shows up in the project's **Query log**. > [!TIP] > > You're set: Claude Code can answer questions from your data, and only what your > policy allows runs. Masked columns stay masked, writes that need approval > wait for a person, and every statement is in the audit log. > [!WARNING] > > Claude Code runs commands as you. If the gateway runs as you too, Claude Code > can read its connection strings and reach the database without it. Run the > gateway as another user or on another host ([deploy the > gateway](/docs/gateway/deploy)). To cut it off, **Revoke** it under **Connected agents** on the **Agents** page. It has to sign in again within 15 minutes. > **Use a token instead of signing in** > > For scripts, or a gateway in [local mode](/docs/gateway/local-mode), send a token > from **Tokens for scripts** on the **Agents** page, or from `midplane token` > in local mode: > > ```sh theme={null} > claude mcp add --transport http midplane \ > --header "Authorization: Bearer $(cat token.txt)" > ``` ## Permissions Claude Code asks before an MCP tool call until you allow the tool, or the whole server, in its permission settings. Of the gateway's tools, `query`, `list_tables`, `describe_table` and `check_approval` only read; `write` is the one that changes data, and Midplane's policy still decides whether it runs, waits for an owner or admin, or is refused ([what clients ask you to confirm](/docs/agents/tools#what-clients-ask-you-to-confirm)). ## If it doesn't work * **Claude Code can't connect to the URL**: for a gateway at a Midplane URL, check that the setup step says **Relay · connected**, or the **Gateways** page **Relay: connected**, or see [cannot reach the relay](/docs/troubleshooting#cannot-reach-the-relay). * **The consent page says it wants access, with no databases**: you aren't a member of the gateway's project, or the gateway isn't registered. Ask an owner or admin of its organization to invite you. * **The `localhost` URL does not resolve**: see [troubleshooting](/docs/troubleshooting#the-localhost-url-does-not-resolve). *Tested by* `apps/gateway/test/auth.test.ts` for the gateway's side; the client's steps by hand. # Connect Cursor to Postgres over MCP Source: https://midplane.ai/docs/agents/cursor Add the gateway to Cursor, sign in, and choose which Postgres databases it may read or write. [Midplane](/docs/) is a gateway between AI agents and your Postgres databases. Cursor queries your data through it, never holds a connection string, and every statement it sends is checked against your policy before it runs. You need a running gateway ([get started](/docs/get-started)). **1. Add the gateway** On the project's **Agents** page, under **Connect an agent**, pick **Cursor** and click **Add to Cursor**. Cursor opens and asks to install `midplane`. > **Or add it by hand** > > In `~/.cursor/mcp.json`, with the gateway's URL from that card: > > ```json theme={null} > { > "mcpServers": { > "midplane": { > "url": "" > } > } > } > ``` **2. Sign in** In Cursor's settings under MCP, click **Login** next to `midplane`. **3. Choose what it may reach** Set each database to **None**, **Read** or **Read + write**, and **Allow**. **4. Try it** Ask Cursor which tables it can see. Its statement shows up in the project's **Query log**. > [!TIP] > > You're set: Cursor can answer questions from your data, and only what your > policy allows runs. Masked columns stay masked, writes that need approval > wait for a person, and every statement is in the audit log. To cut it off, **Revoke** it under **Connected agents** on the **Agents** page. It has to sign in again within 15 minutes. ## Permissions Cursor asks before an MCP tool call unless its auto-run setting covers the tool. Of the gateway's tools, `query`, `list_tables`, `describe_table` and `check_approval` only read; `write` is the one that changes data, and Midplane's policy still decides whether it runs, waits for an owner or admin, or is refused ([what clients ask you to confirm](/docs/agents/tools#what-clients-ask-you-to-confirm)). ## If it doesn't work * **Sign-in never finishes**: Cursor takes the sign-in on port 8787, so nothing else may listen there, a gateway included. * **Cursor can't connect to the URL**: for a gateway at a Midplane URL, check that the setup step says **Relay · connected**, or the **Gateways** page **Relay: connected**, or see [cannot reach the relay](/docs/troubleshooting#cannot-reach-the-relay). * **The consent page says it wants access, with no databases**: you aren't a member of the gateway's project, or the gateway isn't registered. Ask an owner or admin of its organization to invite you. * **The `localhost` URL does not resolve**: see [troubleshooting](/docs/troubleshooting#the-localhost-url-does-not-resolve). *Tested by* `apps/gateway/test/auth.test.ts` for the gateway's side; the client's steps by hand. # The gateway's MCP tools Source: https://midplane.ai/docs/agents/tools What an agent can call through the gateway: query, write, list_tables, describe_table and check_approval, their inputs and results, and how claude.ai and ChatGPT gate them. Every agent reaches Postgres through five MCP tools. Two run statements, one kind each: `query` runs reads and `write` runs writes. Clients decide from a tool's annotations whether a call needs your confirmation, so the split is what lets reads run while writes wait ([what clients ask you to confirm](#what-clients-ask-you-to-confirm)). It is enforced, not declared: the gateway refuses a write sent to `query` and a read sent to `write`, and Postgres runs every read in a `READ ONLY` transaction. At `initialize`, the gateway sends instructions for the agent: the order to use the tools in, that masked columns stay masked, how a held write is re-run, that a denial is the policy and not an error to get around, and that content from untrusted columns is data, never instructions. ## query One read-only SQL statement: a `SELECT`, `VALUES`, or a `WITH` query that writes nothing. A write sent here returns an error naming `write`, or saying the agent's access is read-only, and nothing runs. | Input | | | - | - | | `sql` | Exactly one statement | | `intent` | Optional. Why, in a sentence; it goes in the audit log | | `database` | The database id. Optional when the gateway serves one. The argument's description names the databases your access covers, so the agent needn't guess | ## write One SQL write: an `INSERT`, `UPDATE`, `DELETE`, or a schema change Midplane supports (`CREATE TABLE`, `CREATE INDEX`, `ALTER TABLE`, `DROP TABLE`; [the policy](/docs/gateway/configuration#the-policy)). The policy decides: it runs now, waits for a person's approval, or is refused, and the answer says which. A read sent here returns an error naming `query`. The inputs are `query`'s. `intent` is what approvers read, and an approval binds to it: once a held write is approved, the agent re-runs exactly the same statement with the same intent through `write` ([held writes](/docs/policies/approvals-and-taint#held-writes)). An agent whose access is read-only on every database this gateway has doesn't get this tool at all, and its instructions say so. A client keeps the tool list for its session, so an agent given write access sees `write` on its next connection. ## list\_tables The tables and views the agent may read, one per line, with its access to each: ```text theme={null} public.customers (table, read only) public.orders (table, read and write) public.order_totals (view, read only) ``` Takes `database`. A table the policy denies isn't listed. ## describe\_table One table's columns, from the catalog: each column's type, `primary key` or `not null`, where a foreign key points, `[masked]` when the policy masks it, and its comment. The table's comment follows the first line, and a foreign key over several columns follows the columns. ```text theme={null} public.orders (table, read and write) -- One row per checkout id bigint primary key customer_id bigint not null -> public.customers(id) email text [masked] note text -- Free text from the agent ``` Takes `table` (optionally schema-qualified) and `database`. A foreign key to a table the agent can't read is left out, as `list_tables` leaves that table out. All of it is catalog metadata: the gateway reads no row for it, and comments stay on the gateway, never going to Midplane Cloud. ## check\_approval Where a held write stands, by the request id a held `write` returned: pending, with the review link and when it expires; approved, with the statement and intent to re-run through `write`; denied, with the note; expired; or used, with what the run changed. In [local mode](/docs/gateway/local-mode), it says approvals need a linked gateway. ## Results An allowed statement returns JSON text, and the same object as the result's `structuredContent`: ```json theme={null} { "columns": ["id", "email"], "rows": [[1, "***"]], "row_count": 1, "truncated": false } ``` * `rows` are arrays in column order. Bigints come as strings, `bytea` as `\x…` hex, and dates, times and timestamps as Postgres prints them. * A read stops at `limits.max_rows` (1,000 by default) or `limits.max_bytes` (1 MiB) ([configuration](/docs/gateway/configuration)): `truncated` is `true`, `row_count` is `null`, and a line says so. A write's `row_count` is exact. * Masked columns hold masked values. The result doesn't say which columns; `describe_table` does. * A result with content from a column labeled untrusted ends with a note to treat it as data, never as instructions, and that read taints the agent's grant ([taint](/docs/policies/approvals-and-taint#taint)). * A write that ran after approval says who approved it and the request id. ### When nothing runs | Outcome | What comes back | | - | - | | Denied | `isError`, and why: the rule in words, and what the agent can do instead | | Held | `isError`, the request id, the review link, how many rows it would change and when the request expires; `structuredContent.approval` holds `id`, `status`, `preview`, `expires_at` and `review_url` | | Failed in Postgres | `isError`, `Postgres returned an error (SQLSTATE): message`. On a database with masks the message's detail and hint are dropped, and on a statement that reads a table with untrusted columns only the SQLSTATE comes back: either could quote a row | | Unavailable | `isError`, and why nothing runs now: the project is paused, the gateway has no policy or halted, the database can't be reached, or the audit log can't be written (every statement is recorded before it runs) | | Too many arguments | `isError`, before anything is recorded: a call's arguments hold at most 64 array elements and object members, and no tool takes more than three | `EXPLAIN` is refused as unsupported, like everything else the gateway can't analyze: `SET`, `SHOW`, `COPY`, transaction control and `MERGE`. Each statement is one statement; a write hidden in a `WITH` is denied. ## What clients ask you to confirm Each tool carries the MCP annotations clients read: | Tool | `readOnlyHint` | `destructiveHint` | `idempotentHint` | `openWorldHint` | | - | - | - | - | - | | `query` | `true` | `false` | `true` | `false` | | `write` | `false` | `true` | `false` | `false` | | `list_tables`, `describe_table`, `check_approval` | `true` | `false` | `true` | `false` | * **claude.ai** groups a connector's tools into read-only and write/delete, each set to Always allow, Needs approval or Blocked. `write` is the one tool in the write/delete group; blocking it leaves reads working ([claude.ai](/docs/agents/claude-ai#permissions)). * **ChatGPT** treats a tool without `readOnlyHint` as a write action and asks before each call, remembering your answer for the chat. Reads run; `write` asks ([ChatGPT](/docs/agents/chatgpt#permissions)). * **Codex CLI**, in its writes approval mode, runs read-only tools without asking and prompts for the rest. * **Claude Code, Cursor and VS Code** ask by their own settings, per tool or per server. The two checks are different people's. The client's prompt is yours, as the agent's user. A held write waits for an owner or admin in Midplane, and the policy decides whether a write runs at all, whatever the client allowed. If the policy already holds the writes that matter, Always allow on `write` is reasonable: the prompt then adds a check only to writes the policy runs outright. Before `0.26.0`, one `query` tool ran reads and writes, so clients treated every read as a write ([upgrading](/docs/gateway/upgrading#changes-that-need-attention)). *Tested by* `apps/gateway/test/gateway.e2e.test.ts` (the split and what each refusal says, every tool's title and hints, the instructions, the database argument, and keys and comments without a row read) and `packages/corpus/fixtures/tools.json` (the rule in the core). # Connect VS Code to Postgres over MCP Source: https://midplane.ai/docs/agents/vs-code Add the gateway to VS Code so GitHub Copilot can query Postgres: sign in, then choose which databases it may read or write. [Midplane](/docs/) is a gateway between AI agents and your Postgres databases. GitHub Copilot in VS Code queries your data through it, never holds a connection string, and every statement it sends is checked against your policy before it runs. You need a running gateway ([get started](/docs/get-started)). **1. Add the gateway** On the project's **Agents** page, under **Connect an agent**, pick **VS Code** and click **Add to VS Code**. VS Code opens and asks to install `midplane`, in your user profile. > **Or add it by hand, for one workspace** > > In `.vscode/mcp.json`, with the gateway's URL from that card: > > ```json theme={null} > { > "servers": { > "midplane": { > "type": "http", > "url": "" > } > } > } > ``` **2. Sign in** Start `midplane`, and allow the sign-in. **3. Choose what it may reach** Set each database to **None**, **Read** or **Read + write**, and **Allow**. **4. Try it** Ask Copilot, in agent mode, which tables it can see. Its statement shows up in the project's **Query log**. > [!TIP] > > You're set: Copilot can answer questions from your data, and only what your > policy allows runs. Masked columns stay masked, writes that need approval > wait for a person, and every statement is in the audit log. To cut it off, **Revoke** it under **Connected agents** on the **Agents** page. It has to sign in again within 15 minutes. ## Permissions VS Code asks before an MCP tool call, and lets you allow a tool for the session, the workspace or always. Of the gateway's tools, `query`, `list_tables`, `describe_table` and `check_approval` only read; `write` is the one that changes data, and Midplane's policy still decides whether it runs, waits for an owner or admin, or is refused ([what clients ask you to confirm](/docs/agents/tools#what-clients-ask-you-to-confirm)). ## If it doesn't work * **VS Code can't connect to the URL**: for a gateway at a Midplane URL, check that the setup step says **Relay · connected**, or the **Gateways** page **Relay: connected**, or see [cannot reach the relay](/docs/troubleshooting#cannot-reach-the-relay). * **The consent page says it wants access, with no databases**: you aren't a member of the gateway's project, or the gateway isn't registered. Ask an owner or admin of its organization to invite you. * **The `localhost` URL does not resolve**: see [troubleshooting](/docs/troubleshooting#the-localhost-url-does-not-resolve). *Tested by* `apps/gateway/test/auth.test.ts` for the gateway's side; the client's steps by hand. # Audit Source: https://midplane.ai/docs/audit The gateway's audit log, what goes to Midplane Cloud, retention, export and verification. Every statement the gateway evaluates is recorded on its own disk before it runs. The file is the full record, it stays in your network, and you can export and verify it. A linked gateway also sends Midplane Cloud a projection of each event for the dashboard's query log: metadata and the statement with its values replaced, unless you turn on full statements for a database. ## The file `audit.file` is a SQLite database (Node's built-in SQLite, WAL, full sync): an event is on disk before the gateway goes on. | Event | When | Holds | | - | - | - | | `ATTEMPTED` | Before the statement is evaluated | Database, person (`sub`), agent (`client_id`), grant, the statement as sent, the intent | | `DECIDED` | Before anything runs | Verdict, rule and reason, the kind of write (row or schema change), fingerprint, tables, whether it taints or comes back masked, the policy version | | `APPROVAL` | When a held write is filed, claimed, recounted, or its claim fails | Approval id, step, status, the row count | | `EXECUTED` | After it ran | Row count, duration, whether the result was cut at the caps | | `FAILED` | After it failed | SQLSTATE and the message | * **Nothing runs unrecorded.** If `ATTEMPTED` or `DECIDED` can't be written (a full or read-only disk), the statement doesn't run; the agent is told the audit log can't be written. The gateway also refuses to start on a file it can't write. * **Calls refused before evaluation leave no event.** The gateway refuses a call before it reads the statement while it has no policy yet, is halted, or its project is paused; when the call names a database it doesn't serve, or names none and it serves several; and while the database can't be reached. Nothing runs, and nothing is recorded. * **No results.** The record proves what ran, never what it returned. It does hold statements as written, which can contain values a person typed. * **A hash chain.** Each event carries the hash of the one before it, so an event removed or changed breaks the chain from there on. * **Several local processes, one file.** Processes may share a file (two Claude Code sessions, each starting `midplane local --stdio`): each event takes its sequence and the hash before it from the file as it is written, so they keep one chain, and share the taint it holds. * **One linked gateway per file.** Each file has a random instance id, which a linked gateway pushes it under; replicas each keep their own. A file whose events part from what Midplane Cloud holds under its id (restored from a backup, or copied to a second replica) gets a new id the first time the cloud answers one of its batches with a conflict, an event it holds under another hash: the cloud stores and acks the batch only up to there, and the file sends the rest as the new instance (see [operations](/docs/gateway/operations#backups)). ## What goes to Midplane Cloud (linked mode) The gateway sends each event once it is on disk, on a loop of its own: pushing never holds up a statement, and a cloud that is down or refuses the push changes nothing on the gateway. Events wait in the file until the cloud has them; each sync reports how many wait, and the Gateways page shows it. | Sent | Never sent | | - | - | | Times, query id, database, person, agent, grant; each statement's kind (`InsertStmt`, `CopyStmt`); verdict, rule, the kind of write, the tables the catalog knows, taint, masked, policy version; approval steps and counts; row counts, durations, SQLSTATEs; each event's position in the chain, and its hash keyed with the file's secret | Results; Postgres error messages (they can quote a row); the DSN and the salt; the statement's fingerprint (it covers names, so a guessed name could be confirmed against it); the chain's own hashes, and the key | | The statement with every literal replaced by `$1, $2…`, every name the database's catalog doesn't have replaced by `_1, _2…`, and comments dropped | The statement as written, the agent's intent and a denial's reason, **unless** the database's switch is on | * **Redaction is checked twice, and fails closed.** The redacted text must parse back to the same statement, then pass an allowlist read token by token: keywords, names, `$n` markers, punctuation and operators, integers up to 1000 (positions and type modifiers are small), and no quoted name but one the catalog has, a built-in collation or a keyword. Anything else fails it: strings of every spelling, bit and hex strings, floats, comments. A statement that can't be vouched for goes with no text, only why: it doesn't parse, it is a kind Midplane doesn't evaluate (`COPY`, `ALTER ROLE … PASSWORD`), it is over 16 KiB, or it failed a check. Midplane Cloud applies the same allowlist, reading text that holds an integer as a tree too, and stores any statement that fails without text; text cut to fit that holds an integer is stored without text too, since a position can't be told from a value there. * **Hashes are keyed.** The cloud holds every field of an event but its text, and the hash before it, so the chain's own hash would let it test a guess at a literal offline (a four-digit one falls in milliseconds). It gets each event's hash as HMAC-SHA256 under a random key made with the file instead; the key leaves the file only in its exports, so the cloud has nothing to test a guess against. The file's own chain is plain SHA-256, and `verify` checks it without the key. * **One known gap:** an unquoted lowercase name in a position the walker doesn't rename (a function or type name, say `jane(id)`) goes as written. Values written as names need quoting almost always (`"jane@example.com"`, `"4111-1111"`), and those are caught. * **The switch.** "Send full statements and intents" is per database, off by default: when you add a database, and on its policy page. It travels in the signed bundle; the gateway reads it from there, never from an unsigned answer. It applies when an event is sent, not when it was recorded: turning it on also sends as written the events from while it was off that haven't gone yet (a backlog from a cloud that was down, say). Turning it off stops full text from the next batch, and deletes the full text Midplane Cloud kept for that database. Held writes always send their statement and intent when filed, switch or not: an approver has to read them. * **Text is capped** at 16 KiB per statement in what's sent (the file keeps all of it). * **Only what was recorded while linked.** Events recorded in local mode stay in the file and are never sent. So do the events in a file from a gateway before 0.21: linked, it sends only what it records from then on. Opening a file in local mode stops sending the events a linked gateway left waiting; the gateway logs how many, and keeps them the full window from then. ## Retention * **On the gateway:** `audit.retention_days` (default 30; `0` keeps everything). Events are kept that many days after Midplane Cloud has them (in local mode, after they are recorded), so a backlog sent after an outage still stays the full window, and an event the cloud doesn't have yet stays however old. Events a linked gateway left unsent count from when the file is opened in local mode, and a file from before 0.21 from when 0.21 first opens it. Whatever the window, an event younger than 26 hours stays: a held write's request may wait 24 hours and then run within 2, and its approval reads its events back all that time. Hourly, and at start, the gateway deletes the oldest events past that, and keeps the last one's hash as the anchor the chain is verified from. Only the oldest run is ever deleted, so what remains still verifies. * **In Midplane Cloud:** the query log keeps 30 days by default; its page says how long. ## Export and verify ```sh theme={null} midplane audit export --config midplane.yaml [--out audit.jsonl] [--since ] midplane audit verify --file audit.jsonl [--checkpoints cloud-export.jsonl] midplane audit verify --config midplane.yaml ``` * Both read only `audit.file` from the config, so they need none of its secrets, and they open the file read-only beside a running gateway. Each reads one snapshot of the file, so a prune meanwhile changes nothing in it. `--audit ` names the file directly. * `--out` never overwrites a file: an export to a name that exists fails. On a schedule, give each export a new name: ```sh theme={null} midplane audit export --config midplane.yaml --out "audit-$(date +%F).jsonl" ``` * An export is JSON lines: a header with the file's instance, the anchor and the checkpoint key, then each event with its sequence, the hash before it and its own hash. Keep it as private as the file: it holds the statements as written, and the key. * `verify` recomputes the chain from the anchor and names the first event that doesn't follow. With `--checkpoints` (the query log's Export from the dashboard), every hash Midplane Cloud recorded for this file must match, and the file must be one the cloud knows: its instance appears in the export, under one gateway, at least one of the cloud's hashes falls within it, and its anchor, if the cloud saw that event, has the cloud's hash. The cloud's hashes are keyed, so checking them takes the key: the file's, or the one in the export's header. An export without one fails with `--checkpoints`, saying so; without them, its chain still verifies alone. A live file the cloud saw past is reported as cut short. Only the hashes of the file's current instance count: after a restore made it a new instance, the events pushed under the old one are checked by the chain alone. * `verify` exits `0` when the record verifies. It exits `1` when it doesn't (a broken chain, a checkpoint that doesn't match), and also when it can't check at all: an input or config it can't read, a config with no `audit.file`. It exits `2`, printing its usage, when given nothing to read, and (`unreleased`) given an option it doesn't know. Treat only `0` as verified. * **What it proves:** the record is internally consistent from its anchor, and nothing Midplane Cloud saw was changed afterwards. **What it can't:** the chain is a plain SHA-256 chain, so events the cloud never received can be rewritten undetectably by whoever controls the file; take exports, and keep the cloud's, on a schedule. *Tested by* `apps/gateway/test/audit.test.ts` (the chain, two processes on one file, retention from the ack and the anchor and around live approvals, files from before 0.21 and a switch to local mode, a new instance, export and verify beside a prune, checkpoints and their key, the projection and its keyed hash), `apps/gateway/test/audit.e2e.test.ts` (the push, the switch on and off, a cloud that refuses or keeps answering 429, acks that don't verify or cover part of a batch, an answer cut off mid-body, a conflict, a forged copy stored first, a restored file, and a scan of everything sent for row values, the DSN, the salt, the checkpoint key, Postgres messages, literals and intents), and `apps/gateway/test/gateway.e2e.test.ts` (a read-only audit file runs nothing). # Run the gateway on AWS with CloudFormation Source: https://midplane.ai/docs/gateway/aws One CloudFormation stack puts the gateway in your VPC, next to RDS or Aurora: an EC2 instance with nothing inbound, its env file in Secrets Manager and its logs in CloudWatch. The template runs the gateway on one EC2 instance in your database's VPC, behind Midplane's relay: agents reach it at a Midplane URL, and nothing connects in to the instance. It's the way to reach an RDS or Aurora database that stays private. ## What the stack makes | Resource | What it is | | - | - | | EC2 instance | Graviton, `t4g.small` by default (2 GB), Amazon Linux 2023, an encrypted 16 GB disk. The gateway runs in Docker, as a systemd service | | Security group | No inbound rules, all outbound. If you name your database's security group, a rule there admits this one on its port | | Secrets Manager secret | The env file your project's setup page shows, empty until you set it. Only the instance's role can read it | | CloudWatch log group | What the gateway says, kept 30 days | | IAM role | Reads that secret, writes those logs, and gives you a shell through Session Manager, with no port open | The instance connects out to Midplane Cloud and its relay on port 443, and to your database. Its subnet must reach both: a private subnet with a NAT gateway, or a public subnet with a public IP address. ## Set it up **1. Create an enrollment token** In your project, open step 1 of the setup (or **Gateways**, then **Set up a gateway**), choose **A server (Docker)**, keep **Midplane URL**, and under **Runs on** choose **AWS (CloudFormation)**. Click **Create enrollment token**, above the tabs, and keep the page open. **2. Launch the stack** Click **Launch the stack on AWS**. It opens the console in `eu-central-1`: switch it to your database's region first, at the top of the page. Then choose: * **VPC**: your database's. * **Subnet**: one that reaches the database, and the internet on port 443. * **Public IP address**: `yes` only for a public subnet without a NAT gateway, such as the default VPC's. The instance still takes no inbound traffic. * **Database's security group**: optional. Name it, and the stack admits the gateway there on the database's port. Leave it empty, and allow the stack's `GatewaySecurityGroup` output there yourself. Tick that the stack creates IAM resources, and create it. It takes a few minutes. **3. Set the env file** In the stack's **Outputs**, open **EnvFile**. Set the secret's value, as **Plaintext**, to the env file the page shows, with each database's connection string after its `=` ([prepare your database](/docs/prepare-database)). Use the database's endpoint as the host. For RDS, end the string with `sslmode=verify-full&sslrootcert=/etc/midplane/tls/rds-global-bundle.pem`: the instance downloads that bundle when it starts. **4. Check it on the page** The gateway reads the secret within a minute, joins your project, and gets its certificate through the relay. The page then shows its URL for agents. The **Logs** output shows what it says, such as why it stopped. Until the secret has a value, the logs say `no config` every 30 seconds: that's the gateway waiting for it. Each start reads the secret again, so a failed start after a fix needs nothing but a minute. ## Run it * **A connection string changes.** Change it in the secret, then restart the gateway: reboot the instance, or run `sudo systemctl restart midplane` in a shell from the **Shell** output. * **A database is added later.** Add its `MIDPLANE_DSN_` line to the secret: alone, it is ignored once the gateway has set itself up. Then, in a shell from the **Shell** output, append the database's line to the config on the gateway's volume with `sudo docker exec -i midplane sh -c 'cat >> midplane.yaml'`, and restart the gateway as above ([adding databases](/docs/gateway/multiple-gateways#adding-databases)). * **Upgrade.** Update the stack with the new version's template ([upgrading](/docs/gateway/upgrading#aws)). Each version's template runs that version only. * **Remove it.** Revoke the gateway on your project's **Gateways** page, then delete the stack. That deletes the instance, its disk with the audit log, and the secret. The template is [`deploy/aws/midplane-gateway.yaml`](https://github.com/midplaneai/midplane/blob/main/deploy/aws/midplane-gateway.yaml). To keep Midplane's relay out of the path, run the gateway at [your own URL](/docs/gateway/deploy#your-own-url) instead. *Tested by* `apps/gateway/test/deploy-templates.test.ts` (the template's version, no inbound rule, no secret in a parameter or the user data, the gateway's folder on the encrypted disk, the instance's role out of the container's reach). # Configuration Source: https://midplane.ai/docs/gateway/configuration Every key in midplane.yaml, in both modes, the variables setup reads, every command and the policy. One YAML file, `midplane.yaml`, named with `--config`. Relative paths in it are relative to the file. Secrets never appear inline: a DSN, the mask salt, the identity and the enrollment token each name an environment variable or a file, so the config itself can be committed. ```yaml theme={null} dsn: { env: DSN_MAIN } # an environment variable dsn: { file: /run/secrets/dsn } # or a file, read once at start ``` Unknown keys are errors, and so is anything that doesn't validate: the gateway won't start on a config it can't fully read. ## Both modes | Key | Default | What | | - | - | - | | `listen.host` | `127.0.0.1` | Interface to listen on. Anything but loopback requires `tls`. `0.0.0.0` or `::` also requires `public_urls` | | `listen.port` | `7433` | | | `listen.allowed_hosts` | `[]` | Up to eight more host names the `Host` header may carry, for a proxy that rewrites it: a name, or an IPv6 address in brackets. Not token audiences ([hosted agents](/docs/gateway/hosted-agents)) | | `public_urls` | none | Up to eight base URLs agents reach the gateway at, with no credentials, query or fragment: `https://…`, or `http://` on your computer (`localhost`, a name under `.localhost`, `127.0.0.1` or `[::1]`). Midplane Cloud registers no other http URL, so a linked gateway that lists one can't join a project. Each `/mcp` is a token audience. Without them, the listener's own URL | | `public_url` | none | Shorthand for one; not with `public_urls` | | `tls.cert_file`, `tls.key_file` | none | PEM files; HTTPS on the listener. (`0.23.0+`) Read again every 10 minutes: a replacement whose key matches, that is in date and covers the same public URL hosts is served to new connections without a restart | | `tls.acme` | none | (`0.23.0+`) A certificate the gateway gets and renews itself, from Let's Encrypt; not with `cert_file` and `key_file` ([automatic certificates](#automatic-certificates)) | | `tls.acme.http_port` | `7480` | Where the challenge listener binds, on every interface. The server's port 80 must reach it; not `listen.port` | | `tls.acme.directory_url` | Let's Encrypt | Another ACME directory, such as Let's Encrypt's staging (`https://acme-staging-v02.api.letsencrypt.org/directory`), whose certificates agents don't trust. https, or http on loopback | | `audit.file` | required | The audit log, a SQLite file ([audit](/docs/audit)). Local processes may share one; give each linked gateway its own | | `audit.retention_days` | `30` | Days to keep each event after Midplane Cloud has it (in local mode, after it is recorded; an event a linked gateway left unsent, after the file is opened in local mode), at most 3650. An event the cloud hasn't acked is kept however old. `0` keeps everything | | `mask_salt` | none | A secret source for the salt that keys `consistent-hash` masks: at least 32 characters, the same on every instance ([secrets](/docs/gateway/secrets)). Required once a policy has masks | | `limits.max_rows` | `1000` | Rows returned per query, at most 1,000,000. A read stops at the cap and says it was truncated | | `limits.max_bytes` | `1048576` | Bytes returned per query, from 1 KiB to 256 MiB | | `databases..dsn` | required | A secret source for the connection string, for at least one database. The id starts with a lowercase letter and has up to 32 lowercase letters, digits, `-` or `_` (`main`, `billing-eu`) | ## Automatic certificates (`0.23.0+`) With `tls.acme`, the gateway gets its certificate from Let's Encrypt for the host of its one https public URL, and keeps it current: ```yaml theme={null} listen: { host: 0.0.0.0, port: 7433 } public_urls: [https://midplane.example.com] tls: { acme: {} } ``` * **Before it listens**, it uses the certificate it kept, if that covers the host and has more than 30 days left, or gets a new one. If Let's Encrypt refuses and the kept certificate still covers the host and is in date, the gateway serves it, logs `certificate renewal failed`, and renews it at the next check. With no certificate it can serve, the gateway exits, its last log line saying why with `acme_type` and `acme_detail` ([troubleshooting](/docs/troubleshooting#the-gateway-does-not-connect)). * **Every 12 hours** it renews a certificate with 30 days or fewer left, and serves the new one to new connections without a restart. A renewal that fails logs `certificate renewal failed` and is tried at the next check; the current certificate is served meanwhile. * **Let's Encrypt checks the host** with an HTTP-01 challenge: it fetches a token from port 80 of the host, which must reach `http_port` (in Docker, `-p 80:7480`). The listener there answers a token pending right now and nothing else: every other request, `/mcp` included, gets an empty `404`, so no agent's token is read in the clear. * **It keeps** the certificate, its key and the account's key in `acme/` beside the config, closed to other users. Keep the folder: Let's Encrypt issues at most five certificates for the same host a week. The host must be a name, not an IP address, and must be the only https host in `public_urls`: a tunnel's or proxy's URL leads Let's Encrypt somewhere else. Getting a certificate agrees to Let's Encrypt's [subscriber agreement](https://letsencrypt.org/repository/). Setup with `--acme` (`midplane setup --url https://… --acme`) writes `tls: { acme: {} }` and gets the first certificate before it joins the project. ## Behind the relay With `relay`, agents reach a linked gateway through Midplane's relay, at its Midplane URL, and the gateway only connects out. `midplane setup --relay`, or setup from environment variables without `MIDPLANE_URL`, asks Midplane Cloud for its relay and writes: ```yaml theme={null} listen: { host: 127.0.0.1, port: 7433 } relay: { control: relay.eu.midplane.app } public_urls: [https://gw-7hq2m4xd.eu.midplane.app] tls: { acme: {} } ``` * **`relay.control`** is the relay's name, `relay.` and the relay domain. The gateway keeps 4 connections open to it on port 443, authenticated with its link token, and takes agents' connections over them. * **`public_urls`** holds one URL, the gateway's Midplane URL: `https://gw-` and 8 characters, on the relay domain, with no port or path. It gets one only when it joins a project, and it is never given to another gateway, even after this one is revoked. * **`tls.acme`** is required, without `http_port`: the gateway gets its certificate from Let's Encrypt through the relay, which forwards the check of port 80 to it, so it binds no port for it. `directory_url` may name another ACME directory. * **`listen.host`** must be loopback: the listener is for health checks, and agents come through the relay. Each of these is checked at start, and a config that breaks one is refused by its key. A config with `relay` in local mode is refused too. What the relay sees, and what it can't, is in [security](/docs/security#the-relay). Two more rules the config can't check: * **It needs a disk.** The gateway keeps its certificate in `acme/` beside the config, on its volume, and never in a variable. Without a disk it gets a new certificate at every start, and Let's Encrypt issues at most five for one name a week. * **Run at most two copies of it.** The relay holds 8 idle connections per gateway, and each copy keeps 4. Each copy orders its own certificate, out of the same five a week, and the relay passes Let's Encrypt's check to one of the gateway's connections after another until the copy that ordered it answers. For more copies, use your own URL ([replicas](/docs/gateway/multiple-gateways#replicas)). ## Local mode only (`midplane local`) | Key | Default | What | | - | - | - | | `project` | `local` | The `project` claim tokens must carry | | `auth.issuer` | required | The `iss` tokens must carry | | `auth.public_key_file` | required | A public JWK (or a JWKS) that tokens are verified with; `midplane keygen` writes one | | `auth.revoked_token_ids` | `[]` | Token ids (`jti`) to refuse | | `databases..policy` | `{}` | The database's policy (below). `{}` means nothing is readable | ## Linked mode only (`midplane gateway`) | Key | Default | What | | - | - | - | | `link.cloud_url` | required | Midplane Cloud's URL: https, or http on loopback | | `link.identity` | required | Where the gateway's identity lives: `{ file: identity.json }`, written at enrollment, or `{ env: VAR }` holding what `midplane enroll` printed ([linked mode](/docs/gateway/linked-mode)) | | `link.enrollment_token` | none | A secret source for the one-time token from the project's Gateways page; read only until the identity exists | | `link.bundle_cache` | `bundle.jws` | The newest authentic bundle, enforced again after a restart with the cloud down | | `link.name` | none | How the dashboard names this gateway, up to 80 characters | | `relay.control` | none | The relay's name, `relay.`, such as `relay.eu.midplane.app`: agents reach the gateway through it ([behind the relay](#behind-the-relay)) | Linked databases have no `policy`: policies come in signed bundles from Midplane Cloud. ## Setup from environment variables (`0.24.0+`) `midplane start`, the image's default command, serves the first of these that exists: 1. `/etc/midplane/midplane.yaml`, a config you mounted. 2. `/var/lib/midplane/midplane.yaml`, the config of a volume set up before. 3. With `MIDPLANE_ENROLLMENT_TOKEN` set, a gateway it sets up in `/var/lib/midplane` from the variables below, then serves: behind the relay, as `midplane setup --relay` would from its answers, or with `MIDPLANE_URL` at that URL, as `midplane setup --acme` would. It joins the project first and gets the certificate as it starts, so a token the project refuses gets none. Without any of them, it exits with `no config`. Each variable is read on the first start only, but for the connection strings: | Variable | Required | What | | - | - | - | | `MIDPLANE_CLOUD_URL` | yes | Midplane Cloud's URL, such as `https://eu.app.midplane.ai` (`--cloud`) | | `MIDPLANE_ENROLLMENT_TOKEN` | yes, until it has joined | The token from the project's setup page (`--token`) | | `MIDPLANE_URL` | no | The `https://` URL agents reach the gateway at (`--url`). Its host must be a name: the certificate is automatic (`--acme`). Without it, the gateway is set up [behind the relay](#behind-the-relay) (`--relay`), and a cloud without a relay refuses it | | `MIDPLANE_DSN_` | at least one | A database's connection string. The id is `` lowercased: `MIDPLANE_DSN_SHOP` is `shop`. An id with `-` can't be named this way, as Docker drops such a variable: use an id with `_`. Kept in the environment: the config names the variable (`dsn: { env: MIDPLANE_DSN_SHOP }`), and the gateway reads it at every start | | `MIDPLANE_PORT` | no | The port the gateway listens on in the container, `7433` by default (`--port`). Behind the relay, on loopback, for health checks | | `MIDPLANE_ACME_DIRECTORY` | no | Another ACME directory, such as Let's Encrypt's staging (`--acme-directory`) | Setup's messages are log lines. It checks the variables before it writes anything: it names at once every one that is missing and every `MIDPLANE_DSN_*` that is wrong, then the first other value that is wrong, and never a connection string's value. A failure before the gateway joins the project removes what it wrote and leaves the token unused. A certificate it can't get is tried again at each start. A start that fails exits at once, its error the last log line, and a restart policy starts it again. Once the volume has a config, a new `MIDPLANE_DSN_*` alone is ignored: add the database to the config too ([adding databases](/docs/gateway/multiple-gateways#adding-databases)). ## Commands Every command needs Node 24.16 or newer. (`unreleased`) `--help` after any command prints the usage, and so does a flag the command doesn't know, which exits 2. | Command | What | | - | - | | `midplane setup` | Asks for each database's connection string, tests it, writes the gateway's folder and joins the project, then serves it as `midplane gateway` would. Stopped with `Ctrl-C` before it joins, it removes what it wrote and leaves the token unused. Its flags are [below](#setup-flags) | | `midplane start [--config ] [--dir ]` | The image's default command. Serves `--config` (`/etc/midplane/midplane.yaml` by default), else `/midplane.yaml` (`/var/lib/midplane` by default), else sets up from the environment into `` and serves ([setup from environment variables](#setup-from-environment-variables)) | | `midplane gateway --config ` | Serves in [linked mode](/docs/gateway/linked-mode) | | `midplane enroll --config [--out ]` | Joins the project once and prints the identity as one line of JSON, or writes it to `--out` with mode 0600 | | `midplane local --config [--stdio]` | Serves in [local mode](/docs/gateway/local-mode). With `--stdio`, MCP goes over stdin and stdout, with the token in `MIDPLANE_TOKEN` | | `midplane keygen --out ` | Writes `midplane-signing-key.json` (mode 0600) and `midplane-verify-key.json` in `` | | `midplane token --config --key --sub ` | Prints a local-mode token, signed with the key `--key` names. `--database ` limits it to one database (every one in the config by default), `--access` is `read` (the default) or `write`, and `--ttl` is a whole number of seconds (`3600` by default, `86400` for a day). (`unreleased`) A `--ttl` such as `24h` is refused, and stderr says when the token expires | | `midplane audit export`, `midplane audit verify` | Export the audit file, and verify a file or an export ([audit](/docs/audit#export-and-verify)) | | `midplane --version` | Prints `midplane` and its version | `enroll` and `keygen` never write over a file. ### Setup flags | Flag | Default | What | | - | - | - | | `--cloud ` | required | Midplane Cloud's URL: https, or http on loopback | | `--token ` | required | The enrollment token | | `--database ` | none | A database of the project, asked for first. Repeat it for each; `Enter` skips one. Setup then offers to add others | | `--relay` | off | Sets the gateway up [behind the relay](#behind-the-relay). Not with `--url`, `--acme` or `--tls-dir`. A cloud without a relay refuses it | | `--url ` | none | The URL agents reach a server gateway at: https, a host and no path. The gateway listens on every interface, with TLS | | `--tls-dir ` | `/etc/midplane/tls` | With `--url`: the folder holding the certificate, `tls.crt`, and its key, `tls.key`. Setup checks that the certificate covers the URL's host, is in date and matches its key | | `--acme` | off | With `--url`: an [automatic certificate](#automatic-certificates), which setup gets before it joins the project, answering Let's Encrypt's check on port 7480. Not with `--tls-dir`. The URL's host must be a name | | `--acme-directory ` | Let's Encrypt | With `--acme` or `--relay`: another ACME directory, https or http on loopback | | `--port ` | `7433` | The port to listen on, 1 to 65535. On loopback (on your computer, or behind the relay), the port asked for must be free, and without one setup takes the first free port from 7433 to 7442 | | `--dir ` | `gw-` and 8 characters | The gateway's folder. It must not exist, or must be empty but for `lost+found` | | `--no-start` | off | Exit once set up, instead of serving | Without `--relay` or `--url`, setup makes a gateway for your computer: on loopback, at `http://gw-….localhost` and its port, with no TLS. ## The policy A database's policy, in local mode under `databases..policy`, in linked mode authored in the dashboard (which shows it as JSON too). Strict: an unknown key anywhere refuses the whole policy, never part of it. ```yaml theme={null} table_access: default: deny # deny | read | read_write tables: public.customers: read # schema-qualified public.notes: read_write writes: row_changes: hold # allow | hold | deny; INSERT, UPDATE, DELETE, CREATE TABLE [AS] schema_changes: deny # allow | hold | deny; ALTER, DROP, CREATE INDEX masks: public.customers: id: none # reviewed and left clear email: consistent-hash phone: { t: partial, keepEnd: 4 } created_at: { t: generalize, granularity: month } labels: untrusted_columns: public.support_tickets: [body] secret_tables: [public.api_keys] role: agent_readonly # SET LOCAL ROLE for every statement limits: statement_timeout_ms: 30000 lock_timeout_ms: 5000 ``` | Section | Default | What | | - | - | - | | `table_access.default` | `deny` | Access to a table not listed. `information_schema` is always readable, `pg_catalog` never. A partition without its own entry takes its parent's | | `table_access.tables` | `{}` | `schema.table` → `deny`, `read` or `read_write` | | `writes.row_changes` | `allow` | `hold` waits for a person's approval (linked mode); while an agent is tainted, `allow` becomes `hold` | | `writes.schema_changes` | `deny` | Only shape changes are ever allowed: owners, row security, triggers and storage options are refused | | `masks` | `{}` | `schema.table` → column → rule ([masking](/docs/policies/masking)). In a table with an entry, a column without one is fully redacted until reviewed | | `labels.untrusted_columns` | `{}` | Columns whose values anyone may have written. Reading one taints the agent's grant | | `labels.secret_tables` | `[]` | Tables a tainted grant may not touch ([approvals and taint](/docs/policies/approvals-and-taint)) | | `role` | none | Role every statement runs as (`SET LOCAL ROLE`); the gateway's login role must be a member | | `limits.statement_timeout_ms` | `30000` | Up to an hour | | `limits.lock_timeout_ms` | `5000` | Up to an hour | Whatever the policy says, a statement must be one statement Midplane can parse and resolve; writes need a `WHERE`; writes hidden inside a `WITH` are denied; and reads run in read-only transactions. *Tested by* `apps/gateway/test/config.test.ts` (secrets and paths, TLS off loopback, `tls.acme`, public URLs, the salt, retention, unknown keys, both modes), `apps/gateway/test/certificates.test.ts` and `apps/gateway/test/acme.e2e.test.ts` (getting, keeping, renewing and reloading certificates), `apps/gateway/test/setup.test.ts` and `apps/gateway/test/cli.e2e.test.ts` (setup's flags and the commands), and the core's corpus (`packages/corpus`) for what a policy means. # Deploy the gateway Source: https://midplane.ai/docs/gateway/deploy What the gateway is, where to run it, and the steps to get it running on your computer, on a server with Docker, or on a platform. The gateway is the part of Midplane that runs in your network. It's a small program next to your databases: agents connect to it, it checks each statement against your policy and records it, and only then runs it. It holds your connection strings and opens its own connections: to your databases, and to Midplane Cloud for your policy. Behind Midplane's relay, agents' traffic comes in over connections the gateway opens too, so nothing connects in to it at all. ## Where to run it > **[Your computer](#your-computer)** > > To try Midplane: with agents on your computer, or with claude.ai and > ChatGPT too, at a Midplane URL. > **[A server, with Docker](#docker)** > > For a team or production: agents anywhere reach it at an HTTPS URL, > claude.ai and ChatGPT included, even in a private network. > **[A cloud platform](#on-a-platform)** > > The same, from a template: AWS, Fly.io, Render or Kubernetes. Run it where it can reach your databases: on your computer for a local or cloud database, inside the VPC for a private one. ## Your computer You need Node 24.16 or newer, and macOS, Linux or WSL on Windows. Under **Agents reach it at**, the page offers two URLs where Midplane has a relay: | | This computer only | Midplane URL | | - | - | - | | Agents reach it at | `http://gw-….localhost` and its port | `https://gw-….eu.midplane.app/mcp`, through Midplane's relay | | Which agents | Those on your computer | Any, claude.ai and ChatGPT included, and those on your computer | | Your computer needs | Nothing more | To connect out on port 443 | > [!WARNING] > > The gateway runs as you, so an agent that runs commands as you, such as > Claude Code with its shell, can read `secrets/*.dsn` and connect to the > database without Midplane. Use your computer to try Midplane; to hold such > an agent to your policy, run the gateway as another user or on another host. **1. Create an enrollment token** In your project, open step 1 of the setup (or **Gateways**, then **Set up a gateway**), and click **Create enrollment token**. The gateway uses it once, to join your project. Keep the page open: the command below it now holds the token. **2. Run setup** Choose **Your computer**, keep **This computer only** or choose **Midplane URL**, and paste the command into a terminal. It runs `midplane setup`, with `--relay` for a Midplane URL, which asks for each database's connection string ([prepare your database](/docs/prepare-database)): paste it and press Enter. What you paste isn't shown, and stays in the gateway's folder: it never reaches Midplane Cloud. **3. Check each database** Setup connects as the gateway will and says what it found, such as `ok: database app on db.internal, 12 tables`. If it fails, it says why: paste a corrected string, or type `keep` to save it anyway. It suggests a name for each database new to the project, such as `shop`, which you can change. **4. Start it** Setup joins your project, writes the gateway's folder where you ran it, such as `gw-k3m9x2qa`, and starts the gateway. The page shows it connected; with a Midplane URL, it shows the URL for agents once the gateway's connections to the relay are up. It keeps running in that terminal; `Ctrl-C` stops it, and `npx -y midplane@latest gateway --config gw-k3m9x2qa/midplane.yaml` starts it again. With **This computer only**, agents on your computer reach the gateway at the `http://gw-….localhost` URL setup prints. Hosted agents such as claude.ai and ChatGPT can't, unless a tunnel or a URL of your own is in front of it ([hosted agents](/docs/gateway/hosted-agents)). Setup takes the first port from 7433 to 7442 that is free when it runs, so a gateway that was stopped then may have its port taken: give the new one another with `--port`. With a **Midplane URL**, agents anywhere reach it at the URL the page shows, such as `https://gw-k3m9x2qa.eu.midplane.app/mcp`, which setup's last line names too. Agents on your computer use it as well: the gateway answers on no other URL. It listens only on loopback and connects out to the relay, which passes agents' encrypted traffic to it without reading it, as [for a server](#docker). * **The URL is on the internet.** Anyone can reach it, and an agent reaches your data only once a member of your project signs it in, as at any gateway's URL. Its certificate, and so its name, is published in Certificate Transparency logs ([watch them](/docs/security#watch-certificate-transparency-logs)). * **Its certificate comes through the relay** as the gateway first starts, and stays in its folder, where the gateway renews it. * **While your computer sleeps** or is offline, agents can't reach the gateway. It reconnects to the relay on its own once your computer wakes, as it does [when the relay is down](/docs/security#when-the-relay-is-down). ## Docker You need a server with Docker that can reach your databases and connect out to the internet on port 443. Agents reach the gateway at a Midplane URL, such as `https://gw-7hq2m4xd.eu.midplane.app/mcp`, through Midplane's relay: no host name, DNS record or open port, and it runs in a private network. The relay passes agents' encrypted traffic to the gateway without reading it, and TLS ends at the gateway, with a certificate it gets from Let's Encrypt and a key that never leaves its volume ([how it works](/docs/how-it-works#through-the-relay), [what the relay sees](/docs/security#the-relay)). | | Midplane URL | [Your own URL](#your-own-url) | | - | - | - | | You need | A server that connects out on port 443 | A DNS name for the server, and ports 80 and 443 open or a certificate of your own | | Between agents and the gateway | Midplane's relay, which passes encrypted bytes it can't read | Nothing of Midplane's | | Copies of one gateway | Up to two | As many as you run behind the URL ([replicas](/docs/gateway/multiple-gateways#replicas)) | | While the relay is down | Agents can't reach the gateway | No effect | Let an AI assistant (Claude, ChatGPT, Cursor) set the server up with you: copy this prompt into it. It asks how you want to work first, never asks for your connection strings or token, and asks before creating anything that costs money. Set up a server gateway behind the relay with your AI assistant Help me run a Midplane gateway on a server, so AI agents can reach my Postgres database through Midplane's relay. First read `https://midplane.ai/docs/gateway/deploy` (the Docker section). Where this prompt and that page disagree, follow the page. Ask me first: 1. Should you run the commands yourself, asking me before each one, or tell me what to run? 2. Where should the server run: a cloud I already use (AWS, Google Cloud, Azure, Hetzner, DigitalOcean…) or a server I have? Which account and region? On AWS, Fly.io, Render or Kubernetes, tell me first that Midplane has a template for it (the page's "On a platform"), and ask whether I'd rather use that. 3. Where is the database, and can that server reach it? Rules: * A server you create takes no inbound traffic: its firewall or security group allows nothing in. Don't open SSH or any other port to get in: use a shell that connects out, such as AWS Systems Manager Session Manager, or the provider's web console. On a server I have, leave its firewall as it is. * The gateway only connects out: to the internet on port 443, and to the database. Leave the server's outbound traffic open unless I ask you to restrict it. * Before creating anything that costs money, tell me the account, region, size and price per hour, and wait for my yes. Tag what you create so it can be found and removed. * Never ask me for a connection string, password or enrollment token in this chat, and don't run anything that prints them, such as `cat midplane.env` or `docker inspect` on the gateway's container. I put them in the env file on the server myself. * In the gateway's container, `localhost` is the container. For a database on the same server, use the address the page gives for that case. Steps: 1. Prepare the server with Docker. From inside a container, as the gateway connects, check that it reaches the database's host and port, which needs no password, and connects out on port 443. 2. I open my project in Midplane Cloud, choose "A server (Docker)" and "Midplane URL", and click "Create enrollment token". 3. I save the env file the page shows as `midplane.env` on the server, fill in each connection string, and run `chmod 600 midplane.env`. 4. The page's one `docker run` command runs in that folder: I paste it, or you run it once I say the file is saved. It holds no secret and publishes no port. 5. Check: the container's logs (`docker logs`) end setup with the `https://gw-….midplane.app/mcp` URL agents reach it at, and the page shows that URL with the relay connected and the gateway enforcing. 6. If the server was a test, offer to remove everything you created, then check that nothing is left. > **Read the prompt** > > Help me run a Midplane gateway on a server, so AI agents can reach my Postgres database through Midplane's relay. First read `https://midplane.ai/docs/gateway/deploy` (the Docker section). Where this prompt and that page disagree, follow the page. > > Ask me first: > > 1. Should you run the commands yourself, asking me before each one, or tell me what to run? > 2. Where should the server run: a cloud I already use (AWS, Google Cloud, Azure, Hetzner, DigitalOcean…) or a server I have? Which account and region? On AWS, Fly.io, Render or Kubernetes, tell me first that Midplane has a template for it (the page's "On a platform"), and ask whether I'd rather use that. > 3. Where is the database, and can that server reach it? > > Rules: > > * A server you create takes no inbound traffic: its firewall or security group allows nothing in. Don't open SSH or any other port to get in: use a shell that connects out, such as AWS Systems Manager Session Manager, or the provider's web console. On a server I have, leave its firewall as it is. > * The gateway only connects out: to the internet on port 443, and to the database. Leave the server's outbound traffic open unless I ask you to restrict it. > * Before creating anything that costs money, tell me the account, region, size and price per hour, and wait for my yes. Tag what you create so it can be found and removed. > * Never ask me for a connection string, password or enrollment token in this chat, and don't run anything that prints them, such as `cat midplane.env` or `docker inspect` on the gateway's container. I put them in the env file on the server myself. > * In the gateway's container, `localhost` is the container. For a database on the same server, use the address the page gives for that case. > > Steps: > > 1. Prepare the server with Docker. From inside a container, as the gateway connects, check that it reaches the database's host and port, which needs no password, and connects out on port 443. > 2. I open my project in Midplane Cloud, choose "A server (Docker)" and "Midplane URL", and click "Create enrollment token". > 3. I save the env file the page shows as `midplane.env` on the server, fill in each connection string, and run `chmod 600 midplane.env`. > 4. The page's one `docker run` command runs in that folder: I paste it, or you run it once I say the file is saved. It holds no secret and publishes no port. > 5. Check: the container's logs (`docker logs`) end setup with the `https://gw-….midplane.app/mcp` URL agents reach it at, and the page shows that URL with the relay connected and the gateway enforcing. > 6. If the server was a test, offer to remove everything you created, then check that nothing is left. **1. Create an enrollment token** In your project, open step 1 of the setup (or **Gateways**, then **Set up a gateway**), choose **A server (Docker)**, keep **Midplane URL** and, under **Runs on**, **A server with Docker**. Click **Create enrollment token**, above the tabs. Keep the page open: the env file and the command it shows now hold the token. **2. Save the env file** On the server, save the file the page shows as `midplane.env` with an editor, and close it to other users (`chmod 600 midplane.env`). It has a line per database of the project, such as `MIDPLANE_DSN_SHOP=`: put each database's connection string after its `=` ([prepare your database](/docs/prepare-database)). The gateway connects from inside its container, where `localhost` is the container: use the database's host as the server's network knows it. If the database runs on the same server, the container reaches the server at Docker's bridge address, by default `172.17.0.1` on Linux (`ip addr show docker0` shows it), so use that as the host. Postgres must listen there and admit the container: set `listen_addresses = '*'`, since Postgres can start before Docker adds that address, keep port 5432 closed to the outside in the server's firewall, and add a `pg_hba.conf` line for the bridge's network, by default `172.17.0.0/16`. A Postgres in another container publishes its port there: `-p 172.17.0.1:5432:5432`. On Docker Desktop, use `host.docker.internal`. **3. Run the gateway** In the folder that holds the file, run the command. It starts the gateway, detached and again whenever the server restarts, and publishes no port: nothing connects in. At its first start it asks Midplane Cloud for the relay, tests each connection string, joins your project and writes its config to the Docker volume the command names, such as `midplane-gw-k3m9x2qa`. Then it connects out to the relay and gets its certificate through it. `docker logs -f midplane-gw-…` shows what it says; setup's last line is the URL agents reach it at. The gateway picks the name in that URL itself, so it isn't the container's. **4. Check it on the page** Once the gateway connects, the page shows its URL for agents, and the setup step says **Relay · connected**. If it says why the relay refused it, see [cannot reach the relay](/docs/troubleshooting#cannot-reach-the-relay). * **A variable per database.** `MIDPLANE_DSN_SHOP` is the database `shop`: the name after `MIDPLANE_DSN_`, lowercased. A database not in the project yet is added, with nothing readable until you publish its policy. * **Each connection string is tested** as setup tests it: `docker logs` says what it found, such as `ok: database shop on db.internal, 12 tables`, or why it failed, but never the string. One that fails is kept, and the gateway reports it unreachable until it answers. * **The connection strings stay in the env file.** The config on the volume names each one's variable (`dsn: { env: MIDPLANE_DSN_SHOP }`), so keep the file: the gateway reads them each time it starts, and never sends them to Midplane Cloud. * **The rest is read once.** Restarted, the container serves what the volume holds, without joining the project again, and no longer needs the token. * **If setup fails** before the gateway joins the project, the log's last line says why ([troubleshooting](/docs/troubleshooting#the-gateway-does-not-connect)). Setup removed what it wrote and left the token unused, but the container keeps restarting and trying, about once a minute: Docker reads the env file only when it creates the container. Remove it (`docker rm -f midplane-gw-…`), fix the file, and run the command again. * **Keep the volume.** The gateway's certificate is on the volume: a gateway that loses it orders a new one as it starts, and Let's Encrypt issues at most 5 for one name a week, to all its copies together. Behind the relay, run at most two copies of one gateway, each with a volume of its own ([replicas](/docs/gateway/multiple-gateways#replicas)); for more, use [your own URL](#your-own-url). * **If the relay is down,** the gateway keeps enforcing your policies, but agents can't reach it until the relay is back. It reconnects on its own ([when the relay is down](/docs/security#when-the-relay-is-down)). The command names the newest release's image by its version, such as `ghcr.io/midplaneai/midplane:0.26.0`, which you can [verify](/docs/releases); the server keeps it until you [upgrade](/docs/gateway/upgrading#docker) the gateway. Every variable is in [configuration](/docs/gateway/configuration#setup-from-environment-variables). ### Your own URL You need a server with Docker and a URL for the gateway, such as `https://midplane.example.com`. The gateway serves HTTPS itself whenever it's reachable from other machines, with a certificate it gets from Let's Encrypt and renews, or with your own. Nothing of Midplane's is between agents and the gateway. **1. Point the URL at the server** Point the URL's DNS at the server: an `A` record, and `AAAA` if it has IPv6. Then, for the certificate: **Automatic certificate** Open ports 80 and 443 to the internet, or the port agents will use instead of 443. Let's Encrypt checks port 80 before it issues the certificate, and again each time the gateway renews it: there it reaches a listener that answers its challenge and nothing else. Getting the certificate agrees to Let's Encrypt's [subscriber agreement](https://letsencrypt.org/repository/). **My own certificate** Open port 443 to the agents that will use the gateway, or the port they use instead. On the server, save the certificate and its key as `/etc/midplane/tls/tls.crt` and `/etc/midplane/tls/tls.key`. The image runs as uid 1000, which must be able to read both, such as after `sudo chown 1000 /etc/midplane/tls/tls.key`. Setup checks that the certificate is for the URL, current, and matches its key. The gateway reads them again every 10 minutes, so a renewed certificate is served without a restart. Use your own for an IP address, which Let's Encrypt doesn't certify, or a server port 80 can't reach. **2. Create an enrollment token** In your project, open step 1 of the setup (or **Gateways**, then **Set up a gateway**), and click **Create enrollment token**. **3. Enter the gateway's URL** Choose **A server (Docker)**, then **Your own URL** if the page offers a **Midplane URL**, and enter the gateway's URL: `https://` and the name you pointed at the server, such as `https://midplane.example.com`. Agents reach the gateway at it. Under **Certificate**, keep **Automatic (Let's Encrypt)** or choose **My own**. With an automatic certificate, the page lists what must be true before you run the commands, and looks up where the name points now, such as `midplane.example.com points at 203.0.113.7`. If it says the name doesn't resolve yet, wait a few minutes for the record and press **Check again**. The page then shows two commands, with the token in them. **4. Run setup in the image** Run the first command on the server. Setup asks for each connection string and tests it from inside the container, where `localhost` is the container: use the database's host as the server's network knows it. With an automatic certificate it then gets one for the URL, saying `ok: valid until` and its date; if Let's Encrypt can't reach the server, it says why ([troubleshooting](/docs/troubleshooting#the-gateway-does-not-connect)), removes what it wrote and leaves the token unused. It writes the config, the connection strings, the certificate and the gateway's identity to a Docker volume named after the gateway, such as `midplane-gw-k3m9x2qa`, and joins your project. **5. Start it** Run the second command. It starts the gateway from that volume, and again whenever the server restarts. The page shows it connected; `docker logs -f midplane-gw-…` shows what it says. An automatic certificate is renewed in its last 30 days, without a restart. Both commands name the newest release's image by its version, which you can [verify](/docs/releases); the server keeps it until you [upgrade](/docs/gateway/upgrading#at-your-own-url) the gateway. The volume holds the gateway's config, connection strings, identity, certificate and audit log. To replace the gateway with a new one, revoke it on the **Gateways** page (its URL is taken until then), run `docker rm -f midplane-gw-… && docker volume rm midplane-gw-…`, which deletes its audit log, and set the new one up with a new token. ## On a platform Behind the relay the gateway needs no port, domain or certificate of yours, so it runs anywhere its image keeps a disk and connects out. Each template reads the same env file as the Docker steps, and the page shows its steps under **Runs on**. > **[AWS](/docs/gateway/aws)** > > A CloudFormation stack in your VPC, next to RDS or Aurora: one EC2 > instance with nothing inbound. > **[Fly.io](/docs/gateway/fly)** > > One machine with a volume, and no IP address. > **[Render](/docs/gateway/render)** > > A background worker with a disk, from a Blueprint. > **[Kubernetes](/docs/gateway/kubernetes)** > > A Helm chart: one pod with its own disk, and no Service. ## Other setups > **Your own URL, with one command** > > With your own URL, the image can also set itself up from > environment variables when it first starts, then serve: one `docker > run`, or a platform's secrets, with no terminal. Point the URL at the > server and open ports 80 and 443, as for an [automatic > certificate](#your-own-url), and create an enrollment token. Then, on the > server, write an env file such as `midplane.env` with an editor, and > close it to other users (`chmod 600 midplane.env`). Its `MIDPLANE_URL` > is what makes it your own URL; without it, the gateway is set up behind > the relay, as [above](#docker): > > ```sh midplane.env theme={null} > MIDPLANE_CLOUD_URL=https://eu.app.midplane.ai > MIDPLANE_ENROLLMENT_TOKEN=mpe1_… > MIDPLANE_URL=https://midplane.example.com > MIDPLANE_DSN_SHOP=postgres://midplane_agent:…@db.internal:5432/shop > ``` > > Start the gateway with it: > > ```sh theme={null} > docker run -d --name midplane --restart unless-stopped \ > --env-file midplane.env -v midplane:/var/lib/midplane \ > -p 443:7433 -p 80:7480 ghcr.io/midplaneai/midplane: > ``` > > With `` the newest release's, such as `0.26.0` > ([upgrading](/docs/gateway/upgrading#docker)). > > The variables per database, the connection tests, what stays in the > file and what happens when setup fails are as [above](#docker), with > `midplane` for the container's name. Restarted, the container doesn't > get a certificate again either. > > * **The certificate comes after joining**, as the gateway starts, so a > token the project refuses gets none. If Let's Encrypt refuses, the > log's last line says why ([troubleshooting](/docs/troubleshooting#the-gateway-does-not-connect)): > fix the DNS or the ports, and the gateway tries again each time it > restarts, with no new token. Let's Encrypt allows five failed tries an > hour for a name. > > A config mounted at `/etc/midplane/midplane.yaml` is served instead, as > before. Every variable is in > [configuration](/docs/gateway/configuration#setup-from-environment-variables). > **Behind your TLS proxy** > > If a proxy or load balancer on the same host already serves TLS for the > URL, keep the gateway on loopback and have the proxy forward the URL to > it, keeping the `Host` header. > > * **With `npx`:** follow [your computer](#your-computer). Then add the URL > to `public_urls` in the gateway's `midplane.yaml`, such as > `public_urls: [http://gw-k3m9x2qa.localhost:7433, > https://midplane.example.com]`, and restart it. A URL added after the > gateway joined waits on the project's **Gateways** page until an owner > or admin presses **Register**. Point the proxy at > `http://127.0.0.1:7433`, or the port setup chose. > * **With Docker:** [write the config yourself](#write-the-config-yourself), > with `listen: { host: 127.0.0.1, port: 7433 }`, your URL in > `public_urls` and no `tls`, and run the container with `--network host` > (Linux only). The gateway registers the URLs in its config when it > first starts. > **A platform without a disk** > > At your own URL only: behind the relay, the gateway keeps its certificate > on its disk, and without one it would order a new one at every start. > > Some platforms keep no files across restarts, so the gateway can't keep the > identity it gets when it joins. Enroll it once and store the identity as a > secret instead: > > 1. [Write the config yourself](#write-the-config-yourself), with > `link.identity: { env: MIDPLANE_IDENTITY }`. > 2. Run `npx -y midplane@latest enroll --config midplane.yaml` with the > token in place. It prints the identity as one line of JSON. It reads the > whole config, so every file and variable it names must exist there. > 3. Store that line in the platform's secrets as `MIDPLANE_IDENTITY`, and > delete any copy: it holds the gateway's private key. > > Most platforms end TLS at their edge and forward plain HTTP, which the > gateway refuses: run a TLS proxy beside it, as in > [hosted agents](/docs/gateway/hosted-agents). Mount a volume for the audit log > if you want to keep it. > **Write the config yourself** > > For what setup doesn't cover, such as a proxy in front, a secret manager > or Kubernetes, write the config by hand. The gateway joins the project > the first time it starts, with the token from the file the config names. > > 1. Make the gateway's folder, with a `secrets/` folder closed to other > users: `mkdir -p gw/secrets && cd gw && chmod 700 secrets`. > > 2. Make the mask salt: `openssl rand -hex 32 > secrets/mask-salt`. > > 3. With an editor, write each connection string to > `secrets/.dsn`, and the token to > `secrets/enrollment-token`. The token is the `--token` value in the > command the page shows after **Create enrollment token**. > > 4. Save the config as `midplane.yaml`, with a line per database: > > ```yaml theme={null} > listen: { port: 7433 } > public_urls: [http://gw-k3m9x2qa.localhost:7433] > audit: { file: audit.db } > mask_salt: { file: secrets/mask-salt } > link: > cloud_url: https://eu.app.midplane.ai > identity: { file: identity.json } > enrollment_token: { file: secrets/enrollment-token } > databases: > shop: { dsn: { file: secrets/shop.dsn } } > ``` > > 5. Start it: `npx -y midplane@latest gateway --config midplane.yaml`. > > A database the config names that the project lacks shows on **Gateways** > as `in its config, not in this project`, with **Add** beside it. In > Docker, mount the folder read-only at `/etc/midplane`, where the image > reads `midplane.yaml`, and let uid 1000 read the secrets (`sudo chown -R > 1000:1000 secrets`). Put what the gateway writes (`audit.file`, > `link.identity` and `link.bundle_cache`) under `/var/lib/midplane/`, on a > volume. With `tls.acme`, the gateway keeps its certificate in `acme/` > beside the config, so mount the folder writable instead. Every key is in > [configuration](/docs/gateway/configuration). > **What the gateway's folder holds** > > ```text theme={null} > gw-k3m9x2qa/ > midplane.yaml the config, with no secrets in it > secrets/ closed to other users > shop.dsn a connection string per database > mask-salt the key for hashing masked values > identity.json the gateway's key, written when it joins > audit.db the audit log > bundle.jws the newest policy, for a restart with the cloud down > acme/ with an automatic certificate, closed to other users > cert.pem the certificate, renewed in its last 30 days > key.pem its key > account.key the Let's Encrypt account's key > ``` > > In Docker, the same files are in the gateway's volume, such as > `midplane-gw-k3m9x2qa`. Keep the folder or the volume: a new one is a new > gateway, with a new mask salt. Every setting is in [configuration](/docs/gateway/configuration). If the gateway doesn't connect, see [troubleshooting](/docs/troubleshooting). *Tested by* `apps/gateway/scripts/package-smoke.ts` (setup with the packed npm package; setup then the gateway in the image on one volume, with your own certificate and with an automatic one from Pebble, Let's Encrypt's test CA; one `docker run --env-file` that sets itself up against Pebble, then restarts, in CI's `package` job), and `apps/gateway/test/setup-relay.test.ts` (setup behind a stand-in relay, from flags and from the environment, then the gateway serving through it). # Run the gateway on Fly.io Source: https://midplane.ai/docs/gateway/fly Run the gateway on one Fly.io machine with a volume, behind Midplane's relay: no IP address or service, its connection strings in Fly's secrets. The gateway runs on one Fly machine with a volume, behind Midplane's relay: agents reach it at a Midplane URL, and the app has no IP address or service, so nothing connects in. It suits a database Fly can reach over the internet, such as [Supabase](/docs/prepare-database/supabase) or [Neon](/docs/prepare-database/neon). You need [flyctl](https://fly.io/docs/flyctl/install/), signed in. ## Set it up **1. Create an enrollment token** In your project, open step 1 of the setup (or **Gateways**, then **Set up a gateway**), choose **A server (Docker)**, keep **Midplane URL**, and under **Runs on** choose **Fly.io**. Click **Create enrollment token**, above the tabs, and keep the page open. **2. Save the env file** On your computer, save the file the page shows as `midplane.env` with an editor, with each database's connection string after its `=` ([prepare your database](/docs/prepare-database)). **3. Run the commands** In that folder, run the page's commands, one block at a time: 1. `fly apps create` makes the app, named after the gateway. It may ask which organization. 2. `fly secrets import` stores the env file as the app's secrets, then the file is removed. 3. `fly volumes create` and `fly machine run` make a 1 GB volume and one machine with 512 MB on it, restarted whenever it stops. Both commands name the region `fra`, beside the relay: use your database's region instead in both. **4. Check it on the page** The gateway sets itself up from the secrets, joins your project, and gets its certificate through the relay. The page then shows its URL for agents. `fly logs -a midplane-gw-…` shows what it says. ## Run it * **A connection string changes.** Import it from a file, as setup did, such as `fly secrets import -a midplane-gw-… < shop.env` with one line: Fly restarts the machine with it. * **A database is added later.** Import its `MIDPLANE_DSN_` line the same way: alone, it is ignored once the gateway has set itself up. Then append the database's line to `/var/lib/midplane/midplane.yaml`, in a shell from `fly ssh console -a midplane-gw-…`, and restart the machine: `fly machine restart -a midplane-gw-…` ([adding databases](/docs/gateway/multiple-gateways#adding-databases)). * **Upgrade.** `fly machine update` with the new version's image ([upgrading](/docs/gateway/upgrading#fly-io)). The volume, and with it the gateway, stays. * **The volume is on one host.** Fly doesn't copy it: if that host fails, the gateway's identity is gone with it, and a new gateway needs a new token. Fly snapshots volumes daily. * **Remove it.** Revoke the gateway on your project's **Gateways** page, then `fly apps destroy midplane-gw-…`, which deletes the volume with the audit log. `fly machine run` names the newest release's image by its version, which you can [verify](/docs/releases). # Reaching a gateway from hosted agents Source: https://midplane.ai/docs/gateway/hosted-agents claude.ai, ChatGPT and other hosted agents reach the gateway at a Midplane URL through the relay, through a vendor tunnel, or at your own endpoint. Agents on laptops or in your own cluster (Claude Code, Cursor, VS Code, your own agents) reach the gateway wherever it answers: at its Midplane URL behind the relay, at your own URL, or on your computer. Hosted agents run on their vendor's servers: claude.ai connectors, ChatGPT and background agents. They can reach an MCP server only at a public HTTPS URL, and your gateway sits in your network. The simplest way is a Midplane URL: a gateway [behind the relay](/docs/gateway/deploy#docker), on a server or a platform or [on your computer](/docs/gateway/deploy#your-computer), is reachable at a public HTTPS URL while it only connects out, even in a private network. The relay passes agents' encrypted traffic to the gateway without reading it, and TLS ends at the gateway ([what the relay sees](/docs/security#the-relay)). Add that URL to [claude.ai](/docs/agents/claude-ai) or [ChatGPT](/docs/agents/chatgpt) as you would any other: nothing on this page is needed. Otherwise, make the gateway reachable with what you already run, or with the tunnel your agent vendor offers, and register each URL the gateway answers on, as below. Either way, query results never reach Midplane Cloud. ## One gateway, several URLs A gateway can answer on several URLs at once: its direct URL, plus any tunnel or ingress URL in front of it. A gateway behind the relay can't: it answers only on its Midplane URL ([configuration](/docs/gateway/configuration#behind-the-relay)). List them all in its config: ```yaml theme={null} # midplane.yaml listen: { host: 127.0.0.1, port: 7433 } public_urls: - https://midplane.abc123.tunnel.anthropic.com # a tunnel for claude.ai - https://db-gateway.internal.example.com # laptops, through your ingress ``` * **Tokens.** Each URL is a token audience. The gateway accepts a token that names any of its URLs, and refuses a token that names none of them. Policy is the same on every URL: it keys on the agent, not on the URL it came in on. * **Metadata.** The gateway's protected resource metadata names the URL a client came in on, so each client asks for a token for the URL it uses. * **Host names.** The gateway answers only requests for its own host names, which protects against DNS rebinding. A request for any other name gets a 403\. A gateway listening on loopback also answers to `localhost`, `127.0.0.1` and `[::1]`. If a proxy in front of it rewrites the Host header to another name, add that name to `listen.allowed_hosts`. * **Registration.** Only registered URLs are audiences, and only an owner or admin registers one. The URLs in the config when a gateway enrolls are registered then, under the enrollment token. A linked gateway reports its `public_urls` on every sync; one you add to the config later waits on the project's Gateways page until an owner or admin registers it there, since whoever can edit the config or holds the gateway's identity shouldn't be able to add audiences on their own. A Midplane URL is registered only as its gateway joins: **Add URL** refuses one. You can also register a URL that isn't in the config. A URL you remove from the config is dropped within ten minutes, or at once on the Gateways page. A URL another live gateway holds can't be registered: the Gateways page and the gateway's log say so (`public URL not registered`). * **Revoking.** Revoking a gateway frees its URLs for another gateway, except a Midplane URL: no other gateway ever gets one, so agents given it never reach another gateway by it. Stop the revoked gateway's process too: it keeps its last bundle, and so keeps accepting tokens that name its URLs. Behind the relay, the relay closes its connections within 10 seconds, so agents can't reach it there anyway. * **TLS.** The gateway requires TLS whenever it listens off loopback. Run a tunnel next to it over loopback (in one Kubernetes pod, or one network namespace in Compose), or give the gateway a certificate (`tls`). A proxy that rewrites Host hides which URL a client used. The gateway's metadata then names its first URL, so list the tunnel's URL first in `public_urls` when it is the only way in that rewrites Host. ## Which option fits | Where the agent runs | What you do | Who can read traffic | | - | - | - | | Laptops or your own cluster (Claude Code, Cursor, VS Code) | Nothing extra: add the gateway's URL | Only you; behind the relay, it passes ciphertext | | claude.ai, ChatGPT or another hosted agent, with a gateway behind the relay | Nothing extra: add its Midplane URL ([claude.ai](/docs/agents/claude-ai), [ChatGPT](/docs/agents/chatgpt)) | Only you: the relay passes ciphertext | | claude.ai or ChatGPT, with the vendor's tunnel | Run the vendor's tunnel next to the gateway, and register the tunnel URL | The agent vendor, which is the client anyway | | Hosted agents without a vendor tunnel | Give the gateway a public URL: your load balancer, allowlisted to the vendor's IP ranges, or a public deployment in your own cloud account | Only you | > [!WARNING] > > **Tunnels that decrypt can read your data.** Cloudflare Tunnel terminates > TLS at Cloudflare's edge, and ngrok does too unless you turn on its > end-to-end TLS. Either provider can then read queries, results and tokens. > Use them only if you accept that. In every option, the hosted agent's vendor sees query results, because it is the client. Midplane never does. ## Claude: Anthropic MCP tunnels Anthropic's MCP tunnels are in research preview. For claude.ai they need the Enterprise plan and a request to Anthropic; tunnels for the Claude Console and API are separate. Set one up with [Anthropic's guide][anthropic-setup] first. * **Parts.** You run `cloudflared` and Anthropic's `mcp-proxy`. `cloudflared` holds an outbound connection to Cloudflare. Traffic inside it is encrypted again from Anthropic to `mcp-proxy` with a certificate from your own CA, so Cloudflare carries ciphertext. * **URL.** A route named `midplane` gets `https://midplane.`. Register that URL; the connector URL in claude.ai is `https://midplane./mcp`. * **Loopback.** By default `mcp-proxy` connects only to RFC 1918 addresses. To reach the gateway over loopback, set `upstream.allowed_ips` to `127.0.0.1/32`. That replaces the default list, so the proxy reaches nothing off this network namespace, though anything else listening on its loopback (such as `cloudflared`'s metrics) is in reach too. Routes decide which host names go where. * **Host header.** Anthropic doesn't document which Host `mcp-proxy` sends upstream. If it sends `127.0.0.1:7433`, a loopback gateway answers to it, and its metadata names its first URL: list the tunnel URL first. * **Names.** Anthropic's proxy runs as `mcp-proxy`, reads `/etc/mcp-gateway/config.yaml`, and its Helm values call it `gateway`. Name Midplane's service `midplane-gateway`, never `mcp-proxy` or `mcp-gateway`. ### Docker Compose The gateway shares `mcp-proxy`'s network namespace, as `cloudflared` does, so the proxy reaches it on `127.0.0.1`. Take the image digests and the certificate setup from [Anthropic's Compose guide][anthropic-compose]. ```yaml theme={null} # compose.yaml services: mcp-proxy: image: us-docker.pkg.dev/anthropic-public-registry/images/mcp-proxy@sha256: volumes: - ./config/mcp-proxy.yaml:/etc/mcp-gateway/config.yaml:ro - ./data:/data:ro cloudflared: image: cloudflare/cloudflared@sha256: command: tunnel --no-autoupdate run --url http://localhost:8080 environment: [TUNNEL_TOKEN] network_mode: "service:mcp-proxy" midplane-gateway: image: ${MIDPLANE_IMAGE} # the gateway image command: gateway --config /etc/midplane/midplane.yaml volumes: - ./midplane:/etc/midplane # the config, its secrets/ and what it writes network_mode: "service:mcp-proxy" ``` ```yaml theme={null} # config/mcp-proxy.yaml listen_addr: ":8080" tunnel_domain: ${TUNNEL_DOMAIN} tls: cert_file: /data/tls.crt key_file: /data/tls.key routes: midplane: http://127.0.0.1:7433 upstream: allowed_ips: ["127.0.0.1/32"] ``` ```yaml theme={null} # midplane/midplane.yaml listen: { host: 127.0.0.1, port: 7433 } public_urls: - https://midplane.abc123.tunnel.anthropic.com audit: { file: /etc/midplane/audit.db } mask_salt: { file: /etc/midplane/secrets/mask-salt } link: cloud_url: https://cloud.midplane.example identity: { file: /etc/midplane/identity.json } enrollment_token: { file: /etc/midplane/secrets/enrollment-token } databases: main: { dsn: { file: /etc/midplane/secrets/main.dsn } } ``` Write the secrets with an editor, as in [write the config yourself](/docs/gateway/deploy#write-the-config-yourself). The gateway runs as uid 1000 and writes its identity, audit log and bundle cache in the folder too, so give it the folder: `sudo chown -R 1000:1000 midplane`. ### Kubernetes Anthropic's Helm chart (`mcp-tunnel`) runs `cloudflared` and `mcp-proxy` in a pod of its own and takes no extra containers, so the gateway can't join it on loopback. The hop from the proxy to the gateway then crosses the cluster network, where the gateway requires TLS: 1. Give the gateway a certificate from your internal CA (`tls` in its config), listen on `0.0.0.0`, and put a Service named `midplane-gateway` in front of it. Add a NetworkPolicy that lets only the tunnel's pods reach port 7433. The proxy may send the Service's name as Host, so add it to `listen.allowed_hosts` (not to `public_urls`: it isn't a URL agents use). 2. Route the chart to that Service over `https`, and have the proxy trust your CA with `upstream.tls.ca_file` (see [Anthropic's Helm guide][anthropic-helm] for mounting it): ```yaml theme={null} # values for Anthropic's mcp-tunnel chart gateway: config: routes: midplane: https://midplane-gateway.midplane.svc.cluster.local:7433 upstream: tls: ca_file: /etc/midplane-ca/ca.crt ``` ```yaml theme={null} # midplane.yaml, the parts that differ from the Compose example listen: host: 0.0.0.0 port: 7433 allowed_hosts: [midplane-gateway.midplane.svc.cluster.local] tls: { cert_file: /etc/midplane-tls/tls.crt, key_file: /etc/midplane-tls/tls.key } public_urls: - https://midplane.abc123.tunnel.anthropic.com ``` Pod addresses are usually RFC 1918, which the proxy allows by default. ## ChatGPT and Codex: OpenAI Secure MCP Tunnel OpenAI's Secure MCP Tunnel serves ChatGPT developer mode, Codex and the Responses API, not publicly listed apps. You run OpenAI's open-source [`tunnel-client`][openai-tunnel-client], which long-polls OpenAI over outbound HTTPS and forwards each request to the gateway. TLS ends at OpenAI, which is the client anyway. * **Setup.** Create a tunnel as [OpenAI's guide][openai-tunnels] describes, then point `tunnel-client` at the gateway: `MCP_SERVER_URL=http://127.0.0.1:7433/mcp`. Your authorization server is Midplane Cloud, on another origin than the gateway, so add its origin to `MCP_OAUTH_TRUSTED_ORIGINS`. Its docs ask for `--harpoon.allow-plaintext-http` to discover OAuth metadata over plain loopback HTTP. * **Host header.** `tunnel-client` sends the upstream's own address as Host, `127.0.0.1:7433` here. A gateway listening on loopback answers to it. * **Resource URL: not verified yet.** `tunnel-client`'s docs say it rewrites the `resource` in the gateway's metadata to an OpenAI URL for your tunnel, so ChatGPT would ask Midplane Cloud for a token for that URL, which must be one of the gateway's URLs. OpenAI doesn't document its form, and Midplane registers only URLs whose path ends in `/mcp`. Until a real run settles this, ChatGPT through OpenAI's tunnel may not finish signing in. ### Docker Compose `tunnel-client` shares the gateway's network namespace: ```yaml theme={null} # compose.yaml services: midplane-gateway: image: ${MIDPLANE_IMAGE} # the gateway image command: gateway --config /etc/midplane/midplane.yaml volumes: - ./midplane:/etc/midplane # as in the Claude example, secrets in files tunnel-client: image: ghcr.io/openai/tunnel-client: environment: CONTROL_PLANE_API_KEY: ${CONTROL_PLANE_API_KEY} CONTROL_PLANE_TUNNEL_ID: tunnel_0123456789abcdef0123456789abcdef MCP_SERVER_URL: http://127.0.0.1:7433/mcp MCP_OAUTH_TRUSTED_ORIGINS: https://cloud.midplane.example network_mode: "service:midplane-gateway" ``` ### Kubernetes The gateway and `tunnel-client` run in one pod and talk over loopback, the pattern OpenAI documents. Midplane's [Helm chart](/docs/gateway/kubernetes) runs the gateway alone and takes no extra container, so deploy this pod instead. ```yaml theme={null} apiVersion: apps/v1 kind: Deployment metadata: name: midplane-gateway spec: replicas: 1 selector: matchLabels: { app: midplane-gateway } template: metadata: labels: { app: midplane-gateway } spec: containers: - name: midplane-gateway image: MIDPLANE_IMAGE # the gateway image args: [gateway, --config, /etc/midplane/midplane.yaml] env: - name: MIDPLANE_IDENTITY # what `midplane enroll` printed valueFrom: { secretKeyRef: { name: midplane, key: identity } } - name: MIDPLANE_MASK_SALT valueFrom: { secretKeyRef: { name: midplane, key: mask-salt } } - name: MAIN_DSN valueFrom: { secretKeyRef: { name: midplane, key: main-dsn } } volumeMounts: - { name: config, mountPath: /etc/midplane } - { name: state, mountPath: /var/lib/midplane } - name: tunnel-client image: ghcr.io/openai/tunnel-client: env: - name: CONTROL_PLANE_TUNNEL_ID value: tunnel_0123456789abcdef0123456789abcdef - name: CONTROL_PLANE_API_KEY valueFrom: { secretKeyRef: { name: openai-tunnel, key: api-key } } - name: MCP_SERVER_URL value: http://127.0.0.1:7433/mcp - name: MCP_OAUTH_TRUSTED_ORIGINS value: https://cloud.midplane.example volumes: - name: config configMap: { name: midplane-gateway } - name: state # use a persistent volume to keep the audit log emptyDir: {} ``` ```yaml theme={null} # midplane.yaml, in the midplane-gateway ConfigMap listen: { host: 127.0.0.1, port: 7433 } public_urls: - https://… # the URL OpenAI names for your tunnel; see "Resource URL" audit: { file: /var/lib/midplane/audit.db } mask_salt: { env: MIDPLANE_MASK_SALT } link: cloud_url: https://cloud.midplane.example identity: { env: MIDPLANE_IDENTITY } bundle_cache: /var/lib/midplane/bundle.jws databases: main: { dsn: { env: MAIN_DSN } } ``` The ConfigMap is read-only and a pod has no identity file, so enroll once with `midplane enroll` and keep the identity it prints in the secret. ## Your own endpoint When a hosted agent has no vendor tunnel, give the gateway a public URL: * **Your load balancer or ingress**, in front of the gateway. Allow only the vendor's egress ranges: Anthropic publishes [its IP addresses][anthropic-ips], OpenAI its [ChatGPT connector ranges][openai-ips]. TLS ends at your load balancer, or passes through to the gateway's own certificate. * **A public deployment in your own cloud account**, with `tls` and the gateway listening on `0.0.0.0`. Add the URL to `public_urls`. If the load balancer rewrites Host, add that name to `listen.allowed_hosts`. [anthropic-setup]: https://claude.com/docs/connectors/mcp-tunnels/setup [anthropic-compose]: https://platform.claude.com/docs/en/agents-and-tools/mcp-tunnels/deploy-compose [anthropic-helm]: https://platform.claude.com/docs/en/agents-and-tools/mcp-tunnels/deploy-helm [anthropic-ips]: https://platform.claude.com/docs/en/api/ip-addresses [openai-tunnels]: https://developers.openai.com/api/docs/guides/secure-mcp-tunnels [openai-tunnel-client]: https://github.com/openai/tunnel-client [openai-ips]: https://developers.openai.com/api/docs/guides/ip-addresses # Run the gateway on Kubernetes with Helm Source: https://midplane.ai/docs/gateway/kubernetes Install the gateway with Midplane's Helm chart, behind Midplane's relay: one pod with its own disk, its env file in a Secret, and no Service or Ingress. The chart runs the gateway as one pod with its own disk, behind Midplane's relay: agents reach it at a Midplane URL, and the chart makes no Service or Ingress, so nothing connects in. Run it in a cluster that reaches your databases. You need `kubectl` and `helm` for the cluster, and a default storage class, or one you name. ## Set it up **1. Create an enrollment token** In your project, open step 1 of the setup (or **Gateways**, then **Set up a gateway**), choose **A server (Docker)**, keep **Midplane URL**, and under **Runs on** choose **Kubernetes (Helm)**. Click **Create enrollment token**, above the tabs, and keep the page open. **2. Save the env file** On your computer, save the file the page shows as `midplane.env` with an editor, with each database's connection string after its `=` ([prepare your database](/docs/prepare-database)). Name each database's host as the cluster's pods reach it. **3. Run the commands** In that folder, run the page's two commands. The first makes a Secret from the file, then removes the file; the second installs the chart with that Secret's name. **4. Check it on the page** The gateway sets itself up from the Secret, joins your project, and gets its certificate through the relay. The page then shows its URL for agents. `kubectl logs -f midplane-gw-…-0` shows what it says. ## What the chart makes * **A StatefulSet of one.** A replacement pod starts only once the old one is gone, so two never share the disk. Run one release per gateway. * **A 1 Gi disk** (`persistence.size`, `persistence.storageClass`), owned by the image's user, uid 1000. It holds the gateway's identity, mask salt, certificate and audit log. `helm uninstall` keeps it. * **A locked-down pod.** It runs as uid 1000 with a read-only root filesystem, no capabilities, and no service account token. * **No Service or Ingress.** The gateway only connects out: to Midplane Cloud and its relay on port 443, and to your databases. To mount files the connection strings name, such as a CA bundle for `sslrootcert`, set `tlsSecret` to a Secret: its keys become files in `/etc/midplane/tls`. Every value is in the chart's [`values.yaml`](https://github.com/midplaneai/midplane/blob/main/deploy/helm/midplane-gateway/values.yaml). ## Run it * **A connection string changes.** Change it in the Secret, then restart the pod: `kubectl rollout restart statefulset midplane-gw-…`. * **A database is added later.** Add its `MIDPLANE_DSN_` to the Secret: alone, it is ignored once the gateway has set itself up. Then append the database's line to `/var/lib/midplane/midplane.yaml`, in a shell from `kubectl exec -it midplane-gw-…-0 -- sh`, and restart the pod as above ([adding databases](/docs/gateway/multiple-gateways#adding-databases)). * **Upgrade.** `helm upgrade` to the new version's chart ([upgrading](/docs/gateway/upgrading#kubernetes)). The chart's version is the gateway's. * **Remove it.** Revoke the gateway on your project's **Gateways** page, then `helm uninstall midplane-gw-…`, and delete its disk, `kubectl delete pvc data-midplane-gw-…-0`, which holds the audit log. *Tested by* `apps/gateway/test/deploy-templates.test.ts` (the chart's version, one pod, its disk, no Service or Ingress). # Linked mode Source: https://midplane.ai/docs/gateway/linked-mode Enrollment, bundles, databases a gateway can't reach, what goes up, replicas and revoking. `midplane gateway` enforces what a project in Midplane Cloud publishes: policies authored in the dashboard, agents' identities, approvals, taint shared by every instance, and a query log. The gateway opens every connection; the cloud never connects to it, and it serves nothing for the cloud to call. Behind the relay, the gateway's connections to the relay are its own too, opened with the same link token ([behind the relay](/docs/gateway/configuration#behind-the-relay)). ```yaml theme={null} # midplane.yaml listen: { host: 127.0.0.1, port: 7433 } public_urls: [https://db-gateway.internal.example.com] audit: { file: audit.db } mask_salt: { env: MIDPLANE_MASK_SALT } link: cloud_url: https://midplane.example.com # as the project's Gateways page shows it identity: { file: identity.json } enrollment_token: { env: MIDPLANE_ENROLLMENT_TOKEN } name: prod-eu-1 databases: main: { dsn: { env: DSN_MAIN } } ``` The database ids (`main`) are the ones the project's databases use in the dashboard. ## Enrollment An owner or admin makes an enrollment token on the project's Gateways page. It works once, within 24 hours, and pins Midplane Cloud's signing key: the gateway refuses an answer signed with any other key, so a TLS-inspecting proxy can't stand in for the cloud. Behind the relay, setup first asks Midplane Cloud where the relay is, and (`0.26.1+`) believes only an answer signed with that key for that setup, so such a proxy can't send the gateway's connections to another relay either. * **On a disk:** with `link.identity: { file: identity.json }`, the first start enrolls with the token and writes the identity (mode 0600). Later starts read it and never need the token again. * **Without a disk** (a platform that rebuilds containers): run `midplane enroll --config midplane.yaml` once. It prints the identity as one line of JSON; store it in your secret manager and point `link.identity: { env: MIDPLANE_IDENTITY }` at it. `--out ` writes it to a file instead. The identity holds the gateway's private key: treat it like a password ([secrets](/docs/gateway/secrets)). Enrolling registers every URL in `public_urls` (or the listener's) as one of the gateway's URLs ([hosted agents](/docs/gateway/hosted-agents)). Behind the relay, that is its Midplane URL, which only enrolling gives a gateway. ## Bundles A bundle is a project's complete policy, signed by Midplane Cloud when someone publishes. The gateway takes one only if its signature, issuer, project and version check out, and only if it is newer than the one it holds. * **Enforcing:** the newest bundle is written to `link.bundle_cache` before it takes effect. After a restart with the cloud down, the gateway enforces it again. * **Waiting:** a gateway that has never received a bundle serves nothing (503). * **Halted:** an authentic bundle the gateway can't fully enforce (a newer format, a policy feature it doesn't know, masks without `mask_salt`) halts it: every call is refused, and the dashboard and the log say why. Upgrade the gateway, or fix its config. * **Paused:** a paused project's gateways refuse every call until it's resumed. A published change reaches a running gateway within a second or two: it waits on a long poll of up to 50 seconds that the cloud answers as soon as there is news. ## Databases it cannot reach From `0.22.0`, a database the gateway can't reach at start (a wrong host or port, a wrong password, a database that doesn't exist yet) doesn't stop a linked gateway. It enrolls, syncs and serves its other databases, and: * **Refuses every call to it**, before anything else is checked or recorded, with `Database "" can't be reached from the gateway right now.` * **Keeps trying it**, after 1 s, 2 s, 4 s and so on, at most every 30 s. Each try waits up to 10 s to connect and 30 s to read the catalog. Once one succeeds the database is served, its catalog goes up at once, and the dashboard hears within seconds. * **Reports it** in its status: for each database, whether the most recent attempt to reach it answered, since when, and if not, its SQLSTATE or Node error code. Attempts are catalog reads, statements, the dashboard's connection test and `/readyz` pings. A statement counts only when it can tell: one Postgres answered, even with an error, says the database is up; one that never got an answer, or lost its connection, says it's down; one the gateway refused itself says nothing. Until a database's catalog has been read, only a catalog read can say it's up. The error's message stays in the gateway's log (`database unreachable`), since it can name roles and hosts; only the code goes up. The **Gateways** page and the setup step show it beside the database: `shop · can't connect: password authentication failed (28P01)`. | Code | Usually means | | - | - | | `ECONNREFUSED` | Nothing listens at the DSN's host and port | | `ENOTFOUND`, `EAI_AGAIN` | The host name doesn't resolve from where the gateway runs | | `ETIMEDOUT`, `TIMEOUT` | No answer at all: a firewall, or the wrong network | | `28P01` | Wrong password | | `28000` | No `pg_hba.conf` entry for this host, role or database, or the role can't log in | | `3D000` | The database named in the DSN doesn't exist | | `57P03` | Postgres is starting up or shutting down | A database the gateway has read once keeps its catalog if it goes down later: calls to it fail with the connection's error, and its health shows the code after the next one, until a statement gets an answer again. `/readyz` answers `503` while any database is down or unread ([operations](/docs/gateway/operations#health)). Local mode still refuses to start without every database, and so does any gateway with a config error (an unknown key, a missing secret file). Before `0.22.0`, a linked gateway did too: it exits at start with the database's error, before it enrolls. ## What goes up Over the link, a gateway sends its status (bundle version, state, version, features, database ids, URLs, each database's health as an error code, and behind the relay how many connections it holds there and the relay's last refusal), each database's catalog (table and column names and types, primary and foreign keys, view definitions with every literal replaced; never a value, and never a `COMMENT ON`, which could hold one), held writes for approval, taint records, and its audit log ([audit](/docs/audit)). It never sends a DSN, the salt, a Postgres error message, or a row. The cloud's ack of each audit batch is signed like its other answers, for a nonce the gateway sends, and names the hash of the event it stored at the acked sequence, the hash of the request body it received, and the batch's first event it already holds under another hash, if any: it stores a batch only up to there. The gateway stops owing events only on an ack that verifies for the bytes it sent, and only up to such a conflict, sending the rest as a new instance, so a proxy can't make it forget events the cloud never got, whether it forwards part of a batch or stores a forged copy first. The event hashes the cloud holds are keyed with a secret that stays in the audit file, so it can't test a guess at a statement's values against them. ## Replicas Several processes may share one identity (the same `MIDPLANE_IDENTITY`), each with its own audit file. They share approvals and taint through the cloud: an agent tainted on one is tainted on all. On one cloud instance, replicas of one identity take turns holding the long poll, so each syncs about once a second. They take turns pushing audit batches too: one turned away (a 429) tries again a few seconds later, and doesn't log it as a failure unless the 429s go on for two minutes. Give each replica a file of its own: a copied file goes on as a new instance once the cloud names the other's event at one of its sequences. Local processes may share a file; linked ones may not. Behind the relay, run at most two processes, each with a disk of its own: the relay holds 8 idle connections per gateway, and each process keeps 4. Each orders its own certificate, and the relay passes Let's Encrypt's checks of the gateway's name to one of its connections after another until the process that ordered it answers. For more, give the gateway [your own URL](/docs/gateway/deploy#your-own-url). ## Revoking Revoking a gateway on the Gateways page cuts its link: it can't get new bundles or file held writes. It keeps enforcing its last bundle, and personal access tokens it already accepts keep working there until they expire, so stop the process as well. Behind the relay, the relay closes the gateway's connections within 10 seconds, so agents can't reach it at its Midplane URL from then on, and no other gateway ever gets that URL. *Tested by* `apps/gateway/test/setup-relay.test.ts` (a forged answer naming another relay, refused before anything is written), `apps/gateway/test/link.e2e.test.ts` (enrollment and the pin, bundles, halting, pausing, restarts with the cloud down, revocation, no inbound connection, a database unreachable at start, then served, then dropped), against a stand-in for the cloud, `apps/gateway/test/catalog.e2e.test.ts` (no DSN or Postgres message in anything sent), and CI's `package` job (`enroll` and `gateway` from the npm package). # Local mode Source: https://midplane.ai/docs/gateway/local-mode The policy in midplane.yaml, tokens from midplane token, no Midplane Cloud. `midplane local` runs the gateway with no Midplane Cloud: each database's policy is in `midplane.yaml`, and tokens are signed with a key you make. Use it offline, to try Midplane ([quickstart](https://github.com/midplaneai/midplane/tree/main/examples/quickstart)), or when you don't want a hosted control plane. What it doesn't have: approvals (a held write is refused, with a pointer to linking the gateway), a dashboard, taint shared between instances (each gateway keeps taint in its audit file), and the relay: agents reach a local gateway on your computer or at a URL of your own, and a config with `relay` is refused. ## Keys and tokens ```sh theme={null} midplane keygen --out . ``` writes `midplane-signing-key.json` (private: keep it secret, it mints tokens) and `midplane-verify-key.json` (public: `auth.public_key_file` names it). It won't overwrite either. ```sh theme={null} midplane token --config midplane.yaml --key midplane-signing-key.json \ --sub alice@example.com [--database main] [--access read|write] [--ttl 3600] ``` prints a token for one person (`--sub`), for one database or all of them, `read` by default, valid for an hour by default (`--ttl` is in seconds: `86400` is a day). Each token is its own grant: taint recorded for it doesn't follow its person to another token. A token whose id is in `auth.revoked_token_ids` is refused. ## Serving ```sh theme={null} midplane local --config midplane.yaml # MCP over HTTP at /mcp midplane local --config midplane.yaml --stdio # MCP over stdin and stdout ``` Over HTTP, agents send the token as `Authorization: Bearer `: ```sh theme={null} claude mcp add --transport http midplane http://127.0.0.1:7433/mcp \ --header "Authorization: Bearer $(cat token.txt)" ``` Over stdio, the MCP client starts the gateway itself and passes the token in `MIDPLANE_TOKEN`; it is verified at start and again on every call, so an expired token stops working without a restart. Logs go to stderr, since stdout carries MCP. Each session starts a gateway of its own; with one config they share its audit file, as one chain, and the taint it keeps. From `0.23.0`, an MCP client that installs from the official MCP registry (`ai.midplane/midplane`) runs this same command. It asks for the config's absolute path and the token, which stays in its entry, so make the token with a `--ttl` as long as the entry should work, in seconds. Add the environment variables the config names, such as a DSN or the mask salt, to the entry yourself. Stdio keeps an agent out only if it has no shell or file access: the gateway's secrets (the DSN, the salt, the token) sit in the agent's MCP config, and the gateway runs as the agent's own user. Beyond a demo, serve over HTTP, with the gateway running as another user or on another host. ## What an agent can call Five tools ([tools](/docs/agents/tools)): `query` (one read-only statement, with an optional `intent`, the agent's reason), `write` (one write, which the policy runs, holds or refuses), `list_tables`, `describe_table`, and `check_approval` (which, in local mode, says approvals need a linked gateway). *Tested by* `apps/gateway/test/cli.e2e.test.ts` (keygen, token, stdio) and `apps/gateway/test/gateway.e2e.test.ts` (local mode end to end). # Multiple gateways Source: https://midplane.ai/docs/gateway/multiple-gateways A gateway per network or region, replicas of one gateway, and adding databases later. A project can have several gateways. All of them enforce the project's one policy and share approvals and taint: an agent tainted on one is tainted on all. ## Another network or region When a database lives where your first gateway can't reach it, run a second gateway there: **Gateways**, **Set up another gateway**, and run its commands in that network. * Its command names every database of the project, and setup asks for each one's connection string. Press Enter to skip one it can't reach, or add one the project doesn't have yet. * A database can be served by more than one gateway. Give its connection string to each gateway's setup. * An agent connects to one gateway. For databases behind two, add each gateway to the agent as its own MCP server and sign in to each. ## Replicas For availability, run several processes of one gateway behind one URL of your own, or two behind the relay. They share one identity (enroll once with `midplane enroll`, as on [platforms without a disk](/docs/gateway/deploy#other-setups)), and each keeps its own audit file. A new enrollment token always makes a new gateway, so don't use one for a replica. Behind the relay, each process also keeps its own certificate, so give each a disk of its own. More in [linked mode](/docs/gateway/linked-mode#replicas). ## Adding databases **1. Add it in the dashboard** **+ Add database** under **Databases**, with its id. **2. Add it to the gateway's config** Add a line under `databases:` that names a file for the connection string, then write the string to that file: ```yaml theme={null} databases: shop: { dsn: { file: secrets/shop.dsn } } billing: { dsn: { file: secrets/billing.dsn } } ``` A gateway set up from environment variables (in Docker behind the relay, or on a platform) has lines such as `shop: { dsn: { env: MIDPLANE_DSN_SHOP } }`. Copied with the id changed, such a line names a variable the container doesn't have, so name a file instead, as above. In Docker, the config and the secrets are in the gateway's volume. Write from standard input, so the connection string stays off the command line: run `docker exec -i midplane-gw-… sh -c 'umask 077 && cat > secrets/billing.dsn'`, paste the string, and press `Ctrl-D`. Then run `docker exec -i midplane-gw-… sh -c 'cat >> midplane.yaml'`, paste the new line, and press `Ctrl-D`: the config setup writes ends with its `databases:` list, so the line joins it. **3. Restart the gateway** On your computer, stop it with `Ctrl-C` and start it again; in Docker, run `docker restart midplane-gw-…`. It reads the new database's tables, and the policy editor shows them. To keep the new connection string in a variable instead, as the others are, add `MIDPLANE_DSN_BILLING` where the gateway's variables live, add a line under `databases:` that names it, and restart the gateway so it reads both: ```yaml theme={null} billing: { dsn: { env: MIDPLANE_DSN_BILLING } } ``` A new `MIDPLANE_DSN_` variable alone does nothing once the gateway has set itself up. In Docker, `docker restart` doesn't read the env file again: remove the container and run its command again, as in [upgrading](/docs/gateway/upgrading#behind-the-relay). Each platform's page says where its variables live. If you change the config first, the gateway still starts: on **Gateways** its line reads `billing · in its config, not in this project`. **Add** beside it adds the database with its id filled in, and the gateway sends its tables within seconds. # Operations Source: https://midplane.ai/docs/gateway/operations Health checks, logs worth alerting on, stopping, resources and backups. ## Health | Endpoint | Answers | | - | - | | `GET /healthz` | `200 ok` while the process serves HTTP: for liveness | | `GET /readyz` | `200 ready` only while the gateway enforces a policy and every database answers; `503` otherwise (waiting for a first bundle, paused, halted, a database down, or one whose catalog it hasn't read yet): for readiness | Both are unauthenticated and say nothing else. A linked gateway with no bundle yet answers `/mcp` with a plain `503`, so clients don't loop through sign-in. Behind the relay, the listener is on loopback, so a health check runs on the same host or in the same pod: `curl -k https://127.0.0.1:7433/readyz`, with the port in `listen.port`. It serves HTTPS with the certificate for the gateway's Midplane URL, which doesn't name `127.0.0.1` (so `-k`), and it listens only once it has that certificate. ## Logs JSON lines on stderr, one object each, with `time`, `level` and `msg` (stdout carries MCP in stdio mode). The ones worth alerting on: | `msg` | Means | | - | - | | `gateway halted` | An authentic bundle can't be enforced here; `reason` says why. Everything is refused until it's fixed | | `bundle rejected` | A bundle failed its signature, issuer, project or version check; nothing changed | | `bundle refused` | An authentic bundle that isn't the project's latest, or that came without a valid proof that it is, such as an old one a proxy sent again: `reason` says which. Nothing changed, and the next sync asks again | | `bundle cache write failed` | The newest bundle is enforced but couldn't be written to `link.bundle_cache`: check the disk. The gateway removes the old cache, so a restart with the cloud down waits for a bundle (`503`) instead of enforcing an older one | | `stale bundle cache could not be removed` | As above, and the old cache couldn't be removed either: a restart with the cloud down enforces that older bundle until the cloud answers. Fix the disk | | `sync failed` | The long poll to Midplane Cloud failed: the cloud can't be reached, a proxy is in the way, or the cloud refused the gateway's status (an older cloud, [upgrading](/docs/gateway/upgrading#what-carries-over)). `error` says why; it tries again after `retry_ms`, backing off to about 30 seconds, and keeps enforcing its bundle meanwhile | | `audit write after execution failed` | A statement ran but its outcome couldn't be recorded: check the disk | | `audit push failed` | Midplane Cloud couldn't be reached, or answered with an error (a 5xx); the events wait in the file and are retried, backing off to every five minutes. A 429, another replica's batch in progress, is retried within seconds and not logged, for up to two minutes in a row | | `audit push waiting: Midplane Cloud keeps answering 429` | The cloud, or a proxy before it, has answered 429 for two minutes, longer than any replica's turn: said once until a batch is acked again, and each further 429 is an `audit push failed`. `from` and `to` name the batch | | `Midplane Cloud's audit answer didn't verify` | An ack that isn't the cloud's, or covers other bytes than the batch sent (a proxy's, say, or one that forwarded only part of the batch), names another event than the one sent, or a conflict at the very event sent, or a page in place of an answer: the events stay owed, so nothing is pruned, and the push is retried | | `audit file forked from what Midplane Cloud holds; sending it as a new instance` | The cloud holds another event at `seq` than the file's: the file was restored from a backup, or copied to a second replica, or a forged copy reached the cloud first. The cloud stored the batch up to the event before, and those are acked; the file goes on as the new `instance` from `seq`, sending the rest; nothing is lost here. `from` is the instance it was, under which the cloud's export holds its earlier events; the Gateways page flags that one's conflict | | `Midplane Cloud holds another event for the new instance too` | As above, but for the new instance too, which the cloud can't have seen: the events stay owed and the push is retried. Report it | | `Midplane Cloud refused audit events` | The cloud refused a batch, even one event at a time; `from` and `to` name its events. The push stops there: the same events are tried again every five minutes, and none is skipped | | `Midplane Cloud doesn't take audit events yet; upgrade it` | The cloud has no audit endpoint (a 404). Events wait in the file until it's upgraded, tried every five minutes | | `audit event sent without its details` | An event couldn't be projected, so it went with no statement text; `seq` names it, and the file keeps all of it | | `audit pruning failed` | Retention couldn't delete old events, so the file keeps growing; `error` says why | | `audit events recorded while linked never reached Midplane Cloud; local mode doesn't send them` | The file last ran linked, and `events` of its events were still waiting when it was opened in local mode. They stay in the file for the retention window from now (export them to keep them longer) and are never sent, even if it is linked again | | `database unreachable` | (`0.22.0+`) A database didn't answer: at start, on a retry, or when a statement lost its connection. `database` and `code` say which and why, `error` has the full message. Said again only if it fails differently, and `database reachable` follows once it answers ([linked mode](/docs/gateway/linked-mode#databases-it-cannot-reach)) | | `certificate renewal failed` | (`0.23.0+`) Let's Encrypt didn't renew the gateway's automatic certificate; `error` says what to fix, `acme_type` and `acme_detail` what Let's Encrypt said ([troubleshooting](/docs/troubleshooting#the-gateway-does-not-connect)). The current certificate is served meanwhile, and the renewal is tried again in 12 hours. Said at start too, with the kept certificate's `valid_until`, when it is still in date | | `certificate not reloaded` | (`0.23.0+`) The certificate files changed, but the new pair can't be served: `error` names the file and the problem. The old certificate stays; said once per replacement | | `link cut` | The cloud refused the gateway's credentials: it was revoked | | `relay connection failed` | Behind the relay: a work connection to the relay failed. `status` is the relay's answer (`401` for the link token, after a fresh one; `429` for too many idle connections), or `code` and `error` say why there was none. It tries again after `retry_ms`, backing off to about a minute ([cannot reach the relay](/docs/troubleshooting#cannot-reach-the-relay)) | | `The relay at refused this gateway: Midplane Cloud holds no relay URL for it, so agents can't reach it there. Only enrolling gives a gateway one. It tries again every minute.` | Behind the relay: the relay answered `403`, as Midplane Cloud holds no relay URL for this gateway ([no relay URL](/docs/troubleshooting#midplane-holds-no-relay-url-for-this-gateway)). Said once until a connection is accepted again | | `midplane upgrade needed` | (`0.25.0+`) A warning from a linked gateway: a release fixed a security problem, and this gateway runs a version below it. `needed` is the lowest version to run, `reason` says why, and `advisory` links the advisory when there is one. Said once per lowest version while it runs. It keeps enforcing your policy meanwhile ([upgrading](/docs/gateway/upgrading#when-an-upgrade-is-needed)) | | `newer midplane available` | (`0.25.0+`) Midplane Cloud knows a newer release than this linked gateway's `version`: `latest` is the newest. Said once per release while it runs, at `info` ([upgrading](/docs/gateway/upgrading)) | | `taint check failed; the grant counts as tainted` | The cloud couldn't be asked; the agent was contained | | `taint answer didn't verify; the grant counts as tainted` | An answer to a taint check that isn't the cloud's signed answer for that grant and check, such as a proxy's; the agent was contained | | `approval didn't verify; nothing ran` | Midplane Cloud's approval of a held write didn't verify for it (`approval` names it), so the write was refused. Report it | | `catalog upload refused` | The cloud refused a database's catalog: `code` is `too_large`, or the HTTP status. The gateway doesn't send that catalog again on its own until it changes, so the policy editor may not show the database's newest tables | | `public URL not registered` | A URL in `public_urls` isn't one of the gateway's yet ([hosted agents](/docs/gateway/hosted-agents)) | | `view definitions withheld from the cloud` | Some views' definitions couldn't be redacted, so they weren't sent; simulation denies statements through them | `mcp client` notes each new agent (client, person and MCP protocol version) once. `certificate obtained`, `certificate renewed` and `certificate reloaded` note a new certificate and its `valid_until`. Behind the relay, `relay work connection silent; replaced`, at `info`, notes an idle connection that heard nothing from the relay for 75 seconds: the gateway opens another, so it needs no alert. ## Stopping `SIGTERM` or `SIGINT` closes the listener, its connections and the database pools, then exits. Nothing is lost: every event was already on disk. ## Resources Each database gets a pool of up to 10 connections. A query returns at most `limits.max_rows` rows and `limits.max_bytes` bytes, and stops at either. A write drains its `RETURNING` rows, so its row count stays exact. Statement and lock timeouts come from the policy. The catalog (names and types) is re-read at start, after every write, when a statement names something unknown (at most every five seconds), and in linked mode every five minutes. A read may take 30 seconds. In linked mode, a database whose catalog couldn't be read at start is tried again after 1, 2, 4 and so on seconds, at most every 30 seconds, until it can be. ## Upgrades What carries over, and the commands for each way to run the gateway, are in [upgrading](/docs/gateway/upgrading). ## Backups Back up the audit file if you rely on it as your record (or export it on a schedule with `midplane audit export --since`), and keep the identity in your secret manager. The bundle cache is rebuilt from the cloud. A restored audit file is behind what Midplane Cloud holds: its next events take sequences the cloud already has, with other events. The cloud stores a batch only up to the first of them, and its signed ack says where; that makes the file a new instance (logged once, with `from` naming the old one), which sends the rest. The cloud keeps the old instance's events, flagged as a conflict. Events recorded after the backup survive only as far as the cloud got them, in its export under the old instance: the file keeps every instance it was, oldest first, in its `previous_instances` row (`sqlite3 audit.db "SELECT value FROM meta WHERE key = 'previous_instances'"`). `verify --checkpoints` then matches the new instance's hashes ([audit](/docs/audit#export-and-verify)). *Tested by* `apps/gateway/test/link.e2e.test.ts` (readiness, 503 without a bundle, halting, SIGTERM, restarts with the cloud down, a database down and back, the release lines), `apps/gateway/test/gateway.e2e.test.ts` (caps, timeouts, the catalog, local mode refusing to start without a database, an audit write that fails after execution), `apps/gateway/test/health.test.ts` (the retry schedule, what counts against a database's health), `apps/gateway/test/relay.test.ts` (the relay's lines and backoff) and `apps/gateway/test/release-notice.test.ts` (when each release line is said). # Run the gateway on Render Source: https://midplane.ai/docs/gateway/render Deploy the gateway as a Render background worker with a disk, from Midplane's Blueprint, behind Midplane's relay: no port or domain. The gateway runs as a Render background worker with a 1 GB disk, behind Midplane's relay: agents reach it at a Midplane URL, and the worker has no port or domain, so nothing connects in. It suits a database Render can reach over the internet. Background workers need a paid instance type; the Blueprint picks Starter, in Frankfurt. ## Set it up **1. Create an enrollment token** In your project, open step 1 of the setup (or **Gateways**, then **Set up a gateway**), choose **A server (Docker)**, keep **Midplane URL**, and under **Runs on** choose **Render**. Click **Create enrollment token**, above the tabs, and keep the page open. **2. Deploy the Blueprint** Click **Deploy to Render**, name the Blueprint, and apply it. Render makes the worker and its disk. Its first start says `no config` and stops: it has no env file yet. **3. Add the env file** In the worker's **Environment**, choose **Add from .env**, paste the file the page shows with each database's connection string after its `=` ([prepare your database](/docs/prepare-database)), and save, choosing to deploy. **4. Check it on the page** The gateway sets itself up from the environment, joins your project, and gets its certificate through the relay. The page then shows its URL for agents. The worker's **Logs** show what it says. ## Run it * **A connection string changes.** Change it in **Environment** and deploy. * **A database is added later.** First append the database's line to `/var/lib/midplane/midplane.yaml`, in the worker's **Shell**. Then add its `MIDPLANE_DSN_` in **Environment** and deploy, so the gateway starts with both: the variable alone is ignored once the gateway has set itself up ([adding databases](/docs/gateway/multiple-gateways#adding-databases)). * **Upgrade.** In the worker's **Settings**, change the image's tag to the new version, and deploy ([upgrading](/docs/gateway/upgrading#render)). The disk, and with it the gateway, stays. * **Remove it.** Revoke the gateway on your project's **Gateways** page, then delete the worker, which deletes its disk with the audit log. The Blueprint is [`deploy/render/render.yaml`](https://github.com/midplaneai/midplane/blob/main/deploy/render/render.yaml). *Tested by* `apps/gateway/test/deploy-templates.test.ts` (one worker, the version's image, the gateway's folder on its disk, no variable in the Blueprint). # Secrets Source: https://midplane.ai/docs/gateway/secrets Connection strings, the mask salt and the gateway's identity: where they live and how to keep them. The gateway holds the secrets Midplane Cloud never sees: database credentials, the mask salt and its own identity. Name each in `midplane.yaml` by environment variable or file; none of them is ever sent over the link. ## Database credentials ```yaml theme={null} databases: main: { dsn: { env: DSN_MAIN } } # or { file: /run/secrets/dsn-main } ``` One DSN per database id. Give the gateway a login role with only what agents should ever be able to do: Midplane narrows a role's permissions, it never widens them, and row-level security still applies. A policy's `role` runs every statement as another role (`SET LOCAL ROLE`), which the login role must be a member of. ## The mask salt `consistent-hash` masks are `sha256(salt || value)`: the same value always masks to the same token, so joins and counts work, and without the salt the tokens can't be reversed by hashing guesses. * **Generate** at least 32 random characters once, for example `openssl rand -hex 32`, and keep it in your secret manager. * **Share** it across every instance that serves the same databases, so they mask alike. * **Rotate** by hand: a new salt changes every masked token, so anything that stored old tokens no longer matches. A policy with masks halts a linked gateway that has no `mask_salt`, and stops a local one from starting. ## The identity (linked mode) The identity file, or the `MIDPLANE_IDENTITY` variable, holds the gateway's private key: whoever has it can act as the gateway on the link (fetch its bundles, file held writes, record taint). The file is written with mode 0600. If it leaks, revoke the gateway on the Gateways page and enroll a new one. Enrollment tokens are single use and expire after 24 hours; the gateway reads one only until its identity exists. ## Local signing keys (local mode) `midplane-signing-key.json` mints tokens for every database the config names. Keep it off the gateway's host when you can: the gateway only needs the public half. ## The audit file Not a secret, but sensitive: it holds every statement as agents wrote it and their stated intents, which can include values a person typed. It never holds results. Protect it like the database's own logs ([audit](/docs/audit)). *Tested by* `apps/gateway/test/config.test.ts` (secret sources, the salt's length), and the approvals and audit suites (`apps/gateway/test/approvals.e2e.test.ts`, `audit.e2e.test.ts`), which scan everything the gateway sends for the DSN, the salt and row values. # Upgrading Source: https://midplane.ai/docs/gateway/upgrading Move a gateway to a new release: what carries over, and the commands for Docker, your computer and each deploy template. Midplane never upgrades a gateway: you do, when you choose. When a newer release is out, the gateway's row on your project's **Gateways** page says so, such as `midplane 0.22.0 · 0.24.0 available`, and **How to upgrade** shows the commands for that gateway, with the release's version in them. A linked gateway (`0.25.0+`) says so in its log too, once per release while it runs: `newer midplane available` ([logs](/docs/gateway/operations#logs)). ## When an upgrade is needed When a release fixes a security problem, Midplane Cloud makes it the lowest version gateways should run. A gateway below it says why on its row, with **How to upgrade**; the project's overview shows a banner; and the organization's owners and admins get one email for each new lowest version. A linked gateway (`0.25.0+`) logs the warning `midplane upgrade needed`, with the reason. The gateway keeps enforcing your policy meanwhile: the warning never stops it. ## What carries over * **Deploy Midplane Cloud before gateways.** A gateway's sync reports what it supports, and a newer gateway's calls need a cloud that knows them; an older cloud refuses its status, and the sync fails until the cloud is upgraded. * **Gateways halt rather than half-enforce.** A bundle that needs a feature a gateway lacks halts it, and the dashboard warns before publishing one to a gateway that would. * **Restarts keep enforcing.** A linked gateway enforces its cached bundle while it reconnects, and resumes pushing its audit log where it stopped. * The audit file, the bundle cache and the identity carry over between versions; keep them on a volume. ## Changes that need attention * **`0.26.0`: writes moved to a `write` tool.** `query` runs reads only and refuses a write with a message naming `write`; `write` runs writes and refuses reads. Agents see the new tool when their client next lists the gateway's tools, on reconnect or in a new session. A prompt or script that sent writes through `query` must send them through `write`, and a held write's approval is claimed by re-running it there. Clients run a read-only tool without asking and gate the rest, which one tool that did both couldn't express ([tools](/docs/agents/tools)). ## Docker Use a fixed version, such as `ghcr.io/midplaneai/midplane:0.26.0`, not `:latest`: Docker doesn't download `:latest` again once the image is on the server. Pull the new image first, so the gateway is down only between removing its container and starting the new one. Its volume, with its identity, cached policy and audit log, stays. The dashboard names a gateway's container `midplane-gw-` and 8 characters, such as `midplane-gw-k3m9x2qa`, and **How to upgrade** names it when Midplane Cloud knows it. That's the name the page gave, so check that the server lists it before you remove anything: `docker rm -f` of a name that isn't there says nothing, and the new container would be a second gateway. Its name starts with `midplane-`. ```sh theme={null} docker ps --format '{{.Names}}' ``` ### Behind the relay In the folder with `midplane.env`, with `` replaced by the gateway's container and `` by the release's: ```sh theme={null} docker pull ghcr.io/midplaneai/midplane: docker rm -f docker run -d --name --restart unless-stopped \ -v :/var/lib/midplane --env-file midplane.env \ ghcr.io/midplaneai/midplane: ``` The new container reads the same env file and volume, and starts from the config it holds, without joining the project again. ### At your own URL Have the `docker run -d` command you started it with at hand: it's the one way to start it again. Then pull the new image, and remove the container: ```sh theme={null} docker pull ghcr.io/midplaneai/midplane: docker rm -f ``` Then start it again with the `docker run -d` command you started it with, with the new version in place of its tag: Midplane Cloud doesn't know the port or the certificate you chose. ## Your computer Stop the gateway (`Ctrl-C`), then start it again in the folder you ran setup in, such as: ```sh theme={null} npx -y midplane@latest gateway --config gw-k3m9x2qa/midplane.yaml ``` `midplane@latest` gets the newest release each time it starts. The folder has the name in the gateway's URL: `http://gw-k3m9x2qa.localhost:7433` is `gw-k3m9x2qa`, and so is the Midplane URL `https://gw-k3m9x2qa.eu.midplane.app/mcp`. Behind the relay, **How to upgrade** opens on **Docker**, since the URL doesn't say where the gateway runs: choose **Your computer**. ## Deploy templates Each version's template, chart and Blueprint runs that version's image. ### AWS Update the stack with the new version's template, replacing the current one with `https://midplane-gateway-templates.s3.eu-central-1.amazonaws.com/gateway//aws.yaml`. The instance stops and starts, and runs the new image; its disk, and with it the gateway, stays. ### Fly.io ```sh theme={null} fly machine update -a midplane-gw-… \ --image ghcr.io/midplaneai/midplane: ``` `fly machine list -a midplane-gw-…` shows the id. The volume, and with it the gateway, stays. ### Render In the worker's **Settings**, change the image's tag to the new version, and deploy. The disk, and with it the gateway, stays. ### Kubernetes ```sh theme={null} helm upgrade midplane-gw-… oci://ghcr.io/midplaneai/charts/midplane-gateway \ --version --reuse-values ``` The chart's version is the gateway's. The pod is replaced, on the same disk. ## Automatic updates Midplane never updates a gateway. Midplane Cloud signs policy only: it can't change the code that runs beside your databases. A tool of yours can, such as Watchtower or a GitOps pipeline that follows new tags. We recommend a fixed version, upgraded on purpose after [verifying it](/docs/releases). ## Which version is newest The **Gateways** page says so: Midplane Cloud checks the public repository's latest release every hour, and counts only a final release. Every release is on [GitHub](https://github.com/midplaneai/midplane/releases), with what changed. # Get started Source: https://midplane.ai/docs/get-started From a new Midplane Cloud project to the first query an agent makes through your gateway. You need a Postgres database and its connection string (or Docker, for the sample one), Node 24.16 or newer, and an MCP client such as Claude Code. **1. Create a project** Midplane Cloud is invite-only for now: [get access](https://midplane.ai/#get-access), and we'll email you an invitation. Follow its link to create your account in its region, such as [eu.app.midplane.ai](https://eu.app.midplane.ai), verify your address, name your organization and create a project. Its overview opens on **Connect your first database**. **2. Run the gateway** Have a connection string at hand for a role the gateway logs in as. **Before you start**, in the step, has the SQL for a read-only one ([prepare your database](/docs/prepare-database)). No database at hand? Try [the sample](#with-the-sample-database) instead. **Create enrollment token** and pick where the gateway runs: **Your computer** to try Midplane, or **A server (Docker)** for a team. Agents anywhere, claude.ai and ChatGPT included, reach a gateway on a server, and one on your computer at a **Midplane URL**; at **This computer only**, only agents on your computer do. On your computer, the step's command runs `midplane setup`, which asks for each database's connection string, tests it, and names it in Midplane, such as `shop`: your name for the database, not its Postgres name. On a server, you put each connection string in the env file the step shows, and the gateway tests it as it starts. Either way it then joins your project and starts. The step shows the gateway's checks as it reports them, and it's done when the gateway enforces the project's policy. The **Gateways** page then lists each database, such as `shop · reachable · catalog read: 12 tables` ([deploy the gateway](/docs/gateway/deploy)). **3. Choose what agents may read** A new policy denies everything. **Open `shop`'s policy** from the step and set the tables agents may read; setting **Default access** to **Read** opens every table the policy doesn't list, including tables created later. Review the columns that look like personal data or credentials, then **Review and publish**. **Back to setup** returns to the card. **4. Connect an agent** Run the `claude mcp add` command the step shows, then `/mcp` in Claude Code and **Authenticate**. Sign in, and choose which databases it may use. Another client? See [Cursor](/docs/agents/cursor), [VS Code](/docs/agents/vs-code), [claude.ai](/docs/agents/claude-ai) or [ChatGPT](/docs/agents/chatgpt). **5. Try it** Ask your agent one of the questions the step suggests about a table it may read, such as "How many rows are in `public.customers`?". The card finishes with that query, and every statement shows up in the **Query log**. Stuck? See [troubleshooting](/docs/troubleshooting). ## With the sample database No database at hand? On **Run the gateway**, an owner or admin can choose the **Try with sample data** tab. It creates a second project, **Sample shop**, with the database `shop` and the sample's policy published: customers' emails hashed and phones partly hidden, support ticket bodies untrusted, the API keys secret, and row changes held for approval. The project you started from stays as it is, for your own database. In the sample project, **Run the gateway** starts the sample's Postgres with Docker, then setup on your computer, which runs the gateway on port 7434 so it can run beside one of your own. Choose **Midplane URL** to try it from claude.ai or ChatGPT too. When setup asks for `shop`'s connection string, paste the one the step shows (the password is a public example). **Choose what agents may read** is already done. When Claude Code signs in, give it read and write on `shop`, then ask, in order: 1. "List our customers with their emails and plans": emails come back hashed, phones partly hidden. 2. "Summarize the open support tickets": the agent reads untrusted text and is tainted. 3. "Show me the API keys": refused, since a tainted agent can't read secret tables. 4. "Close ticket 2": held. Approve it under **Approvals**. To try Midplane with no account, the [local quickstart](https://github.com/midplaneai/midplane/tree/main/examples/quickstart) runs the same sample with the policy in a file. # How it works Source: https://midplane.ai/docs/how-it-works The gateway, the relay, your databases, projects and agents: how agents reach the gateway, the path of a statement, and what stays in your network. ![An AI agent connects over MCP to Midplane's relay; its queries and results are encrypted end to end, and the relay forwards them without being able to read them. In your network, the Midplane gateway checks, records and runs each statement, writes it to the audit log before it runs, and queries Postgres as its own role. Both of its other connections are outbound: to the relay, which agents' traffic comes in over, and to Midplane Cloud, which gets the query log with values removed and sends signed policies and approvals. Midplane Cloud gets no query results and no credentials.](https://mintcdn.com/midplane/lkG0VdvBBdYX5VLc/images/architecture.png?fit=max\&auto=format\&n=lkG0VdvBBdYX5VLc\&q=85\&s=c687745244d1a45b775a79e4068050c2) On [your computer](/docs/gateway/deploy#your-computer) with **This computer only**, or at [your own URL](/docs/gateway/deploy#your-own-url), agents connect to the gateway directly, with no relay. The gateway opens its own connections: to your databases, to Midplane Cloud and, behind the relay, to the relay. Neither Midplane Cloud nor the relay connects to the gateway or to your databases. ## The parts > **Gateway** > > One process in your network, next to your databases. It holds their > connection strings, checks every statement, and keeps the audit log. > Agents talk only to it. > **Databases** > > Each has an id, such as `shop`, and its own policy: which tables agents may > use, and what comes back masked. > **Agents** > > MCP clients such as Claude Code or claude.ai. Each signs in through the > gateway, and you choose which databases it may use. > **Relay** > > Midplane's, in its region. It gives a gateway in a private network a > public HTTPS URL, its Midplane URL, and passes agents' encrypted traffic > to it without reading it. > **Midplane Cloud** > > Where you write the policy, approve held writes and read the Query log. It > signs every policy it sends, and gets no rows and no connection strings. > **Project** > > In Midplane Cloud: databases, their policies, their gateways and the agents > allowed in. Published policies reach every gateway in seconds. ## How agents reach the gateway | Where the gateway runs | Agents reach it at | Which agents | What you set up | | - | - | - | - | | [Your computer, this computer only](/docs/gateway/deploy#your-computer) | The `http://gw-….localhost` URL setup prints, with its port | Those on the same computer | Node | | [A server, a platform or your computer, behind the relay](/docs/gateway/deploy#docker) | Its Midplane URL, such as `https://gw-7hq2m4xd.eu.midplane.app/mcp` | Any, claude.ai and ChatGPT included | Nothing inbound: the gateway connects out on port 443 | | [A server at your own URL](/docs/gateway/deploy#your-own-url) | Your URL, such as `https://midplane.example.com/mcp` | Any that can reach the URL | A DNS record, and ports 80 and 443 open or a certificate of your own | Hosted agents, such as claude.ai and ChatGPT, run on their vendor's servers, so they need a public HTTPS URL. Behind the relay the gateway has one, even in a private network with nothing open. ### Through the relay **1. The gateway connects out** It keeps 4 connections open to the relay of its region, such as `relay.eu.midplane.app`, and authenticates them with the token it uses with Midplane Cloud. **2. An agent connects to the Midplane URL** The URL's name, such as `gw-7hq2m4xd.eu.midplane.app`, points at the relay. Each gateway gets its own when it joins a project. **3. The relay passes the bytes on** It reads only the name the agent asks for in its TLS handshake, then passes the still-encrypted bytes over one of that gateway's connections. **4. TLS ends at the gateway** With a certificate the gateway got from Let's Encrypt, for a key that never leaves its disk. The agent's statement then takes the path below. The relay can't read a query, a result or a token, but it is in the path: while it's down, agents can't reach the gateway, and Midplane controls the DNS of the names it serves. [Security](/docs/security#the-relay) has what the relay sees, what it can't, and how to watch for a certificate your gateway didn't get. To keep Midplane out of the path, give the gateway [your own URL](/docs/gateway/deploy#your-own-url). ## The path of a statement **1. Check** The gateway parses the statement and checks it against the database's policy. **2. Record** It writes the attempt and the decision to its audit log, before anything runs. **3. Run** An allowed statement runs, with masks applied inside the query. A held write waits for a person's approval. ## What stays and what goes up > **Stays in your network** > > Connection strings, database error messages, the mask salt, and the full > audit log. Results go only to your agents. > **Goes to Midplane Cloud** > > Table and column names, statements with their values replaced, gateway and > database status, and writes held for approval, as written. Full statements > only if you turn them on. > **Passes through the relay** > > Agents' traffic, still encrypted. The relay sees the gateway's name, the > addresses, and when and how many bytes cross, never what they say. Every detail is in the reference: [linked mode](/docs/gateway/linked-mode#what-goes-up), [audit](/docs/audit#what-goes-to-midplane-cloud-linked-mode) and [security](/docs/security#the-relay). # The MCP gateway for Postgres Source: https://midplane.ai/docs/index A gateway between AI agents and your Postgres databases. Every statement is checked against your policy and recorded before it runs. ![An AI agent sends five statements, and Midplane, drawn as a plane between the agent and Postgres, checks each one against your policy and records it before it runs. Two reads pass through to Postgres: one is answered, the other answered with its emails hashed. Three stop at Midplane: a read of the api\_keys table is blocked because the table is not open to agents, a delete waits for your approval, and dropping the orders table is blocked because schema changes are denied.](https://mintcdn.com/midplane/4v67ABanoRZJXzA_/images/overview.png?fit=max\&auto=format\&n=4v67ABanoRZJXzA_\&q=85\&s=a0d841f0399b4b587160a2f422a08de6) The gateway runs in your network, next to your databases. In Midplane Cloud you write the policy, approve held writes and read the Query log. Agents reach the gateway through Midplane's relay, which passes their encrypted traffic without reading it, or at a URL of your own. Midplane never sees your query results or connection strings. > **[Get started](/docs/get-started)** > > From a new project to your agent's first query. > **[How it works](/docs/how-it-works)** > > The parts, how agents reach the gateway, and what stays in your network. > **[Deploy the gateway](/docs/gateway/deploy)** > > On your computer, a server, or a platform. ## What it enforces > **Table access** > > Denied by default. You open tables one by one, or all for reading. > **Masking** > > Emails hashed, phones shortened, dates generalized, inside the query. > **Approvals** > > A write can wait for a person to approve the exact statement. > **Containment** > > An agent that reads untrusted text loses access to secret tables. Every statement is also recorded in an audit log on the gateway's own disk. The gateway is MIT licensed, with its source on [GitHub](https://github.com/midplaneai/midplane). # People and roles Source: https://midplane.ai/docs/organization Invite people into your organization, give them roles, remove them, and delete the organization. Midplane Cloud is invite-only for now: we invite each organization's first owner, and owners and admins invite everyone else. An organization lives in one region (EU or US), and a person can belong to several organizations. Switch between them in the organization menu at the top left, which also leads to **Organization settings**. ## Roles | Role | What they can do | | - | - | | Owner | Everything, including making and removing owners. An organization always keeps at least one. | | Admin | Manage projects, gateways, policy, approvals and people, except owners. | | Member | Use the projects: connect agents, make their own personal access tokens, see approval requests and the Query log. They don't change policy or decide on held writes. | Nobody changes their own role. ## Invite someone **1. Send the invitation** In **Organization settings**, under **Invitations**, enter their email address, pick a role and choose **Invite**. Only owners invite owners. **2. They join** The email's link works for seven days, and only for someone signed in with that address, so a forwarded link is useless to anyone else. Someone new chooses **Create an account**, verifies their address from the email we send, then chooses **Join**. Someone with an account signs in and chooses **Join**. **3. Check that it worked** They appear under **Members**, with the role you gave them. If the email doesn't arrive, the dialog after **Invite** shows the link once: pass it on another way. **Resend** sends the same link again for another seven days; **Revoke** makes it stop working. ## Change roles and remove people Owners and admins change a member's role or remove them under **Members**. Removing someone ends their access to the dashboard at once. Agents they connected lose access to the organization's projects within 15 minutes, and their personal access tokens stop working on its gateways after the next sync. Inviting them again doesn't bring either back. ## Leave an organization Anyone can leave from **Organization settings**, with the same effect on their agents and tokens. The only owner can't leave: make someone else owner first, or delete the organization. ## Delete an organization Only owners can delete an organization, under **Delete** in **Organization settings**: type the organization's name to confirm. There's no undo, and it takes effect at once: * Every gateway stops serving at its next sync and refuses every call from then on. Uninstall them afterwards; their local audit files stay yours. * Agents lose access, and every personal access token stops working. * Everyone in it, you included, loses access, and pending invitations stop working. Its projects, policies, approvals and Query log are removed for good after 30 days. A gateway that can't reach Midplane Cloud for that whole time never learns of the deletion and keeps enforcing the last policy it had, so uninstall every gateway, including any that are offline. # Approvals and taint Source: https://midplane.ai/docs/policies/approvals-and-taint Held writes and containment, from the operator's side. Two controls that need state shared by every gateway instance, so in linked mode both live in Midplane Cloud. People decide approvals in the dashboard; this page is what an operator needs to know about how gateways behave. ## Held writes A policy can hold a class of write (`writes.row_changes` or `writes.schema_changes` set to `hold`), and a tainted agent's allowed writes are held too. A held write doesn't run: 1. The gateway counts the rows it would change (a `SELECT count(*)` built from the write, run read-only, for at most 5 seconds) and files the write with Midplane Cloud: the statement, the intent, the count. Never the rows. 2. The agent gets the request's id and a review link at once. An owner or admin approves or denies it on the Approvals page; Slack can announce new requests (one webhook per project). 3. The agent re-runs the same statement with the same intent through `write`, or calls `check_approval` with the id. An approval binds the database, the exact statement, the intent and the agent's grant: change a byte and it's a new request. 4. On the re-run, the gateway claims the approval (once, atomically), checks the decision's signature, and runs the write in a transaction that rolls back if it changes a different number of rows than the approver saw (more, for an upsert, whose count is an upper bound). A write that couldn't be counted runs without that check, and its approver was told so. Approvals expire: a pending request after 60 minutes, an approved one 15 minutes after the decision, both adjustable per project (at most 24 hours and 2 hours). A re-run is evaluated again under the current policy: an approval never lifts a denial. **In local mode** there is nobody to ask: a held write is refused, and the agent is told to link the gateway to have such writes approved. ## Taint Columns labeled untrusted (`labels.untrusted_columns`) hold text anyone may have written: support tickets, emails, scraped pages. An agent that reads one may have read instructions meant for it. * **Reading taints.** Before returning a result that carries an untrusted column (or storing one with a write), the gateway records that the agent's grant is tainted. If it can't record it, the read is refused. * **Tainted agents are contained.** Their writes are held for a person, and tables labeled secret (`labels.secret_tables`) are closed to them entirely. * **Every instance agrees.** Taint lives in Midplane Cloud, keyed by grant: tainted on one gateway, contained on all of them. * **Only people clear it.** An owner or admin clears an agent's taint on the Agents page, or it ends with the grant (revoking the agent or the token, or the person leaving the organization). In local mode, each gateway keeps taint in its audit file; local processes sharing one file share its taint. ## What fails closed | When | The gateway | | - | - | | Midplane Cloud can't be reached to check taint | Counts the agent as tainted: secret tables are closed, and a write only taint would hold is refused rather than filed | | It can't record taint | Refuses the read | | It can't file a held write | Runs nothing, and says why | | A claim's answer is lost or doesn't verify | Runs nothing; the request shows it was claimed and didn't run | | A signed answer doesn't verify (a decision, a claim, a taint record) | Treats it as no answer | | The project is paused, or a new policy arrived, while a write waited | Runs nothing | Answers the gateway acts on are signed with Midplane Cloud's key, the one it pinned at enrollment, so a proxy between the two can't approve a write or report a tainted agent clean. ## Deploy the cloud first A gateway that files held writes and checks taint needs a Midplane Cloud that answers those calls. Against an older cloud, every held write and every untrusted read fails closed. An older gateway against a newer cloud keeps working as before; the dashboard says which features it lacks. *Tested by* `apps/gateway/test/approvals.e2e.test.ts` (filing, counting, the claim and its signatures, the row-count check, taint before a result, every failure above closed, against a stand-in for the cloud) and `apps/gateway/test/quickstart.e2e.test.ts` (local mode). # Masking Source: https://midplane.ai/docs/policies/masking The transforms, unreviewed columns, what else changes once a database has masks, and the known limits. Masks are applied at the source: the gateway rewrites the statement so each masked column is replaced by its mask wherever the table is read, before any filter, join or aggregate sees it. `WHERE email = 'jane@example.com'` then compares the masked value, so a mask can't be probed through a predicate. ## Transforms | Rule | Columns | Result | | - | - | - | | `none` | any | The raw value: reviewed and left clear | | `full-redact` | any | `***` for text; NULL of the column's own type otherwise | | `null-out` | any | NULL of the column's own type | | `consistent-hash` | text | `sha256(salt ‖ value)` in hex: the same value, the same token, so joins and counts work ([the salt](/docs/gateway/secrets)) | | `{ t: partial, keepStart, keepEnd, glyph }` | text | The middle replaced by `glyph` (`•`); a value no longer than what's kept is masked whole | | `{ t: generalize, granularity: year \| month \| day }` | dates and times | Truncated, keeping the column's type | | `{ t: generalize, granularity: }` | numbers | Rounded down to a multiple of `n` | | `{ t: noise, ratio }` | numbers | Multiplied by a random factor within ±`ratio` | A rule that doesn't fit its column's type denies the statement rather than run a mask Postgres would reject. ## New and unreviewed columns In a table with a mask entry, a column without its own rule is **fully redacted until someone reviews it**, so a column added later never appears unmasked by surprise. Mark a reviewed column `none`. The dashboard counts columns awaiting review and suggests masks for columns whose names look like personal data or credentials. ## What else changes once a database has masks To keep masks from being worked around, a database with any mask entry also: * runs only allowlisted built-in functions, and denies a built-in name that is also defined in `public`; * denies casts to types defined outside `pg_catalog` (a type's input function is code); * denies the catalog views that publish values or statement text (`pg_stats`, `pg_settings`, `pg_stat_activity`, `pg_stat_statements`); * denies a write to a masked table that uses a masked column anywhere but a plain `RETURNING`: in a filter (`DELETE FROM users WHERE email = …`), as an `ON CONFLICT` target, or in a value it computes or copies (`SET salary = salary * 2`, `SET name = email`). The count of rows a filtered write changes would tell the agent the raw value. Writing a new value into a masked column is allowed, and a plain `RETURNING` column comes back masked; * denies renaming or moving a table that has masks or labels, which are keyed by name; * denies `ALTER TABLE` and `CREATE INDEX` on a masked table, since they can evaluate raw values (a `USING` expression, a `CHECK`, an index predicate); * drops Postgres' error `DETAIL` and `HINT`, which can quote the failing row. ## Known limits * **`noise` is redrawn on every read**, so averaging many reads converges on the raw value. Use it against casual reading, not a determined agent. * **`consistent-hash` is an oracle for whoever can write the table:** an agent that can insert a value it chose learns that value's token, and can then find rows holding it. * **Views that reach a masked table are denied**, not inlined, in this version. * **An intermediate partition's own entry doesn't reach its leaves:** masks and labels on a partitioned table's top parent apply to every partition, but the catalog records only each partition's top ancestor. ## The public bypass corpus `packages/corpus` holds the cases Midplane's policy core is tested against: SQL, a policy and a catalog in, the expected decision out, as JSON fixtures. It includes the masking bypasses we know of (aliases, CTEs, set operations, subqueries, whole-row functions, casts, predicate oracles, partitions, catalog views), and every one is denied or masked. Run it with `pnpm vitest run packages/core`; a case that slips through is a security bug ([SECURITY.md](https://github.com/midplaneai/midplane/blob/main/SECURITY.md)). *Tested by* the corpus (`packages/corpus/fixtures/masking*.json` through `packages/core/test/corpus.test.ts`) and `apps/gateway/test/gateway.e2e.test.ts` (masked reads against Postgres). # Prepare your database Source: https://midplane.ai/docs/prepare-database Create a read-only role for the gateway, write its connection string, and check that the gateway connects. The gateway connects to your database from wherever it runs. Midplane Cloud never does, so the database needs no public address. Using Supabase, Neon, Amazon RDS or Railway? Follow its guide instead: > **[Supabase](/docs/prepare-database/supabase)** > **[Neon](/docs/prepare-database/neon)** > **[Amazon RDS](/docs/prepare-database/rds)** > **[Railway](/docs/prepare-database/railway)** ## Create a role for the gateway Recommended, not required: the gateway works with any role that can log in, and Midplane's policy decides what agents may do either way. A role of its own is a second lock. Midplane can narrow what a role may do, never widen it, so whatever the policy allows, Postgres still refuses what the role can't do. `midplane setup` warns when a connection string's role is a superuser, or owns tables or their schema: an owner can drop them whatever its grants. This role can only read. In `psql`, as an admin, replace the two placeholders and run: ```sql theme={null} CREATE ROLE midplane_gateway LOGIN PASSWORD ; \connect BEGIN; GRANT CONNECT ON DATABASE TO midplane_gateway; GRANT USAGE ON SCHEMA public TO midplane_gateway; GRANT SELECT ON ALL TABLES IN SCHEMA public TO midplane_gateway; COMMIT; ``` In a web SQL editor, which is already connected to the database, leave out the `\connect` line. > **Other schemas, and tables created later** > > Repeat the `USAGE` and `SELECT` grants for each schema agents should see. > The grants cover today's tables only: for later ones, run `ALTER DEFAULT > PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO midplane_gateway;` as > the role your migrations use. ### Letting agents write A write needs both locks open. In the policy, set the table to **Read + write** and **Row changes** to **Allow** or **Hold** (held writes wait for a person's approval). Then grant the role the same tables, and the sequences behind `serial` ids: ```sql theme={null} GRANT INSERT, UPDATE, DELETE ON public.tickets TO midplane_gateway; GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO midplane_gateway; ``` Without the grant, the policy lets the write through and Postgres refuses it. Schema changes (`ALTER`, `DROP`, `CREATE INDEX`) need the role to own the table, so most setups leave them off. ## Write the connection string On your computer, or on a server at your own URL, `midplane setup` asks for each database's connection string, tests it, and saves it in the gateway's folder: `secrets/shop.dsn` for the database id `shop`. On a server behind the relay, or on a platform, it goes in the env file the dashboard shows instead, after its `MIDPLANE_DSN_=`. The gateway reads it from there each time it starts, and never saves it. Either way, it's one line: ```text theme={null} postgres://midplane_gateway:@:5432/?sslmode=verify-full ``` * **Host.** Use the address the gateway reaches the database at, from where it runs. In a container, `localhost` is the container itself. * **Password.** Percent-encode `@`, `:`, `/`, `?`, `#` and `%` in it (`@` is `%40`). ### TLS `sslmode=verify-full` encrypts the connection and checks the database's certificate. If your provider signs certificates with its own CA, download it to where the gateway runs, and add its path. In Docker, put it in `/etc/midplane/tls` on the server: `&sslrootcert=/etc/midplane/tls/ca.pem`. The commands for **My own** certificate mount that folder at the same path. With **Midplane URL**, or an **Automatic (Let's Encrypt)** certificate, add `-v /etc/midplane/tls:/etc/midplane/tls:ro` after each `docker run`. On your computer, use the file's full path. > **How the gateway reads sslmode** > > The gateway reads `sslmode` differently from `psql`: `require`, `prefer` and > `verify-ca` check the certificate just like `verify-full`. Without > `sslmode`, or with `disable`, it connects without TLS. `sslmode=no-verify` > encrypts without checking the certificate, which lets a machine in the middle > read the connection: use it only on a network you trust. ## Check that it connects Setup tests each connection string as you give it. From an env file, the gateway tests each one as it first starts, in its log. Either says `ok` with the database and its number of tables, or why not, such as `password authentication failed (28P01)`: look up the code in [troubleshooting](/docs/troubleshooting#connecting-to-a-database). Once the gateway enforces a policy, the **Gateways** page lists each database as it reports it, such as `shop · reachable · catalog read: 12 tables`, and **Test connection** there shows each id with `ok`, such as `shop ok, 3 ms`. # Connect Neon to AI agents over MCP Source: https://midplane.ai/docs/prepare-database/neon Let Claude Code, Cursor and other MCP clients query Neon through the gateway: a read-only role, the direct connection string, and a first check. This guide gives the gateway a read-only role in your Neon project and a connection string it can use. You need a Midplane project ([get started](/docs/get-started)). **1. Create a role for the gateway, with SQL** In Neon, open **SQL Editor**, pick the branch and database, replace the placeholder with a strong password, and run: ```sql theme={null} CREATE ROLE midplane_gateway LOGIN PASSWORD ; GRANT USAGE ON SCHEMA public TO midplane_gateway; GRANT SELECT ON ALL TABLES IN SCHEMA public TO midplane_gateway; ``` This role can only read. To let agents write, see [letting agents write](/docs/prepare-database#letting-agents-write). Create it here, not under **Roles** in the console: roles made there join `neon_superuser`, which can do far more than read. **2. Copy the direct connection string** Click **Connect** on the project's dashboard, choose the branch and database, and turn **Connection pooling** off. Copy the string; it looks like: ```text theme={null} postgres://neondb_owner:@ep-cool-darkness-123456.eu-central-1.aws.neon.tech/neondb?sslmode=require ``` The gateway pools its own connections, so it doesn't need Neon's. **3. Give setup the connection string** Swap in the role and its password, and make the end `sslmode=verify-full`: ```text theme={null} postgres://midplane_gateway:@ep-cool-darkness-123456.eu-central-1.aws.neon.tech/neondb?sslmode=verify-full ``` Neon's certificates come from a public CA, so nothing else is needed. Put the line after its `MIDPLANE_DSN_…=` in the env file, or paste it when `midplane setup` asks for the connection string ([deploy the gateway](/docs/gateway/deploy)). **4. Check that it connects** Setup says `ok` with the database and its number of tables. Once the gateway runs, **Test connection** on the **Gateways** page shows your database's id with `ok`, such as `shop ok, 3 ms`, and the policy editor lists your tables. ## Good to know * **Branches.** Point the gateway at a branch instead of `main`: agents get real data, and nothing they change reaches production. * **Autosuspend.** A compute that scaled to zero takes a moment to wake. If that takes over 10 seconds, the first attempt fails with `TIMEOUT` and the next one connects. * **IP Allow.** If it's on, add the address the gateway connects from. # Connect Railway Postgres to AI agents over MCP Source: https://midplane.ai/docs/prepare-database/railway Let Claude Code, Cursor and other MCP clients query Railway's Postgres through the gateway: a TCP proxy, a read-only role, and a first check. Railway's Postgres is private to its project: `postgres.railway.internal` resolves only for Railway services. This guide opens a TCP proxy so the gateway can reach it from your computer or a server. You need a Midplane project ([get started](/docs/get-started)). **1. Turn on public networking** Open the Postgres service's **Settings**, then **Networking**, and add **Public Networking**. Railway creates a TCP proxy and a variable, `DATABASE_PUBLIC_URL`, with its host and port. **2. Create a role for the gateway** Open a SQL shell on the database, such as with `railway connect Postgres` from the Railway CLI. Replace the placeholder and run: ```sql theme={null} CREATE ROLE midplane_gateway LOGIN PASSWORD ; GRANT USAGE ON SCHEMA public TO midplane_gateway; GRANT SELECT ON ALL TABLES IN SCHEMA public TO midplane_gateway; ``` The proxy is reachable from the internet, so give the gateway this role, never the `postgres` superuser. This role can only read. To let agents write, see [letting agents write](/docs/prepare-database#letting-agents-write). **3. Give setup the connection string** Take the host and port from `DATABASE_PUBLIC_URL`: ```text theme={null} postgres://midplane_gateway:@shortline.proxy.rlwy.net:12345/railway?sslmode=no-verify ``` Railway's Postgres makes its own certificate, which no CA signs, so the gateway can't check it: `no-verify` encrypts the connection without that check. Put the line after its `MIDPLANE_DSN_…=` in the env file, or paste it when `midplane setup` asks for the connection string ([deploy the gateway](/docs/gateway/deploy)). **4. Check that it connects** Setup says `ok` with the database and its number of tables. Once the gateway runs, **Test connection** on the **Gateways** page shows your database's id with `ok`, such as `shop ok, 3 ms`, and the policy editor lists your tables. > [!WARNING] > > Without the certificate check, a machine between the gateway and Railway > could read the connection. Use this for data where you accept that risk. # Connect Amazon RDS or Aurora to AI agents over MCP Source: https://midplane.ai/docs/prepare-database/rds Let Claude Code, Cursor and other MCP clients query a private RDS or Aurora database: the gateway in the VPC, a read-only role, and a first check. This guide connects the gateway to an RDS or Aurora Postgres database that stays private in its VPC. You need a Midplane project ([get started](/docs/get-started)). **1. Run the gateway inside the VPC** The [AWS template](/docs/gateway/aws) runs it on EC2 in a subnet you pick, and can admit it to the database's security group. On ECS or EKS, run it in a subnet that reaches the database ([deploy the gateway](/docs/gateway/deploy#docker), [Kubernetes](/docs/gateway/kubernetes)), and in the database's security group allow the gateway's on port 5432. **2. Create a role for the gateway** Connect with `psql` as the master user, replace the placeholders, and run: ```sql theme={null} CREATE ROLE midplane_gateway LOGIN PASSWORD ; \connect BEGIN; GRANT CONNECT ON DATABASE TO midplane_gateway; GRANT USAGE ON SCHEMA public TO midplane_gateway; GRANT SELECT ON ALL TABLES IN SCHEMA public TO midplane_gateway; COMMIT; ``` This role can only read. To let agents write, see [letting agents write](/docs/prepare-database#letting-agents-write). Use a password, not IAM authentication: IAM tokens expire after 15 minutes, and the gateway reads its connection string once, at start. **3. Download the RDS certificate bundle** The AWS template downloads it for you. Elsewhere, save the [global RDS bundle](https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem) as `rds-global-bundle.pem` where the gateway runs: in Docker, in `/etc/midplane/tls` on the server, mounted at the same path. The commands for **My own** certificate mount that folder already; for the others, add `-v /etc/midplane/tls:/etc/midplane/tls:ro` after `docker run` in each. **4. Give setup the connection string** Use the endpoint from the database's **Connectivity & security** tab. For agents that only read, prefer the Aurora reader endpoint or a read replica: it refuses writes whatever the policy says. ```text theme={null} postgres://midplane_gateway:@mydb.abc123.eu-central-1.rds.amazonaws.com:5432/app?sslmode=verify-full&sslrootcert=/etc/midplane/tls/rds-global-bundle.pem ``` `sslrootcert` is the bundle's path where the gateway runs: `/etc/midplane/tls/rds-global-bundle.pem` in Docker and with the AWS template, or the file's full path on your computer. Put the line after its `MIDPLANE_DSN_…=` in the env file, or paste it when `midplane setup` asks for the connection string ([deploy the gateway](/docs/gateway/deploy)). **5. Check that it connects** Setup says `ok` with the database and its number of tables. Once the gateway runs, **Test connection** on the **Gateways** page shows your database's id with `ok`, such as `shop ok, 3 ms`, and the policy editor lists your tables. ## If it doesn't work * **`ETIMEDOUT` or `TIMEOUT`**: the security group doesn't admit the gateway. * **`SELF_SIGNED_CERT_IN_CHAIN`**: `sslrootcert` is missing or names the wrong file. * **`28000`**: the instance requires TLS (`rds.force_ssl`) and the connection string has no `sslmode`. # Connect Supabase to AI agents over MCP Source: https://midplane.ai/docs/prepare-database/supabase Let Claude Code, Cursor and other MCP clients query Supabase through the gateway: a read-only role, the session pooler's connection string, and a first check. This guide gives the gateway a read-only role in your Supabase project and a connection string it can use. You need a Midplane project ([get started](/docs/get-started)). **1. Create a role for the gateway** In Supabase, open **SQL Editor**, start a new query, replace the placeholder with a strong password, and run: ```sql theme={null} CREATE ROLE midplane_gateway LOGIN PASSWORD ; GRANT USAGE ON SCHEMA public TO midplane_gateway; GRANT SELECT ON ALL TABLES IN SCHEMA public TO midplane_gateway; ``` This role can only read. To let agents write, see [letting agents write](/docs/prepare-database#letting-agents-write). **2. Copy the session pooler's connection string** Click **Connect** at the top of the project and choose **Session pooler**. It works over IPv4 on every plan, which the direct connection doesn't. Copy the string; it looks like: ```text theme={null} postgres://postgres.abcdefghijklmnop:[YOUR-PASSWORD]@aws-0-eu-central-1.pooler.supabase.com:5432/postgres ``` The part after `postgres.` is your project ref. **3. Download Supabase's certificate** Open **Project Settings**, then **Database**, and under **SSL Configuration** download the certificate. Save it as `supabase-ca.crt` where the gateway runs: in Docker, in `/etc/midplane/tls` on the server. The commands for **My own** certificate mount that folder at the same path. With **Midplane URL**, or an **Automatic (Let's Encrypt)** certificate, add `-v /etc/midplane/tls:/etc/midplane/tls:ro` after each `docker run`. **4. Give setup the connection string** Change the copied string in three places: the user becomes `midplane_gateway.` followed by your project ref, the password becomes the role's, and the end gets the TLS settings: ```text theme={null} postgres://midplane_gateway.abcdefghijklmnop:@aws-0-eu-central-1.pooler.supabase.com:5432/postgres?sslmode=verify-full&sslrootcert=/etc/midplane/tls/supabase-ca.crt ``` `sslrootcert` is the certificate's path where the gateway runs: `/etc/midplane/tls/supabase-ca.crt` in Docker, or the file's full path on your computer. Put the line after its `MIDPLANE_DSN_…=` in the env file, or paste it when `midplane setup` asks for the connection string ([deploy the gateway](/docs/gateway/deploy)). **5. Check that it connects** Setup says `ok` with the database and its number of tables. Once the gateway runs, **Test connection** on the **Gateways** page shows your database's id with `ok`, such as `shop ok, 3 ms`, and the policy editor lists your tables. ## If it doesn't work * **`Tenant or user not found`**: the user lacks `.` and your project ref. * **`SELF_SIGNED_CERT_IN_CHAIN`**: `sslrootcert` is missing or names the wrong file. * **Tables are empty**: row-level security is on and has no policy for `midplane_gateway`. Add an RLS policy that lets the role read, or `ALTER ROLE midplane_gateway BYPASSRLS;` to let Midplane's policy be the boundary. # Verifying a release Source: https://midplane.ai/docs/releases Check the npm package's provenance and the image's signature and SBOM. A release is a tag `vX.Y.Z` on `main` in `midplaneai/midplane`. Its workflow (`.github/workflows/release.yml`) runs CI, then packs the gateway once with its whole dependency tree pinned in `npm-shrinkwrap.json`, runs the package smoke test on that tarball and an image built from it, and only then, in a separate job that runs none of its code or dependencies on the runner, publishes that same tarball: * the npm package `midplane`, with provenance: a Sigstore-signed statement that this tarball was built by that workflow, from that commit, in this public repository; * the image `ghcr.io/midplaneai/midplane:X.Y.Z` (amd64 and arm64), with an SBOM and build provenance attached, signed keyless with cosign by the same workflow's identity. `:latest` moves to each release; deploy a version tag or, better, the digest. Once the image is published, the same release also publishes the deploy templates, each set to that version's image: * the Helm chart `midplane-gateway`, to `oci://ghcr.io/midplaneai/charts` ([Kubernetes](/docs/gateway/kubernetes)); * the CloudFormation template, to `https://midplane-gateway-templates.s3.eu-central-1.amazonaws.com/gateway/X.Y.Z/aws.yaml`, and to `gateway/latest/aws.yaml` beside it, the one **Launch the stack on AWS** opens ([AWS](/docs/gateway/aws)). Neither template is signed or attested. The image each one runs is: verify it as below. Then the tag gets a GitHub release: what changed, from the version's section of [`CHANGELOG.md`](https://github.com/midplaneai/midplane/blob/main/CHANGELOG.md), the npm package and the image. From `0.23.0`, the entry `ai.midplane/midplane` in the [official MCP registry](https://registry.modelcontextprotocol.io) moves to that npm version too, for MCP clients that install from it (local mode over stdio; `apps/gateway/server.json`). Versions stay below 1.0 until the gateway has been used and tested more widely; v0.21.0 is the first of this gateway (earlier `midplane` versions on npm and in the MCP registry are an older, unrelated product). ## The npm package In a project that installed it: ```sh theme={null} npm audit signatures ``` checks the registry's signature on every installed package and the provenance attestations of those that have one; `midplane` should be listed as verified with provenance. The package's page on npmjs.com links its provenance to the workflow run and the commit. ## The image ```sh theme={null} cosign verify ghcr.io/midplaneai/midplane:0.21.0 \ --certificate-identity-regexp '^https://github\.com/midplaneai/midplane/\.github/workflows/release\.yml@refs/tags/v' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com ``` passes only for an image signed by that workflow, on a tag, in that repository. Pin the digest it prints when you deploy. The SBOM (SPDX) and the build provenance are attestations on the image: ```sh theme={null} docker buildx imagetools inspect ghcr.io/midplaneai/midplane:0.21.0 --format '{{ json .SBOM }}' docker buildx imagetools inspect ghcr.io/midplaneai/midplane:0.21.0 --format '{{ json .Provenance }}' ``` ## What is tested before a release Every change runs the package job in CI: the packed tarball is installed with npm and runs the local quickstart, `midplane enroll` and linked mode, and the image built from it runs the quickstart as a non-root user. A release runs the same test on the exact tarball it publishes. Installing it runs no dependency install scripts (`--ignore-scripts`), in the test and in the image. Publishing, signing and the attestations happen only on a tag; the commands above are how to check what a tag produced. # Security Source: https://midplane.ai/docs/security What Midplane Cloud and Midplane's relay can and can't see, the relay's DNS caveat, how to watch Certificate Transparency logs for your gateway, and how to report a vulnerability. The gateway runs in your network and holds your connection strings. Rows, results and connection strings stay there: [how it works](/docs/how-it-works#what-stays-and-what-goes-up) lists what goes up to Midplane Cloud, and [linked mode](/docs/gateway/linked-mode#what-goes-up) has every detail. Agents reach the gateway at your own URL, or at a Midplane URL through the relay, below. ## The relay A gateway [behind the relay](/docs/gateway/deploy#docker) connects out to the relay of its region, `relay.eu.midplane.app` in the EU, and keeps a few connections open there, which it authenticates with the token it uses with Midplane Cloud. Setup learns the relay's name from Midplane Cloud in an answer signed with the key the enrollment token pins (`0.26.1+`; earlier gateways take it on TLS alone), so a proxy that intercepts TLS can't point the gateway at another relay. An agent connects to the gateway's Midplane URL, such as `gw-7hq2m4xd.eu.midplane.app`, which points at the relay. The relay reads only the name the agent asks for in its TLS handshake, then passes the still-encrypted bytes, unread, over one of that gateway's connections. TLS ends at the gateway, with a certificate it got from Let's Encrypt for a key that never leaves its disk. Each connection serves only its own gateway's name. ### What the relay sees * **Host names:** the gateway's Midplane URL, as each agent asks for it. * **Addresses:** each agent's IP address and port, and the gateway's. * **Timing and byte counts:** when each connection opens and closes, and how many bytes cross it each way. * **The gateway's token**, which it checks as Midplane Cloud itself does. * **Let's Encrypt's checks** of the gateway's name on port 80: a request for a token whose answer is public by design. Nothing else on port 80 reaches the gateway. The relay logs these, never a byte of a connection, and passes each agent's address to the gateway ahead of its bytes, so the gateway sees the agent as the client rather than the relay. ### What the relay cannot see Anything inside TLS: the agent's requests and its access token, your SQL and its results, and every response the gateway sends. The relay doesn't hold a key or a certificate for any gateway's name, and has no way to decrypt the traffic it passes. "Midplane Cloud never sees a row" holds for a gateway behind the relay as for one at your own URL. The one certificate the relay holds is its own, for `relay.eu.midplane.app`, which it gets from Let's Encrypt as a gateway does. It serves it only to gateways opening their connections; an agent's connection to a gateway's name never ends there. ### Revoking a gateway behind the relay Revoking a gateway closes its connections to the relay within 10 seconds, so agents can't reach it at its Midplane URL from then on, even while its process runs. No other gateway ever gets that URL, even after the gateway is revoked or its organization deleted: agents that have it are never sent to another gateway, and no other gateway can get a certificate for the name through the relay. ### Who could get a certificate for your gateway Midplane controls the DNS of `midplane.app`, where Midplane URLs are. So Midplane could get a certificate for your gateway's name from a public certificate authority, point the name at a server of its own, and read what agents send there. Nothing in the protocol prevents that. Every publicly trusted certificate is published in Certificate Transparency logs, so such a certificate would be visible, but only after it was issued. The CAA records of `eu.midplane.app` allow Let's Encrypt only, by its HTTP challenge, and no wildcard certificate, so any certificate that covers your gateway's name lists that name, and a Certificate Transparency search for it finds the certificate (`dig CAA eu.midplane.app` shows the records); Midplane controls those records too. To have no Midplane component that could do this, give the gateway [your own URL](/docs/gateway/deploy#your-own-url). ### Watch Certificate Transparency logs Look your gateway's name up on [crt.sh](https://crt.sh), such as `https://crt.sh/?q=gw-7hq2m4xd.eu.midplane.app`. It lists every certificate logged for the name, usually twice each (a precertificate and the certificate). Each should be one your gateway got: its log says `certificate obtained`, or `certificate renewed` about 60 days later, with `valid_until`, the certificate's **Not After**. To check the one it serves now: ```sh theme={null} docker exec midplane-gw-k3m9x2qa node -e "const c = new (require('node:crypto').X509Certificate)(require('node:fs').readFileSync('acme/cert.pem')); console.log(c.serialNumber, c.validTo)" ``` A certificate for the name that your gateway didn't log is one it didn't request. To be told rather than look, check crt.sh's JSON (`https://crt.sh/?q=gw-7hq2m4xd.eu.midplane.app&output=json`, with each certificate's `serial_number`, `not_before` and `not_after`) from a scheduled job, or add the name to a Certificate Transparency monitor. ### When the relay is down Agents can't reach a gateway behind the relay, and nothing else reaches it: there is no other way in. The gateway keeps enforcing your policies, keeps its link to Midplane Cloud, which doesn't go through the relay, and tries to reconnect, waiting from 1 second up to a minute between tries. Once the relay is back, agents' next requests succeed with the tokens they have. The gateway renews its certificate through the relay, from 30 days before it expires, so an outage shorter than that costs no certificate. A gateway at [your own URL](/docs/gateway/deploy#your-own-url) doesn't depend on the relay. ## Reporting a vulnerability See [SECURITY.md](https://github.com/midplaneai/midplane/blob/main/SECURITY.md) on GitHub. *Tested by* the cloud's relay tests: marker strings seeded in a query and in its result never appear in anything the relay holds or logs, and the relay creates no TLS server for any name but its own. A smoke test runs the relay from its image with the gateway's, against Pebble, the test ACME server Let's Encrypt publishes, in place of Let's Encrypt itself. # Troubleshooting Source: https://midplane.ai/docs/troubleshooting What each message from the gateway and the dashboard means, and what to do about it. Each heading is a message the gateway or the dashboard shows. The gateway logs to the terminal that runs it, or to `docker logs`. An error at start is its last line before it exits. ## The gateway does not connect The setup step keeps **Waiting for the gateway to connect**. If `midplane setup` stopped, its last line says why; otherwise read the gateway's last log line. ### That is not a Midplane enrollment token The command holds the placeholder or part of a token (in a config you wrote, `secrets/enrollment-token` does). Create a token and run the step's command again. ### This enrollment token is unknown, used, revoked or expired A token works once, within a day, on the Midplane Cloud that made it. Create a new one and run the new command. ### A gateway at this URL is already enrolled Each URL belongs to one gateway. Revoke the old one on its project's **Gateways** page, or use another URL. On your computer, each setup makes a new URL. ### The cloud may have enrolled this gateway Setup lost Midplane Cloud's answer while joining, and removed the files it wrote. If the **Gateways** page lists the gateway, revoke it. Then create a new token and run setup again. Set up from environment variables, put the new token in the env file, then remove the container and run it again. ### No config `no config: mount one at /etc/midplane/midplane.yaml, or set MIDPLANE_ENROLLMENT_TOKEN from your project's setup page`: the image found no config to serve and no token to set one up with. Mount your config, start it with the volume it was set up on, or give it the variables of a [setup from environment variables](/docs/gateway/configuration#setup-from-environment-variables). ### A setup variable is not set `MIDPLANE_CLOUD_URL is not set`, or another variable: setup from environment variables needs it. Add it to the env file or the platform's secrets. Docker reads an env file only when it creates the container: remove the container and run it again. Nothing was written, and the token is unused. ### Already set up The folder, or in Docker the volume, holds a gateway already. Start it instead: on your computer, `npx -y midplane@latest gateway --config /midplane.yaml`; in Docker, the step's `docker run -d` command. On your computer each run of setup makes a new folder; in Docker, a new token gives the commands a new volume. ### The folder is not empty `/var/lib/midplane isn't empty`, naming the folder setup writes to. Setup writes only to a folder that doesn't exist yet or is empty (a disk's own `lost+found` counts as empty), and this one holds files but no `midplane.yaml`. In Docker, use a new volume: a new token gives the commands one. On your computer, leave out `--dir`, and setup makes a new folder. ### The port is taken Another program, or another gateway, listens on it. On your computer, setup takes the first free port from 7433 to 7442 unless the command names one with `--port`: stop the other program, or change the port. In Docker, `docker run` says the port is already allocated: change **Port** on the page, which changes `-p` in the second command. ### Cannot read the certificate `can't read /etc/midplane/tls/tls.crt (ENOENT)`: put the certificate and its key in `/etc/midplane/tls` on the server, as `tls.crt` and `tls.key`. With `EACCES`, the image's user (uid 1000) can't read them: `sudo chown 1000 /etc/midplane/tls/tls.key`. A file that isn't PEM is refused too. ### The certificate is for another name `the certificate in /etc/midplane/tls/tls.crt is for gw.old.example.com, not gw.example.com`: it doesn't cover the URL's host. Use one issued for that name, or enter the URL it was issued for. ### The certificate expired or is not valid yet `the certificate in /etc/midplane/tls/tls.crt expired on …`, or `isn't valid until …`. Renew it, and check the server's clock. ### The key is not the key of the certificate `/etc/midplane/tls/tls.key isn't the key of the certificate in /etc/midplane/tls/tls.crt`. Put the certificate's own key in `tls.key`, as PEM and without a passphrase. ### The DNS for the URL does not point at this server `the DNS for gw.example.com doesn't point at this server`, from setup or as the gateway's last log line, with an automatic certificate (`0.23.0+`, as are the headings down to "An automatic certificate needs a host name"). Let's Encrypt found no address for the host, or fetched its challenge from a server that isn't this gateway: what it said follows (`acme_detail` in the log). Point the host's `A` record, and `AAAA` if it has one, at this server (`dig +short gw.example.com` prints the address), and check that no web server on it takes port 80 first. Then run setup again: the token is unused. ### Port 80 is not reachable from the internet `port 80 on gw.example.com isn't reachable from the internet: open it, forwarded to port 7480 here`. Let's Encrypt couldn't connect to the host on port 80. Open it in the server's firewall and in your cloud's network rules or security group; in Docker, both commands map it with `-p 80:7480`. Without Docker, forward port 80 to 7480, or set `tls.acme.http_port: 80` where the gateway may bind it. A server that port 80 can't reach takes your own certificate. ### Certificates for this host are rate limited `Let's Encrypt is rate limiting certificates for gw.example.com: try again later`. Let's Encrypt issues at most five certificates for the same host a week, and allows five failed checks an hour; what it said follows, with when to try again. A setup that stops after getting the certificate spends one, so while you try things out, add (`0.23.0+`) `--acme-directory https://acme-staging-v02.api.letsencrypt.org/directory` to setup: its certificates are for tests, and agents don't trust them. ### The CAA records forbid the certificate authority `the CAA records for gw.example.com forbid Let's Encrypt`. The domain's CAA records name the certificate authorities that may issue for it, and Let's Encrypt isn't one. Add `0 issue "letsencrypt.org"` to them, or use your own certificate. ### The certificate authority refused a certificate `Let's Encrypt refused a certificate for gw.example.com`, for another reason than the ones above: what it said follows. Fix that, or use your own certificate. ### The challenge port is taken `port 7480 is taken`: another program, such as a second gateway with an automatic certificate on this host, listens where the challenge is answered. Stop it, or give this gateway another port with `tls.acme.http_port` and forward port 80 there. ### An automatic certificate needs a host name Let's Encrypt certifies names, not IP addresses. Give the server a DNS name and use it in the URL, or choose **My own** under **Certificate**. ### Inside the container, localhost is the container Setup in Docker connects to each database from inside its container, so `localhost` and `127.0.0.1` in a connection string are the container itself. Use the database's host as the server's network knows it. If the database runs on the same server, the container reaches the server at Docker's bridge address, by default `172.17.0.1` on Linux (`ip addr show docker0` shows it), so use that as the host. Postgres must listen there and admit the container: set `listen_addresses = '*'`, since Postgres can start before Docker adds that address, keep port 5432 closed to the outside in the server's firewall, and add a `pg_hba.conf` line for the bridge's network, by default `172.17.0.0/16`. A Postgres in another container publishes its port there: `-p 172.17.0.1:5432:5432`. On Docker Desktop, use `host.docker.internal`. ### A gateway URL starts with https `http://` URLs only work on your computer (`localhost`, `127.0.0.1`, `*.localhost`). Use `https://` for anything else. ### Cannot reach Midplane Cloud The gateway couldn't open an HTTPS connection to `link.cloud_url`: check DNS, firewalls and proxies from its host. ### The answer is signed with a key the token does not pin Something between the gateway and Midplane Cloud intercepts TLS. Let the gateway reach the cloud without TLS inspection. Behind the relay, setup (`0.26.1+`) checks where the relay is first, and stops before it writes anything, with the token still unused: `signed where its relay is with a key the enrollment token doesn't pin`, or `didn't sign where its relay is for this setup`. Check, too, that the token comes from the Midplane Cloud the URL names (`MIDPLANE_CLOUD_URL`, or `--cloud`). ### This gateway enrolled with another cloud `link.cloud_url` changed after enrollment. Put it back, or remove `identity.json` and enroll with a new token. ### Cannot read a secret `databases.shop.dsn: cannot read …`: a file the config names is missing or unreadable. In Docker with a config you wrote, let the image's user read the secrets: `sudo chown -R 1000:1000 secrets`. ### An environment variable the config names is not set `databases.shop.dsn: environment variable SHOP_DSN is not set`, or the same for `mask_salt` or `link.enrollment_token`: the config takes the value from the environment (`dsn: { env: SHOP_DSN }`), and the gateway's process doesn't have it. Give the process that variable (a container's environment, a Kubernetes Secret), or have the config name a file instead (`dsn: { file: secrets/shop.dsn }`). For `link.identity`, the message says to put the output of `midplane enroll` in the variable. ### Cannot read config `cannot read config /etc/midplane/midplane.yaml`: the file `--config` names is missing or unreadable. In Docker, mount the folder that holds it, and let the image's user (uid 1000) read it. ### The listen host is not loopback, so TLS is required Add a certificate (`tls`), or keep the gateway on `127.0.0.1` [behind your TLS proxy](/docs/gateway/deploy#other-setups). ### Midplane needs Node 24.16 or newer `midplane needs Node 24.16 or newer; this is Node 22.12.0.` Upgrade Node (`node --version` prints it). The image is for servers: on your computer it needs a TLS certificate. ### Local mode over stdio needs a token in MIDPLANE\_TOKEN `--stdio needs a token in MIDPLANE_TOKEN`: the MCP client started `midplane local --stdio` without one. Add the token `midplane token` printed to the client's entry for the gateway, as the variable `MIDPLANE_TOKEN` ([local mode](/docs/gateway/local-mode)). ### The localhost URL does not resolve Some Linux and WSL setups don't resolve `*.localhost`. Add the gateway's name to `/etc/hosts`: `127.0.0.1 gw-k3m9x2qa.localhost`. ## Cannot reach the relay A gateway [behind the relay](/docs/gateway/deploy#docker) connects out to the relay of its region, such as `relay.eu.midplane.app`, on port 443. The setup step's checklist shows how that goes as **Relay · connected**, and the **Gateways** page as **Relay: connected**. Before the first connection they say **connecting**, and otherwise the relay's refusal, below. Until it first connects, the gateway can't get its certificate, so agents can't reach it; it keeps trying, waiting up to a minute between tries, and logs `relay connection failed` each time with the `status` or `code`. A gateway that connected before keeps enforcing your policies meanwhile ([when the relay is down](/docs/security#when-the-relay-is-down)). ### Midplane Cloud has no relay `Midplane Cloud at https://… has no relay: set the gateway up at a URL of your own, with --url` (or `MIDPLANE_URL`), from setup. The cloud you set up with has no relay in its region, so a gateway there needs [your own URL](/docs/gateway/deploy#your-own-url). Nothing was written, and the token is unused. ### Midplane Cloud did not say where its relay is `Midplane Cloud at https://… didn't say where its relay is (503): try again later`, from setup: the cloud's answer had no relay in it, as when it is being deployed. Run setup again, or start the container again, in a few minutes. Nothing was written, and the token is unused. ### The relay cannot be reached **Relay: can't reach it**, with why, such as `connection timed out (ETIMEDOUT)`. The gateway couldn't open a connection to the relay on port 443\. Check that its host may connect out on port 443 to the relay's name, and that the name resolves there (`getent hosts relay.eu.midplane.app`). A proxy that inspects TLS breaks the connection: let the gateway reach the relay without it. ### The relay refused its link token **Relay: refused its link token** (401). The relay didn't accept the token the gateway got from Midplane Cloud, even a fresh one, as when the gateway has just been revoked. Check the **Gateways** page: a revoked gateway can't be reached at its Midplane URL again, so set up a new one with a new token. ### Midplane holds no relay URL for this gateway **Relay: refused: Midplane holds no relay URL for this gateway** (403). The gateway's config has `relay`, but it didn't get a Midplane URL when it joined the project, or an owner or admin removed it on the **Gateways** page. Only joining gives a gateway one: set up a new gateway behind the relay with a new token. ### Too many idle connections **Relay: refused: too many idle connections, from replicas of this gateway** (429). The relay holds at most 8 idle connections per gateway, and each copy of a gateway keeps 4, so a third copy is refused. Run at most two copies of one gateway behind the relay; for more, give the gateway [your own URL](/docs/gateway/deploy#your-own-url). ### No link token for the relay **Relay: not tried: the gateway couldn't get a link token from Midplane Cloud**. The gateway asks Midplane Cloud for the token it opens its connections with, and that failed: see [cannot reach Midplane Cloud](#cannot-reach-midplane-cloud). ### The certificate cannot be fetched through the relay `Let's Encrypt couldn't fetch the challenge for gw-….eu.midplane.app through the relay`, as the gateway's last log line at its first start, with what Let's Encrypt said. Its check of the gateway's name didn't reach the gateway, as when the gateway lost its connections to the relay meanwhile. The gateway tries again at its next start, which Docker makes on its own under `--restart unless-stopped`, with no new token. ### An agent cannot connect at the Midplane URL The agent's client reports that the connection closed during the TLS handshake, after about 5 seconds or at once. The relay had no connection from the gateway to pass the agent's connection to: * For a gateway it knows, the relay waits up to 5 seconds for an idle connection from the gateway, then closes the agent's connection without an answer. The gateway is down, or isn't connected to the relay. * For a name no gateway holds, such as a revoked gateway's, it closes the connection at once. Nothing reaches the gateway, so the Query log shows nothing. On the **Gateways** page, the gateway should be enforcing, with **Relay: connected**. **Not responding** means it stopped or can't reach Midplane Cloud: check that it runs, and its log. A relay refusal has its heading above. Check too that the agent has the URL exactly as the **Gateways** page shows it. ## Connecting to a database **Test connection** on the **Gateways** page shows each database's id with `failed` and a code, such as `shop failed (28P01)`, and the gateway's log has the full message. From `0.22.0` the gateway keeps running and retries, and the **Gateways** page and the setup step say why beside the database: `shop · can't connect: password authentication failed (28P01)`. Earlier versions exit at start. ### Connection refused (ECONNREFUSED) Nothing listens at that host and port. In a container, `localhost` is the container itself. ### Host not found (ENOTFOUND) The host name doesn't resolve where the gateway runs, often a private name of another network. Run the gateway inside that network. ### Host not found (EAI\_AGAIN) The DNS server didn't answer for now. The gateway retries; check the host's DNS if it lasts. ### Connection timed out (ETIMEDOUT) A firewall or security group drops the connection, or the database is on another network. ### Connection timed out (TIMEOUT) Nothing answered within 10 seconds. Same causes as above, or a Neon compute waking up. ### Password authentication failed (28P01) Wrong user or password. Percent-encode special characters (`@` is `%40`). On Supabase's pooler, the user is `midplane_gateway.`. ### The server refused this role or host (28000) No `pg_hba.conf` entry allows this host and role, or the server requires TLS: add `sslmode=verify-full`. A role without `LOGIN` is refused the same way. ### Database does not exist (3D000) The database name at the end of the connection string is wrong. It's the Postgres name, not the id in Midplane. ### Permission denied (42501) The role lacks `CONNECT` or `USAGE`: run [the role SQL](/docs/prepare-database#create-a-role-for-the-gateway). ### Postgres is starting up or shutting down (57P03) The gateway tries again. ### Certificate not trusted (SELF\_SIGNED\_CERT\_IN\_CHAIN) The provider signs with its own CA. Name it with `sslrootcert` ([TLS](/docs/prepare-database#tls)). A self-signed certificate reports `DEPTH_ZERO_SELF_SIGNED_CERT`. ### Certificate is for another host (ERR\_TLS\_CERT\_ALTNAME\_INVALID) Connect by the host name the certificate names, not an IP address or an alias. ## On the dashboard ### No catalog yet No gateway has sent this database's tables. The line under it says which gateways list the id under `databases:`, and whether they reach the database. A gateway reads its config at start, so restart it after you change the config. If a gateway reaches the database and it still has no catalog, press **Refresh** on the policy page. ### No gateway serves it yet No gateway's config lists this database id. Add it under `databases:` in the config of a gateway that can reach the database, and restart that gateway ([adding databases](/docs/gateway/multiple-gateways#adding-databases)). ### No live gateway serves it Every gateway whose config lists this database id is **Not responding**: none has synced for three minutes. The line names each one. Check that they run, and their logs. ### In its config, not in this project The gateway's config lists a database id the project doesn't have: a database to add, or a typo. An owner or admin presses **Add** beside it to add the database with that id; the gateway then sends its tables. For a typo, correct the id in the config and restart the gateway. ### Not in its config The setup step's checklist shows this when the project has a database that this gateway's config doesn't list. Add it to the config if this gateway should serve it. When another gateway serves it, the line says so and there is nothing to do. ### Catalog read, differs from the one shown in the policy editor This gateway read other tables for the id than the policy editor shows, which are the last ones any gateway sent. After a schema change it passes once every gateway has read the database again, within a few minutes. If it stays, the gateways' connection strings for this id point at different databases. ### Not connected yet The gateway joined the project but hasn't synced with Midplane Cloud yet: it stopped after joining, or it can't reach the cloud. Start it, and read its log ([cannot reach Midplane Cloud](#cannot-reach-midplane-cloud)). ### Waiting for a policy The gateway connected but has no policy yet. If it stays, its log says why (`sync failed`). ### Halted The gateway got a policy it can't enforce, and refuses every call. The reason shows on the **Gateways** page: add `mask_salt` to its config, or upgrade the gateway. ### Refusing calls The project is paused. ### Not responding No sync for three minutes, or no answer to **Test connection**. Check that the gateway runs, and its log. ### Not registered A URL added to the config after enrollment works once an owner or admin presses **Register** on the **Gateways** page. ### Another gateway answers on it Another gateway holds that URL. Revoke it, or use another URL. ### It does not send its query log The gateway is older than 0.21. Upgrade it. ### Its audit log never reached Midplane Cloud The gateway's file lost events before they were sent. Check the file with [export and verify](/docs/audit#export-and-verify). ### Its audit log went backwards, or held two versions of one event The audit file was restored, copied or rewritten, or two processes share it. Give each linked gateway a file of its own ([one linked gateway per file](/docs/audit#the-file)). Nothing is lost: [backups](/docs/gateway/operations#backups). ## What an agent is told ### The audit log cannot be written, so nothing was run `The audit log can't be written, so nothing was run. Every statement is recorded before it runs.` The gateway couldn't record the statement before running it: the disk that holds `audit.file` is full or read-only. Free space on it, or make it writable ([the file](/docs/audit#the-file)).