Runbooks
Run pre-approved, Git-reviewed statements with typed parameters, and make production connections reachable only through them.
A runbook is a pre-approved SQL statement or MongoDB operation kept in a Git repository. Relay syncs the repository, binds each runbook to the connections it may run on, and lets members run it with typed parameters. Combined with a runbook-only connection, production can be reached only through statements that went through code review, while staging stays free-form.
Runbooks are rolling out gradually and are included in the Business and
Enterprise plans. In the Agent Edition the license must include the
runbooks feature. Runbooks run on PostgreSQL, MySQL, SQL Server and
MongoDB connections; see Engine support.
Writing a runbook
Every .sql file under the configured directory is a runbook. It starts with a
YAML header written as -- comment lines between two -- --- fences, followed
by exactly one statement:
-- ---
-- name: find_customer
-- description: Look up one customer by id
-- engine: postgres
-- connections: ["prod-*", "staging"]
-- mode: read
-- timeout: 10
-- max_rows: 100
-- params:
-- - name: customer_id
-- type: int
-- description: Customer id
-- - name: status
-- enum: [active, closed]
-- default: active
-- ---
SELECT id, email FROM customers WHERE id = $1 AND status = $2| Key | Meaning |
|---|---|
name | Lowercase letters, digits, ., _, -. Defaults to the file name. |
description | Shown in the catalog. |
engine | Optional. A runbook for one engine is refused on connections of another. |
connections | Required. Globs on connection names (prod-*). |
mode | read (default) cannot change data (see Engine support); write allows changes. |
timeout | Statement timeout in seconds (default 30, max 300). |
max_rows | Result cap (default 500, max 5000). Results are also capped at 1 MiB. |
params | Typed parameters: string, int, float, bool, date (YYYY-MM-DD), timestamp (RFC 3339), uuid. A parameter with a default is optional; enum restricts the values. |
Parameters bind to the statement's placeholders in the order they are declared.
A file runs exactly one statement: SELECT 1; DROP TABLE x fails. Every
declared parameter must have a placeholder, and the other way around.
Engine support
MongoDB runbooks use a different, declarative file format; see MongoDB runbooks. The table below covers the SQL engines.
| PostgreSQL | MySQL | SQL Server | |
|---|---|---|---|
| Placeholders | $1, $2, … | ? | @p1, @p2, … |
| How values reach the database | Bound values (extended protocol) | Typed literals, see below | Typed sp_executesql parameters |
mode: read | Read-only transaction; writes fail | Read-only session; writes and DDL fail | Runs in a transaction that is always rolled back |
timeout | statement_timeout | max_execution_time (max_statement_time on MariaDB) | Lock timeout plus an agent deadline |
| Affected rows | Command tag | ROW_COUNT() | @@ROWCOUNT |
On PostgreSQL and SQL Server, values are sent as bound values and are never spliced into the statement text, so a parameter cannot change what the statement does.
On MySQL, prepared statements are refused when
masking is on, so the agent sends the statement as text and
keeps masking, policy and audit on exactly what runs. It encodes each parameter
from its declared type, not from the raw text: numbers and booleans as digits,
and strings, dates, timestamps and UUIDs as hexadecimal literals. A value
therefore cannot contain a quote, backslash, comment marker or placeholder, and
the connection's sql_mode (NO_BACKSLASH_ESCAPES, ANSI_QUOTES) cannot change
how it is read. Invalid UTF-8, non-finite numbers and values that do not parse as
their type are rejected before anything is sent.
SQL Server has no read-only session mode. A read runbook runs inside a
transaction that is rolled back at the end, so its writes are undone rather
than refused, and effects that a transaction cannot undo are not covered.
Give the connection's database login only the permissions its runbooks need.
MongoDB runbooks
MongoDB runbooks are declarative: a *.mongo.yaml, *.mongo.yml or
*.mongo.json file describes one operation on one collection. They do not
contain JavaScript. Relay does not run scripts: the agent is a wire-protocol
proxy, and a script could build commands that nobody reviewed. A declarative
file maps to exactly one database command, which is what masking, policies and
audit see and what the Git reviewer reads. A .js file in the runbook directory
is rejected at sync with an error naming the file.
name: recent_orders
description: Orders of one customer above a minimum total
connections: ["orders-*"]
mode: read
timeout: 10
max_rows: 100
params:
- name: customer
type: objectid
- name: min_total
type: float
default: "0"
database: shop
collection: orders
operation: find
filter:
customer_id: {$param: customer}
total: {$gte: {$param: min_total}}
sort: {created_at: -1}
projection: {items: 0}The header keys are the same as for SQL runbooks. engine is optional and must
be mongodb. In addition:
| Key | Meaning |
|---|---|
database | Optional. Defaults to the connection's database. |
collection | Required. system.* collections are refused. |
operation | find, aggregate, count, distinct (read) or insertOne, updateOne, updateMany, deleteOne, deleteMany (need mode: write). |
filter | Query document. Required and non-empty for update and delete operations. |
sort, projection | find only. |
pipeline | Required for aggregate: a list of stages. |
field | Required for distinct. |
document | Required for insertOne. |
update | Required for updateOne and updateMany. Only update operators ($set, $inc, ...); a replacement document is refused. |
Place a parameter anywhere a value goes with {$param: name}. Parameters are
typed and sent as BSON values: int becomes a 64-bit integer, float a double,
bool a boolean, date and timestamp a BSON date, uuid a binary UUID,
objectid an ObjectId (a type available to MongoDB runbooks only) and string
a string. A parameter is never parsed as JSON and never becomes a key or an
operator, so a value such as {"$ne": null} is just a string. An optional
parameter without a value is sent as null. In an aggregate runbook a string
parameter that starts with $ is refused, because a pipeline would read it as a
field path.
These are refused when the file is synced: $where, $function and
$accumulator (they run JavaScript on the server), $out and $merge, and
operations that change data without mode: write. In read mode only find,
aggregate, count and distinct are accepted, and the agent enforces this
again before running.
The agent runs the operation through its own MongoDB proxy as the person who
requested it, so masking, policies and
query audit apply as they do to a tunnel. It sets
maxTimeMS from timeout, and fetches at most max_rows documents in a single
batch (and 1 MiB); a larger result is cut and marked truncated. Each document
is one result row, as relaxed Extended JSON in a document column. count
returns a count column and distinct a value column. A write reports the
number of documents matched, inserted or deleted, or modified for updates, in
the command tag.
Connecting the repository
In Runbooks → Source, set the clone URL, the branch (default main) and an
optional directory. Choose a poll interval (1 minute to 24 hours) or sync
manually with Sync now or relay runbook sync. A source authenticates in one
of two ways.
HTTPS with a token. Set an access token with read access (a GitHub or GitLab token or deploy token). The token is stored encrypted and is never shown again.
SSH with a deploy key. Use an ssh://git@host/org/repo.git or
git@host:org/repo.git URL. Relay generates an Ed25519 key pair when you save:
the private key is stored encrypted and never leaves the server, and the source
shows the public key for you to add to the repository as a read-only deploy
key. Tick Generate a new deploy key to rotate it.
An SSH source must pin the server's host key, and Relay refuses any server that
presents another one. Select Fetch host key: Relay connects once and shows
the key type and its fingerprint. Compare the fingerprint with the one your Git
provider publishes, then pin it. You can also paste a public key line
(ssh-ed25519 AAAA...) or a SHA256: fingerprint. The CLI shows the same with
relay runbook source host-key <ssh-url>.
Relay resolves the repository host itself and refuses loopback, link-local and cloud metadata addresses, and private ranges outside the Agent Edition, for both HTTPS and SSH sources.
A file with an error is skipped and listed in the sync result; it keeps its last good version. Deleting a file retires the runbook.
Sync on push
Instead of waiting for the poll interval, let your Git host tell Relay about each push. On the source, open Push webhook and select Generate secret. Copy the secret now, it is stored encrypted and shown only once, and add a webhook to the repository that posts to the payload URL:
| Provider | Setup |
|---|---|
| GitHub | Content type application/json, the secret, the push event. |
| GitLab | The secret in Secret token, the Push events trigger. |
| Anything else | POST JSON with "ref": "refs/heads/main" and an X-Relay-Signature-256: sha256=<hex> header, the hex HMAC-SHA256 of the raw body using the secret. |
Relay verifies the signature and answers 401 otherwise. Only pushes to the
source's configured branch start a sync; other branches, tags and branch
deletions are ignored. Syncs are debounced to one every few seconds, and a push
inside the window is still picked up by a trailing sync, so the latest push is
always covered. The webhook carries no content: it only makes Relay fetch the
branch, exactly as Sync now does. Rotate secret replaces the secret and
Turn off disables the endpoint.
Versions
Each version is identified by the hash of its file, and records the commit it first appeared in. When you run a runbook, the run is pinned to the version you reviewed: if the file changed since you listed it, the run is refused until you review the new version.
Running a runbook
From the dashboard, open a runbook, fill in the generated form, pick a connection and run it. From the CLI:
relay runbook list -c pg-prod
relay runbook show find_customer
relay runbook run find_customer -c pg-prod --param customer_id=42
relay runbook history find_customerYou need access to the connection, exactly as for a tunnel (an
access grant unless you are an admin), and the
org.runbooks.run permission. The statement runs on an
agent that serves the connection, with the connection's own
database login. Masking, policies and
query audit apply exactly as they do to a tunnel.
Result rows are visible to the person who ran the runbook and to runbook managers. The execution history shows everyone's runs without their rows.
Running from automation
An API key can run runbooks, for example from CI. The key
needs the org.runbooks.run permission through its role and its workspace, like
any member. The run then executes as the user who created the key: that
user's connection access, masking and
policies decide what the run can reach and see, so a key can
never reach a connection its creator cannot. Keys created by another key, and
keys whose creator was removed, cannot run runbooks. Audit-export keys never can.
To give automation a narrow identity, create a dedicated user, grant it only the connections its runbooks need, and create the key from that user's session.
Runs are attributed to the key in the activity log (api-key:<name>, with the
creator as on_behalf_of) and on the execution (api_key). In the query audit,
the requesting user is the creator and the execution id leads back to the key.
export RELAY_TOKEN=... # the API key
relay runbook run find_customer -c pg-prod -p customer_id=42 --jsonRunbook-only connections
Turn on Runbook-only on a connection to refuse tunnels to it for everyone except organization admins and owners. Open tunnels of other users end at the agent's next session report. Runbooks keep working on the connection.
Permissions
| Permission | Default roles | Allows |
|---|---|---|
org.runbooks.read | Admin, Member, Viewer | See runbooks, the source and the execution history |
org.runbooks.run | Admin, Member | Run runbooks, also with an API key |
org.runbooks.manage | Admin | Configure and sync the source, set runbook-only |
Audit
Every run is recorded twice:
- In the activity log, as
runon the runbook, with the connection, commit, version and parameters. - In the query audit, as a
runbook_executionevent from the agent: the statement with its placeholders, the requesting user, the outcome (succeededorfailed) indecision,runbook:<name>@<commit>inrule_id, and a JSONreasonwith the execution id, commit, version, mode, parameters, row counts, truncation, error and duration. The statement's own query events share its connection id.