Skip to main content

Configuration Overview

DBBat is configured via environment variables, an optional configuration file (YAML/JSON/TOML), or CLI flags.

Priority Order

Configuration is loaded in this priority order (highest wins):

  1. CLI flags
  2. Environment variables (DBB_…)
  3. Configuration file (--config, or DBB_CONFIG= env var)
  4. Built-in defaults

Environment Variables

Required

VariableDescription
DBB_DSNPostgreSQL DSN for DBBat's own storage (users, grants, queries, audit, …)

Listeners

VariableDescriptionDefault
DBB_LISTEN_PGPostgreSQL proxy listen address:5433
DBB_LISTEN_ORAOracle proxy listen address. Empty value disables the Oracle proxy.:1522
DBB_LISTEN_MYSQLMySQL/MariaDB proxy listen address. Empty value disables it.:3307
DBB_LISTEN_MONGOMongoDB proxy listen address. Empty value disables it.:27018
DBB_LISTEN_MSSQLMicrosoft SQL Server (TDS) proxy listen address. Empty value disables it.:1434
DBB_LISTEN_APIREST API + web UI listen address:4200

:1434 looks like it should collide with SQL Server, but it does not: the SQL Server Browser service that owns port 1434 is UDP-only, so 1434/tcp is free even on a host already running SQL Server.

Encryption Key

VariableDescriptionDefault
DBB_KEYBase64-encoded 32-byte AES-256 keyAuto-generated
DBB_KEYFILEPath to a file containing the encryption key-

If neither is set, DBBat generates a key on first start and writes it to ~/.dbbat/key (mode 0600, parent dir 0700). Losing this key means the encrypted database credentials cannot be recovered.

Run Mode & Logging

VariableDescriptionDefault
DBB_RUN_MODE`` (production), test, or demo``
DBB_LOG_LEVELdebug, info, warn, errorinfo
DBB_INSTANCE_IDIdentifies this process among the replicas sharing a storeHostname (the pod name under Kubernetes)
DBB_BASE_URLBase URL path the frontend is served under/app
DBB_REDIRECTSDev-only redirect rules (/path:host:port[/target], comma-separated)-
DBB_DEMO_TARGET_DBDemo-mode allowed target (user:pass@host/dbname)demo:demo@localhost/demo

DBB_INSTANCE_ID is stamped on every connection dbbat records, alongside a run id — a UUID the process mints in memory at every start, which is not configurable. Both identify the process in the instances registry, which is keyed by the pair: each run registers itself at startup, refreshes its last_seen_at every 30 seconds, and deletes its row on a clean shutdown. The run id is what makes the identity trustworthy — an instance id is unique per process only by convention, a run id by construction.

At startup, and before any proxy accepts, dbbat marks as disconnected every connection left open by a run that is no longer running — a crash or a SIGKILL never runs the normal teardown, so those rows would otherwise stay "open" forever and never become eligible for retention. Two kinds are closed:

  • Its own: connections left by a previous run carrying this instance id. Logged at info; a large number means an earlier run did not shut down cleanly.
  • Reclaimed: connections owned by any other run that is provably gone — it deleted its registry row on a clean shutdown, or has not heartbeated for 15 minutes (30 missed heartbeats). Logged separately, also at info: a non-zero count means some process died without shutting down.

Both kinds are liveness-checked, so neither closes anything a heartbeating run still owns. A run that crashed moments before this one started therefore looks alive at startup and is not reclaimed then — its rows are picked up by a later reclaim pass, once its registry row goes stale.

Sessions are closed at their last activity time, so retention still measures from when the session actually stopped talking.

The reclaim half does not only run at startup: every running process re-runs it roughly every 7.5 minutes (half the grace period, jittered so replicas do not all sweep at once). Without that, the commonest crash would go unnoticed for as long as the deployment stayed up — a SIGKILLed pod leaves a registry row seconds old, so its replacement sees a live-looking predecessor and reclaims nothing, and 15 minutes later, when the row finally goes stale, there is no restart left to look. That pass excludes the current run, not the current instance id, so it also reclaims a previous run of this same id — a stable id (a StatefulSet, or a pinned DBB_INSTANCE_ID) does not keep its own crashed run's rows open until the next restart. The own half stays at startup because that is the only moment its count means "the run I replaced".

Liveness, not identity, is what makes this safe when several replicas share one store: a starting replica must never close a live connection belonging to a different replica, because such a row immediately becomes eligible for the retention sweep. The grace period is deliberately generous — a running replica would have to fail every heartbeat for a quarter of an hour while still serving traffic before anything touched its sessions.

A plain Kubernetes Deployment, which mints a new pod name on every restart, is therefore handled as well as a StatefulSet or an explicit DBB_INSTANCE_ID: the replacement pod does not recognise its predecessor's id, but it can see that the predecessor stopped heartbeating.

tip

DBB_INSTANCE_ID should be unique per running process, though nothing breaks if it is not. Two live replicas sharing an id cannot close each other's sessions — the reconcile keys on the run id, which no configuration can make them share — but they do answer to one identity in the logs and in the UI, and the "left open by a previous run" count then covers every run of that id rather than the process reporting it. A process that detects a live peer under its own id logs a warning once, a heartbeat after it starts. The default (the hostname) is already unique; there is no reason to pin it.

Connections recorded before instance tracking existed carry an empty instance id; they have no owner and never will, so they are reclaimed the same way. Those recorded before run tracking carry no run id: they are judged by their instance id alone, which is the rule the build that wrote them was playing by, so a replica that is still serving them through an upgrade keeps them.

:::caution Upgrading from v0.20.x v0.20.x is the only released build that predates run tracking: its heartbeat upserts the registry row with ON CONFLICT (instance_id). Once a later build's migration changes the registry's primary key to (instance_id, run_id), that conflict target no longer exists, so a v0.20.x replica's heartbeats start failing and its row stops moving. After the 15-minute grace period above, a new-build replica treats it as dead and reclaims the connections it is still serving — and a reclaimed connection immediately becomes eligible for deletion by the retention sweep, even while the v0.20.x replica is still writing queries against it.

This only bites a multi-replica deployment (replicaCount > 1) where a v0.20.x replica keeps serving for more than 15 minutes after the migration runs — a single-replica deployment is never affected. Complete the upgrade from v0.20.x within 15 minutes, and do not roll back to v0.20.x once you have migrated. Nothing is broken by running the migration itself; this is a rollout-window caveat, not a live bug. :::

Session Packet Dumps

VariableDescriptionDefault
DBB_DUMP_DIRDirectory for .pcapng session captures. Empty = disabled.disabled
DBB_DUMP_MAX_SIZEMax dump file size per session, in bytes10485760 (10 MB)
DBB_DUMP_RETENTIONAuto-delete dumps older than this (Go duration). Local captures only.24h
DBB_DUMP_UPLOAD_URLBlob bucket finished captures are uploaded to on session close (s3://bucket/prefix, file://…). Empty = local disk only. Requires DBB_DUMP_DIR.disabled

See Session Packet Dumps for what gets captured.

Proxy TLS termination

Four of the five proxies terminate client TLS at the listener, and each has the same three knobs: *_TLS_DISABLE, *_TLS_CERT_FILE, *_TLS_KEY_FILE. (The Oracle listener is the exception — it has no TLS termination.)

The rule is the same everywhere: set both the cert and the key, or neither. Leaving both empty makes the proxy generate a self-signed RSA-2048 certificate in memory at startup — convenient for development, not something to run in production. Setting exactly one of the two is a configuration error and the proxy refuses to start.

PostgreSQL Proxy TLS

VariableDescriptionDefault
DBB_PG_TLS_DISABLEAnswer SSLRequest with N and stay plaintext-onlyfalse
DBB_PG_TLS_CERT_FILEPEM-encoded server certificateauto self-signed
DBB_PG_TLS_KEY_FILEPEM-encoded private keyauto-generated RSA-2048

Disabling TLS here is rarely what you want: a client with sslmode=prefer (the libpq default) silently falls back to plaintext rather than failing, so the credentials travel in the clear and nothing tells anyone.

MySQL Proxy TLS

VariableDescriptionDefault
DBB_MYSQL_TLS_DISABLERefuse SSLRequest packets and stay plaintext-onlyfalse
DBB_MYSQL_TLS_CERT_FILEPEM-encoded server certificateauto self-signed
DBB_MYSQL_TLS_KEY_FILEPEM-encoded RSA private key (RSA required for the non-TLS caching_sha2 public-key path)auto-generated RSA-2048

MongoDB Proxy TLS

VariableDescriptionDefault
DBB_MONGO_TLS_DISABLEKeep the listener plaintext — no TLS terminationfalse
DBB_MONGO_TLS_CERT_FILEPEM-encoded server certificateauto self-signed
DBB_MONGO_TLS_KEY_FILEPEM-encoded private keyauto-generated RSA-2048

MongoDB TLS is implicit from the first byte — there is no STARTTLS-style upgrade to negotiate — so the client decides by connecting with tls=true or without it. The listener peeks that first byte and serves both on the same port. SASL PLAIN authentication is only accepted over TLS, so DBB_MONGO_TLS_DISABLE=true also rules that mechanism out.

SQL Server Proxy TLS

VariableDescriptionDefault
DBB_MSSQL_TLS_DISABLEAnswer ENCRYPT_NOT_SUP and stay plaintext — this also refuses clients that require encryptionfalse
DBB_MSSQL_TLS_CERT_FILEPEM-encoded server certificateauto self-signed
DBB_MSSQL_TLS_KEY_FILEPEM-encoded private keyauto-generated RSA-2048
DBB_MSSQL_TLS_MAX_VERSIONCeiling for the client-leg handshake: 1.2 or 1.3. The floor is 1.2 either way.1.2

:::caution DBB_MSSQL_TLS_MAX_VERSION=1.3 is opt-in TDS carries the TLS handshake inside PRELOGIN packets, and under TLS 1.3 the client's handshake ends on a write — so a driver has to decide for itself whether that last flight is still encapsulated. dbbat handles both, but only go-mssqldb has been verified end to end; the Microsoft ODBC and JDBC drivers are untested at 1.3. The classic symptom of a driver guessing wrong is a client that connects and then hangs, not an error, so test your own driver before enabling this. Any value other than 1.2 or 1.3 fails the process at startup rather than falling back silently.

The full explanation is in the SQL Server protocol notes. :::

Note that a SQL Server client connecting with Encrypt=no still performs a complete TLS handshake — TLS then covers the LOGIN7 packet and nothing else, and both ends revert to cleartext TDS once login is through.

Query Result Storage

VariableDescriptionDefault
DBB_QUERY_STORAGE_STORE_RESULTSGlobally enable result-row capturetrue
DBB_QUERY_STORAGE_MAX_RESULT_ROWSMax rows captured per query100000
DBB_QUERY_STORAGE_MAX_RESULT_BYTESMax bytes captured per query104857600 (100 MB)
DBB_QUERY_STORAGE_RETENTIONAuto-delete query history and its captured rows past this Go duration. 0 keeps everything forever.0 (recommended: 720h)
DBB_CONNECTION_RETENTIONAuto-delete closed connections — the session ledger — once they disconnected longer ago than this. Unset inherits the query window above; 0 keeps sessions forever. Must be ≥ the query window.Inherits DBB_QUERY_STORAGE_RETENTION

Retention is opt-in: upgrading dbbat never starts deleting audit history on its own. The per-query caps above bound one query's capture; retention bounds the accumulation of every query ever proxied.

There are two windows because the two things expire on different schedules. Statements and their captured rows are the bulk of the store and can hold customer data, so an operator wants them gone after 30 or 90 days. A connection is one small row per session — who, from where, to which database, under which grant — and that ledger is what a security review asks for a year later. DBB_CONNECTION_RETENTION=8760h with DBB_QUERY_STORAGE_RETENTION=720h keeps a year of sessions and a month of statements; DBB_CONNECTION_RETENTION=0 keeps the ledger forever.

The connection window can never be shorter than the query window, because deleting a session deletes its statements — that would expire history earlier than configured. A shorter one, a malformed value on either side, or a non-zero connection window while queries are kept forever is a misconfiguration that disables both sweeps with a startup warning naming both values. Nothing is deleted and the server still starts. See Query Logging for exactly what a sweep removes.

Per-statement time limits

VariableDescriptionDefault
DBB_STATEMENT_TIMEOUTHow long any single statement may run before dbbat cancels it and ends the session, as a Go duration (30s, 5m). Empty or 0 = no limit.`` (no limit)

This is the deployment default, the lowest of three layers:

  1. a grant definition's statement_timeout_seconds wins over everything — including an explicit 0, which means "no limit for this definition" and is how a dump or ETL definition stays usable;
  2. otherwise the limits.statement_timeout global parameter, editable from the Settings page without a restart;
  3. otherwise this variable.

Like retention, it is opt-in: upgrading dbbat never starts cancelling statements on its own. A malformed value disables the limit with a startup warning rather than shortening it — this setting kills live database sessions, so a typo must never be read as "kill sooner".

Enforcement is dbbat's own watchdog, not the database's. Where a protocol has a server-side knob (PostgreSQL's statement_timeout, MySQL's max_execution_time, MongoDB's maxTimeMS) dbbat sets it too, so the client gets a real database error instead of a dropped socket — but Oracle and SQL Server have no such knob, and a client can try to unset the ones that exist, so the watchdog is what the limit actually rests on. It kills within about 2.25 seconds of the limit and cancels the statement upstream before closing the sockets. See Access Control.

Statement tagging (optional)

VariableDescriptionDefault
DBB_QUERY_TAGGINGTag every statement forwarded to the target with the dbbat identity — a comment on PostgreSQL and MySQL, the comment command field on MongoDBfalse
DBB_QUERY_TAGGING_ORACLEOracle's own switch: off, or user for a tag carrying the version, the user and the grant and no conn=off

Every dbbat session logs in to the target as the same shared database role, from the same host — the proxy. So the target's own tooling attributes the whole fleet's load to one client: RDS Performance Insights shows one user and one host, pg_stat_statements has no application_name dimension at all, and the slow query log prints statement text and nothing else.

What all of them do show is the statement itself. Turn this on and dbbat prepends a sqlcommenter-style comment:

/*dbbat='0.28.1',user='florent',conn='3f9a1c7b2e4d',grant='diag-paris-habitat'*/ SELECT ...
  • dbbat — the dbbat version that forwarded the statement
  • user — the dbbat user, not the shared database role
  • conn — the last 12 hex characters of the dbbat connection uid, the same tag the upstream application_name / program_name carries. Paste it into the connections page search box, or call GET /api/v1/connections?uid_suffix=3f9a1c7b2e4d
  • grant — the slug of the grant definition the session is running under

MongoDB gets the same tag, in the place MongoDB has for it. There is no statement text to comment, so the identity rides in the command's comment field — the one system.profile, the Atlas Query Profiler and db.currentOp() echo back:

{ find: "widgets", filter: {}, comment: "dbbat='0.28.1',user='florent',conn='3f9a1c7b2e4d',grant='diag-paris-habitat'" }

It is the same string, so one search finds a session whatever the protocol. Two MongoDB-specific rules: it is applied to the commands whose comment support MongoDB documents (find, aggregate, count, distinct, insert, update, delete, findAndModify, getMore, mapReduce, bulkWrite) and to no others, and a client-supplied comment wins — that command is forwarded untouched, because the field is single-valued and a driver's or ORM's own tracing may already own it. Such a command is still attributable: the profiler records appName, which dbbat tags on every session. See the MongoDB notes.

Oracle has its own variable, DBB_QUERY_TAGGING_ORACLE, and DBB_QUERY_TAGGING deliberately does not reach it. V$SQL keys on statement text, so every distinct tag is a distinct SQL_ID holding its own shared-pool cursor — the tag buys attribution by spending shared pool, and that trade-off is Oracle's alone. Measured on Oracle 23ai with one join executed 600 times: the conn= tag spread over 200 sessions cost 200 cursors and 9.6 MB, growing with every session opened. Dropping conn= bounds it by the number of dbbat users instead — 20 identities cost 20 cursors, one hard parse each and ~48 KB apiece, then plateau, with a second 600 executions adding nothing at all. So the only non-off value is user:

/*dbbat='0.28.1',user='florent',grant='diag-paris-habitat'*/ SELECT ...

Turning it on does not oblige the proxy to tag. Unlike the three protocols above, Oracle relays the client's own TNS packets, so a statement can only carry the tag when dbbat can relocate it in the frame to the byte and re-encode that frame back to the client's own bytes. A session whose client shape it cannot certify runs untagged from start to finish and logs why — deliberately all-or-nothing per session, because a statement tagged on some executions and not others would get two SQL_IDs and double the cursor count the whole design is about. Anything other than off or user fails the process at startup. The numbers and the encoding details are in the Oracle notes. SQL Server is a follow-up.

Off by default, because it changes the bytes the database receives — a deployment that pins statement text (a pg_stat_statements allowlist, a query firewall, a per-statement plan cache) should turn it on knowingly. It is also the one setting here that makes a statement ~90 bytes longer, so a MySQL statement that was already within ~90 bytes of max_allowed_packet will start being rejected.

The tag carries no timestamp and no per-statement id, on purpose: two executions of the same statement stay byte-identical, so pg_stat_statements and the MySQL digest keep aggregating them into one row instead of one row per execution.

It changes nothing dbbat stores or enforces. Every grant control (read_only, block_ddl, block_copy), every bypass scan and every approval-hold pattern runs on the statement — or, on MongoDB, the command — the client sent, before the tag exists — so a pattern author never has to account for it. The queries table, the tamper-evident audit chain, the UI's query-text search and the .pcapng session captures all hold the client's text too. The tag is a pure function of (version, user, connection, grant), all of which the connection row already stores, so it is reconstructible without being persisted.

One known limit, not fixed on purpose. pg_stat_statements keeps the text of the first execution of a digest, so two dbbat users running the same statement share a row whose tag names whichever of them ran it first. The numbers stay correct; the label is misleading. Performance Insights has the same property per digest, as does MySQL's QUERY_SAMPLE_TEXT. Making the digest per-user would mean varying the tag per user, which stops aggregation altogether — a worse outcome. pg_stat_activity, events_statements_current and the slow logs are exact.

Rate Limiting

VariableDescriptionDefault
DBB_RATE_LIMIT_ENABLEDEnable per-user/IP rate limitingtrue
DBB_RATE_LIMIT_REQUESTS_PER_MINUTERequests per minute per authenticated user60
DBB_RATE_LIMIT_REQUESTS_PER_MINUTE_ANONRequests per minute per source IP (unauthenticated)10
DBB_RATE_LIMIT_BURSTShort-burst tolerance10

Password Hashing (Argon2id)

VariableDescriptionDefault
DBB_HASH_PRESETOne of default, low, minimaldefault
DBB_HASH_MEMORY_MBMemory cost (1–1024 MB)64
DBB_HASH_TIMETime cost (1–10)1
DBB_HASH_THREADSParallelism (1–16)4

Auth Cache

VariableDescriptionDefault
DBB_AUTH_CACHE_ENABLEDCache auth results across REST + proxiestrue
DBB_AUTH_CACHE_TTL_SECONDSCache entry TTL300
DBB_AUTH_CACHE_MAX_SIZEMaximum cache entries10000

Single sign-on / OIDC (optional)

The generic OpenID Connect provider signs users in with your own identity provider — Google Workspace, Okta, Microsoft Entra, Keycloak, Authentik. Every sign-in is carried by an ID token DBBat verifies against the issuer's JWKS, and the code flow always uses PKCE (S256).

VariableDescription
DBB_OIDC_ISSUERIssuer URL. Setting it enables the provider
DBB_OIDC_CLIENT_IDClient ID (required once the issuer is set)
DBB_OIDC_CLIENT_SECRETClient secret (required once the issuer is set)
DBB_OIDC_SCOPESScopes to request (default openid email profile)
DBB_OIDC_DISPLAY_NAMELogin-button label (default SSO)
DBB_OIDC_EMAIL_DOMAINSOptional comma-separated allowlist checked against the verified email claim
DBB_OIDC_GROUPS_CLAIMID-token claim carrying directory group membership (default groups)
DBB_OIDC_ROLE_MAPPINGBinds roles to directory groups, e.g. admin=db-admins,viewer=analysts. Applied on every login

See Single sign-on (OIDC) for the redirect URI, per-provider setup snippets, and what it takes to make each IdP emit groups in the first place.

Slack OAuth (optional)

VariableDescription
DBB_SLACK_AUTH_CLIENT_IDSlack app client ID
DBB_SLACK_AUTH_CLIENT_SECRETSlack app client secret
DBB_SLACK_AUTH_TEAM_IDRestrict sign-in to one workspace

User auto-provisioning (all login providers)

These two apply to every OAuth/OIDC provider — Slack, the generic OIDC issuer, and anything added later.

VariableDescription
DBB_AUTH_AUTO_CREATE_USERSLet a verified identity with no local account provision one on first login (default true)
DBB_AUTH_DEFAULT_ROLERole such an account starts with, and the floor DBB_OIDC_ROLE_MAPPING never digs below (default connector). Must name a real role — admin, viewer or connector — or DBBat refuses to start

Per-provider overrides

One knob for every provider is the right default, and not enough when two providers carry different levels of trust: a tightly-gated Entra tenant where auto-provisioning is exactly what you want, next to a Slack workspace that also contains contractors and should only admit accounts an admin created by hand. Append the provider's name to either variable to override it for that provider alone:

VariableDescription
DBB_AUTH_AUTO_CREATE_USERS_<PROVIDER>Overrides DBB_AUTH_AUTO_CREATE_USERS for one provider
DBB_AUTH_DEFAULT_ROLE_<PROVIDER>Overrides DBB_AUTH_DEFAULT_ROLE for one provider

<PROVIDER> is the provider's key: SLACK or OIDC. Each setting resolves per-provider first, then instance-wide, then the built-in default — so the example above is two variables:

DBB_AUTH_AUTO_CREATE_USERS=true # the OIDC issuer may mint accounts
DBB_AUTH_AUTO_CREATE_USERS_SLACK=false # Slack may not
DBB_AUTH_DEFAULT_ROLE_OIDC=viewer # and its accounts start as viewers

The same thing in a config file, where the overrides are ordinary nested keys:

auth:
auto_create_users: true
providers:
slack:
auto_create_users: false
oidc:
default_role: "viewer"

Two failure modes are startup errors rather than an override that quietly does nothing, since both would leave the deployment with the policy it was trying to change: a per-provider role goes through the same exact-match check as the instance-wide one, and an unknown provider name — DBB_AUTH_AUTO_CREATE_USERS_OKTA, say — is refused outright.

Turning auto-provisioning off for a provider gates account creation, not sign-in: an account an admin created by hand still logs in through it, which is the entire point of the setting.

The legacy DBB_SLACK_AUTH_* names below stay instance-wide. They are aliases for the pair above, not per-provider settings — use DBB_AUTH_AUTO_CREATE_USERS_SLACK to gate Slack specifically.

They used to be called DBB_SLACK_AUTH_AUTO_CREATE_USERS and DBB_SLACK_AUTH_DEFAULT_ROLE, back when Slack was the only login provider. Those names are still accepted, so nothing breaks on upgrade; the DBB_AUTH_* setting wins whenever both are present, whichever source each came from — an auth.default_role in your config file beats a DBB_SLACK_AUTH_DEFAULT_ROLE left over in the environment, and vice versa.

The role name is matched exactly. DBB_AUTH_DEFAULT_ROLE=Admin is a startup error naming the spelling it wants, not a silent admin: before these settings moved, the value was read raw and never checked, so a mis-cased one matched no role and granted nothing at all. Folding it now would hand every auto-provisioned user of that deployment real admin rights on an upgrade alone, which is not a change an upgrade gets to make quietly.

Slack notifications & interactivity (optional)

When configured, DBBat posts each grant request to a Slack channel and updates that message as the request is decided.

:::note Auto-approved requests Grant definitions can be flagged auto_approve. A request matching such a definition is approved instantly at request time — there is no admin decision to make, so its Slack notification carries no Approve/Deny buttons, whether or not a signing secret or app token is configured. A justification is still required, and the approval gets its own audit trail tagged via: auto_approve (as opposed to via: slack or a web-UI decision). :::

VariableDescription
DBB_SLACK_NOTIFY_BOT_TOKENBot user OAuth token (xoxb-…). Empty disables notifications.
DBB_SLACK_NOTIFY_CHANNELChannel id or #name to post to (default #dbbat). Required when the bot token is set.
DBB_PUBLIC_URLExternally reachable base URL, used for the "Review in dbbat" deep-link. Required when the bot token is set, unless the public.web_ui_url parameter is set (see Global Parameters) — that parameter takes precedence when both are present.
DBB_SLACK_SIGNING_SECRETApp signing secret. When set, notification messages carry ✅ Approve / ❌ Deny buttons and DBBat serves POST /api/v1/slack/interactions to receive clicks. Empty keeps the link-through-UI flow (no buttons, no inbound endpoint). Requires the bot token — setting it without one fails at startup. The legacy name DBB_SLACK_NOTIFY_SIGNING_SECRET is also accepted as an alias; if both are set, the canonical DBB_SLACK_SIGNING_SECRET wins.
DBB_SLACK_NOTIFY_APP_TOKENApp-level token (xapp-…, scope connections:write). When set, DBBat opens an outbound Socket Mode connection and receives Approve/Deny clicks over it — no inbound reachability or signing secret needed. Renders the buttons on its own. Requires the bot token — setting it without one fails at startup.

Choosing a deployment shape

Your deploymentConfigureHow Approve/Deny clicks arrive
Publicly reachable — Slack's servers can reach DBB_PUBLIC_URLBot token + DBB_SLACK_SIGNING_SECRETInbound POST /api/v1/slack/interactions, authenticated by Slack's request signature
Gated — VPN, intranet, or an ingress that allowlists source IPsBot token + DBB_SLACK_NOTIFY_APP_TOKENOutbound Socket Mode WebSocket — no inbound reachability needed
Neither — notifications onlyBot token onlyNo buttons: messages carry the "Review in dbbat" deep-link and admins decide in the web UI

"Publicly reachable" means reachable by Slack's servers, not just by your users' browsers. A curl from your laptop proving the endpoint answers is not enough: if the load balancer in front of DBBat allowlists inbound source IPs (a common webhook-hardening pattern), Slack's delivery is dropped at the network boundary — clicks fail with "Operation timed out" after 3 seconds and DBBat never sees the request. That is a gated deployment: use Socket Mode. (Allowlisting Slack instead is impractical — Slack does not publish a small stable set of interactivity source IPs.) At startup, DBBat logs a reminder when interactivity is configured with the HTTP endpoint as its only transport.

Enabling the Approve / Deny buttons

  1. In your Slack app, enable Interactivity & Shortcuts and set the request URL to https://<YOUR_DBBAT_HOST>/api/v1/slack/interactions (see slack_app_manifest.json).
  2. Copy the app's Signing Secret from the Basic Information page into DBB_SLACK_SIGNING_SECRET.

Clicks are authenticated by Slack's request signature. Only DBBat admins can approve or deny; anyone else who clicks gets an ephemeral error. A decision made from a button is identical to one made in the web UI (same audit event, tagged via: slack), updates the original message in place (removing the buttons), and posts a reply in the message thread.

Requests matching an auto_approve grant definition never reach this flow: they are already approved when the message is posted, so it carries no buttons and is never "decided". Their audit event is tagged via: auto_approve.

Deployment note: button clicks require Slack's servers to reach DBB_PUBLIC_URL (inbound), whereas the deep-link only needs users' browsers to reach it. Intranet-only deployments that can't accept inbound Slack traffic should use Socket Mode (below) instead of the signing secret — or leave both unset and keep the link-through-UI flow.

Socket Mode (no inbound endpoint)

For deployments Slack can't reach inbound (behind a VPN or an IP-allowlisted ingress), Socket Mode delivers Approve/Deny clicks over an outbound WebSocket that DBBat opens to Slack — so no public reachability and no signing secret are required.

  1. In your Slack app, open Settings → Socket Mode and enable it.
  2. Under Basic Information → App-Level Tokens, generate a token with the connections:write scope and put it in DBB_SLACK_NOTIFY_APP_TOKEN.

Everything downstream (admin-only decisions, ephemeral errors, via: slack audit tagging, in-place message update, thread reply) is identical to the HTTP path — only the transport differs. Socket Mode and the HTTP endpoint can both be configured; at the Slack app level, enabling Socket Mode makes Slack deliver over the socket and ignore the request URL.

Session termination notifications (optional)

When a bot token is configured, DBBat also posts to DBB_SLACK_NOTIFY_CHANNEL whenever it ends a session on its own for a reason worth a human's attention:

ReasonPosted
statement_timeoutyes
admin_terminatedyes — names the admin and their reason
quota_exceededyes
grant_revokedno — already went through a human
grant_expiredno — routine
instance_lostno — covered by infrastructure alerting elsewhere

The message names the user (@-mentioned when they have a linked Slack identity), the grant, the reason, and — for a statement timeout — the limit and how long the statement actually ran. It carries no buttons: there is no decision left to make, only something to know happened.

VariableDescription
DBB_SLACK_NOTIFY_TERMINATIONSEnable termination notifications. Default true; only meaningful when DBB_SLACK_NOTIFY_BOT_TOKEN is set.
DBB_SLACK_NOTIFY_SQLInclude the (truncated, 200-character) statement text that was running when dbbat acted. Default true — mirrors DBB_APPROVAL_SLACK_SQL for approval-hold escalations, since Slack is a lower trust boundary than the DBBat UI.

A client stuck in a reconnect loop that trips the same limit over and over would otherwise flood the channel with identical messages. DBBat coalesces: the first termination for a given (user, database, reason) posts immediately, and any further one within a 10-minute window is folded into a single follow-up ("+7 more in the last 10 min") posted once the window closes.

Configuration File

DBBat supports YAML, JSON, and TOML configuration files.

YAML Example

listen_pg: ":5433"
listen_ora: ":1522"
listen_mysql: ":3307"
listen_mongo: ":27018"
listen_mssql: ":1434"
listen_api: ":4200"
dsn: "postgres://user:pass@localhost:5432/dbbat?sslmode=require"

query_storage:
store_results: true
max_result_rows: 100000
max_result_bytes: 104857600
retention: "0" # keep forever; e.g. "720h" for 30 days

query_tagging:
enabled: false # prepend /*dbbat=...,user=...,conn=...,grant=...*/ upstream

rate_limit:
enabled: true
requests_per_minute: 60
burst: 10

dump:
dir: "/var/dbbat/dumps"
max_size: 33554432
retention: "72h"

pg:
tls:
disable: false
cert_file: "/etc/dbbat/pg.crt"
key_file: "/etc/dbbat/pg.key"

mysql:
tls:
disable: false
cert_file: "/etc/dbbat/mysql.crt"
key_file: "/etc/dbbat/mysql.key"

mongo:
tls:
disable: false
cert_file: "/etc/dbbat/mongo.crt"
key_file: "/etc/dbbat/mongo.key"

mssql:
tls:
disable: false
cert_file: "/etc/dbbat/mssql.crt"
key_file: "/etc/dbbat/mssql.key"
tls_max_version: "1.2" # "1.3" is opt-in; see above

slack_auth:
client_id: "..."
client_secret: "..."

auth:
auto_create_users: true
default_role: "connector"
providers: # optional per-provider overrides
slack:
auto_create_users: false

slack_notify:
bot_token: "xoxb-..."
channel: "#dbbat"
signing_secret: "..." # Approve/Deny buttons via the inbound HTTP endpoint
# app_token: "xapp-..." # or via Socket Mode (outbound; for gated deployments)

public_url: "https://dbbat.example.com"

Load with the --config flag:

dbbat serve --config /etc/dbbat/config.yaml

Global Parameters

Since v0.16.0, some settings live in the database rather than in the environment, so an operator can change them at runtime without a restart. They are managed through GET, PUT, and DELETE on /api/v1/parameters, and the effective values are exposed by GET /api/v1/instance.

ParameterDescription
public.web_ui_urlExternally reachable base URL of the web UI, used for Slack deep-links. Takes precedence over DBB_PUBLIC_URL when set.
limits.statement_timeoutInstance-wide per-statement time limit, as a Go duration (30s, 5m). Takes precedence over DBB_STATEMENT_TIMEOUT when set; "0" disables the limit outright. Edited from the Settings page, or through PUT /api/v1/instance/limits.
# Read the current parameters
curl -H "Authorization: Bearer $DBBAT_API_KEY" http://localhost:4200/api/v1/parameters

# Set the public web UI URL
curl -X PUT http://localhost:4200/api/v1/parameters \
-H "Authorization: Bearer $DBBAT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"public.web_ui_url": "https://dbbat.example.com"}'

Deleting the parameter falls back to DBB_PUBLIC_URL.

Generating an Encryption Key

DBBat requires a 32-byte AES-256 key for encrypting database credentials. If neither DBB_KEY nor DBB_KEYFILE is set, DBBat generates one at ~/.dbbat/key and reuses it on subsequent starts.

To generate one yourself:

openssl rand -base64 32

Use it as DBB_KEY=… or write it to a file referenced by DBB_KEYFILE=.

Storage Database

DBBat stores its configuration and logs in a PostgreSQL database. Provide the DSN via DBB_DSN.

DSN Format

postgres://user:password@host:port/database?sslmode=require

SSL Modes

  • disable — No SSL
  • require — Require SSL but don't verify certificate
  • verify-ca — Require SSL and verify CA
  • verify-full — Require SSL and verify CA + hostname

:::warning Security DBBat warns at startup if any configured target database matches the storage DSN — sharing a database for storage and proxying enables privilege escalation. Use a separate database (or a separate cluster) for DBBat's own storage. :::

Run Modes

Test Mode (DBB_RUN_MODE=test)

Useful for E2E testing and development:

  • Wipes all DBBat-owned tables on startup
  • Recreates admin with password admintest (already password-changed)
  • Creates viewer (role viewer) and connector (role connector) users
  • Creates a sample target database, plus stable API keys (dbb_admin_key, dbb_viewer_key, dbb_connector_key)

Demo Mode (DBB_RUN_MODE=demo)

For public demos with restricted database targets:

  • Wipes all DBBat-owned tables on startup
  • Creates admin/viewer/connector users with their username as the password
  • Only allows database configurations matching DBB_DEMO_TARGET_DB
  • Defaults to demo:demo@localhost/demo

Default Admin

On first startup (in production mode), DBBat creates a default admin user:

  • Username: admin
  • Password: admin

The password is flagged as requiring change. Login attempts return 403 password_change_required until the admin calls PUT /api/v1/auth/password to set a real password.