Configuration
Every QueryProxy environment variable — limits, timeouts, TLS, secrets, SCIM, Slack, schema explorer, metrics, SIEM export, proxies, database, queue.
Updated:
All configuration is environment variables (.env locally, container
environment in Docker). .env.example in the repository documents the full
set; this page explains the ones you’ll actually change.
QueryProxy settings
| Variable | Default | Meaning |
|---|---|---|
QUERYPROXY_SELECT_DEFAULT_LIMIT |
1000 |
LIMIT injected into SELECTs that have none |
QUERYPROXY_SELECT_HARD_LIMIT |
10000 |
Larger LIMITs are clamped down to this |
QUERYPROXY_EXECUTION_TIMEOUT |
300 |
Max seconds a single request may run on the worker. A whole number of seconds greater than zero; anything else (text, empty, 0, negative, decimal, a value with a unit) falls back to 300. The upper limit is 86400 seconds (24 hours): a valid value above it is not an error, it is clamped to 86400. The Docker entrypoint prints an [entrypoint] warning when it clamps the value. The database-side statement timeout is derived from it (the execution timeout minus 5 seconds, at least 1), so the database stops before the job does; see Read credentials and timeouts |
QUERYPROXY_LOCK_TIMEOUT |
5 |
Seconds a statement waits for a lock on the target database before the database gives up. Digits only, 1 to 3600. A larger value is clamped to 3600; anything else (text, empty, 0) falls back to 5 |
QUERYPROXY_CONNECTION_SLOTS |
2 |
How many requests may run on one target connection at the same time. The rest stay queued and start when a slot frees up; 0 switches the limit off |
QUERYPROXY_CONNECTION_SLOT_RETRY_DELAY |
5 |
Seconds before a request that found every slot busy tries again |
QUERYPROXY_SCHEMA_TIMEOUT |
5 |
Statement timeout, in seconds, of the schema explorer’s catalog read. See Schema explorer |
QUERYPROXY_SCHEMA_CACHE_TTL |
3600 |
Seconds a connection’s schema stays cached; 0 turns the cache off |
QUERYPROXY_SCHEMA_REFRESH_LIMIT |
3 |
Manual schema refreshes a user may ask for per connection and minute |
QUERYPROXY_SCHEMA_MAX_COLUMNS |
20000 |
Most columns one schema read returns; a larger schema is cut at a table boundary |
QUERYPROXY_RESULT_DISK |
local |
Laravel filesystem disk for result files (s3 supported); a publicly visible disk is refused |
QUERYPROXY_RESULT_TTL_DAYS |
30 |
Days before results:prune deletes stored results |
QUERYPROXY_SEED_DEMO |
false |
Docker only: seed the demo team on first boot (one random password, printed once in the logs; refused under APP_ENV=production unless QUERYPROXY_SEED_DEMO_FORCE=true) |
QUERYPROXY_ADMIN_EMAIL / QUERYPROXY_ADMIN_PASSWORD |
— | Docker only: create the first system administrator on boot while the instance has no users (password at least 8 characters); a no-op once any user exists |
QUERYPROXY_ADMIN_NAME |
Admin |
Docker only: display name for that first administrator |
QUERYPROXY_RUN_WORKER |
true |
Docker only: run the queue worker inside the container; set false when the worker runs in its own container. The Helm chart sets it per Deployment (see Kubernetes) |
QUERYPROXY_RUN_SCHEDULER |
true |
Docker only: run the scheduler (schedule:work) inside the container; keep exactly one scheduler per installation |
QUERYPROXY_WORKER_PROCESSES |
1 |
Docker only: number of queue worker processes |
QUERYPROXY_WORKER_TIMEOUT |
execution timeout + 10 | Worker --timeout, derived by the Docker entrypoint from QUERYPROXY_EXECUTION_TIMEOUT (the same applies in a separate worker container). It cannot be overridden: the entrypoint recomputes it on every start and ignores a value you pass. Change the execution timeout instead. Outside Docker, set --timeout on queue:work yourself |
QUERYPROXY_KEY_FILE |
/var/www/html/storage/app/.app_key |
Docker only: where the generated APP_KEY is persisted when APP_KEY itself is not set. When the variable is unset and a key file already exists at the legacy path /var/www/html/database/.app_key, the entrypoint keeps using that file |
QUERYPROXY_REQUIRE_2FA |
none |
Enforce TOTP two-factor auth: none, admins, dba (admins plus anyone with a DBA role) or all |
QUERYPROXY_LOCAL_LOGIN |
all |
Scope of password login: all, admins_only (only system administrators use a password, for break-glass access) or none (password login and password reset are off, so SSO must work first). An unknown value is treated as admins_only. See Single sign-on |
QUERYPROXY_OIDC_ISSUER / QUERYPROXY_OIDC_CLIENT_ID |
— | OpenID Connect single sign-on is on when both are set. See Single sign-on |
QUERYPROXY_OIDC_CLIENT_SECRET |
— | Client secret; QUERYPROXY_OIDC_CLIENT_SECRET_FILE reads it from a file |
QUERYPROXY_OIDC_SCOPES |
openid email profile |
Scopes requested from the provider, separated by spaces; some providers need groups added |
QUERYPROXY_OIDC_GROUP_CLAIM |
groups |
Claim that lists the user’s groups; a dotted path such as realm_access.roles is followed |
QUERYPROXY_OIDC_ROLE_MAP |
— | group:team:role entries, comma-separated; team is a slug or name, role is dba, developer or auditor. Applied at every SSO sign-in |
QUERYPROXY_SCIM_ENABLED |
false |
Serve SCIM 2.0 provisioning under /scim/v2; while off every SCIM path answers 404. See SCIM provisioning |
QUERYPROXY_SCIM_RATE_LIMIT |
300 |
SCIM requests per minute for each token; failed authentications are limited per address to the same number |
QUERYPROXY_API_TOKEN_MAX_DAYS |
90 |
Longest lifetime of an API personal access token, in days. See REST API and tokens |
QUERYPROXY_API_RATE_LIMIT |
60 |
API calls per minute for each token; failed authentications are limited per address to the same number |
QUERYPROXY_CHAT_WEBHOOK_ALLOWED_HOSTS |
— | Extra allowed hosts for outbound chat webhooks, comma-separated, * wildcards allowed (SSRF guard; hooks.slack.com, *.webhook.office.com, *.logic.azure.com and *.powerplatform.com are allowed by default) |
QUERYPROXY_CHAT_INCLUDE_SQL |
literals_redacted |
SQL preview in chat notifications: literals_redacted (string and number literals replaced by ?), full (as submitted) or none. The old booleans work: true is full, false is none |
QUERYPROXY_SLACK_CLIENT_ID / QUERYPROXY_SLACK_CLIENT_SECRET |
— | Client credentials of the Slack app, for the OAuth install. See Slack integration |
QUERYPROXY_SLACK_SIGNING_SECRET |
— | Signing secret of the Slack app; verifies every request Slack sends |
QUERYPROXY_SLACK_RATE_LIMIT |
300 |
Slack requests per minute and workspace, counted after the signature is verified (before it, an address is limited to 120 per minute) |
QUERYPROXY_SECRET_CACHE_TTL |
300 |
Seconds a resolved external secret stays in process memory; 0 turns the cache off. See External secret references |
QUERYPROXY_SECRET_TIMEOUT |
5 |
Seconds a secret store may take to connect and to answer |
QUERYPROXY_VAULT_*, QUERYPROXY_AWS_REGION, QUERYPROXY_AZURE_* |
— | Connection to HashiCorp Vault, AWS Secrets Manager and Azure Key Vault; every variable is listed on External secret references |
QUERYPROXY_PENDING_REMIND_AFTER |
4 |
Hours a request may stay pending before one reminder goes to the team’s DBAs and admins, in the portal and in chat. 0 turns the reminder off; requests never expire either way |
QUERYPROXY_METRICS_TOKEN |
— | Bearer token for GET /metrics, 24 or more random characters. /metrics answers 404 until this or the allowlist is set. See Monitoring |
QUERYPROXY_METRICS_ALLOWED_IPS |
— | Comma-separated addresses or CIDR ranges that may scrape /metrics without a token (follows the TRUSTED_PROXIES rules) |
QUERYPROXY_SIEM_SINKS |
— | Audit export sinks to turn on: stdout, syslog, webhook. The QUERYPROXY_SIEM_* settings are listed in Audit chain and SIEM export |
QUERYPROXY_CONNECTION_HOST_DENYLIST |
— | Extra hosts blocked as connection targets (comma-separated) |
QUERYPROXY_SQLITE_ALLOWED_DIR |
— | Restrict SQLite connection files to this directory. Files inside the application directory (where QueryProxy’s default SQLite database lives) are always rejected, whether or not this is set |
QUERYPROXY_EXTRA_UNTRUSTED_LANGUAGES |
— | Extra procedural languages the guard refuses in CREATE EXTENSION (comma-separated); extends the built-in list. DO, CREATE FUNCTION and CREATE PROCEDURE accept only plpgsql and sql regardless |
QUERYPROXY_EXTRA_DANGEROUS_VARIABLES |
— | Extra server variables the guard refuses in SET (persistent scopes on MySQL / MariaDB, every scope on PostgreSQL) and in set_config (comma-separated); extends the built-in list |
QUERYPROXY_EXTRA_BLOCKED_FUNCTIONS |
— | Extra functions the guard refuses outright (comma-separated); extends the built-in list |
QUERYPROXY_EXTRA_STATE_CHANGING_FUNCTIONS |
— | Extra functions that make a statement a write (comma-separated); extends the built-in list |
QUERYPROXY_EXTRA_RESOURCE_CONSUMING_FUNCTIONS |
— | Extra functions that make a statement a write because they hold resources, such as sleeps (comma-separated); extends the built-in list |
QUERYPROXY_EXTRA_BLOCKED_PROCEDURES |
— | Extra SQL Server procedures the guard refuses (comma-separated); extends the built-in list |
QUERYPROXY_EXTRA_SQLITE_PRAGMAS |
— | Extra SQLite pragmas allowed in their bare, read form (comma-separated); extends the built-in list |
TRUSTED_PROXIES |
— | Reverse proxy IPs/CIDRs allowed to set X-Forwarded-* (comma-separated); empty trusts no proxy |
TRUSTED_HOSTS |
— | Extra hostnames this installation answers to, beyond APP_URL’s host (comma-separated) |
DB_QUEUE_RETRY_AFTER |
QUERYPROXY_EXECUTION_TIMEOUT + 30 (330) |
Seconds before a reserved queue job is retried. Derived from the execution timeout when unset; an explicit value wins. BEANSTALKD_QUEUE_RETRY_AFTER and REDIS_QUEUE_RETRY_AFTER work the same way |
retry_afteris derived: unless you setDB_QUEUE_RETRY_AFTER(or the Beanstalkd / Redis equivalent) yourself, it isQUERYPROXY_EXECUTION_TIMEOUT
- 30 seconds (
330for the default300). The Docker entrypoint derives the worker--timeout(execution timeout + 10) andstopwaitsecs(execution timeout + 20) from the same setting. If you setretry_afterexplicitly, keep it above the execution timeout, because the query job is stopped at that point; the default +30 leaves a margin. A value at or below it lets a slow query be re-reserved while it is still running and then marked failed, even though its write may commit, and logs a warning whenqueue:work(daemon or--once) orqueue:listenstarts. A fixed*_QUEUE_RETRY_AFTER=330left over from the 0.2.0.env.examplestops the derivation, so remove it unless you set it on purpose.
A TOTP code accepted for QUERYPROXY_REQUIRE_2FA is burned, so it cannot be
replayed for the rest of its window. One practical consequence: signing in on a
second device within the same 30-second step means waiting for the next code.
QUERYPROXY_EXTRA_UNTRUSTED_LANGUAGES and QUERYPROXY_EXTRA_DANGEROUS_VARIABLES
add to the two lists the SQL guards judge CREATE EXTENSION
and SET by. They can only extend: the built-in entries are
the floor, merged back on top of whatever the environment provides, so a trimmed
list cannot disarm the guard.
MySQL / MariaDB targets: sql_mode
The guard reads quotes and backslashes the default way, so a request is refused
when the session sql_mode would read them differently. Before it runs the SQL
on a mysql or mariadb connection, QueryProxy reads @@SESSION.sql_mode; if
it contains NO_BACKSLASH_ESCAPES or ANSI_QUOTES, the request fails and
nothing runs, with a message naming the flag. QueryProxy itself sets a strict
sql_mode when it connects, which replaces the server’s default, so if this
fires, look for what changes the mode after connect (a proxy in between, a
changed connection configuration); keeping the flags out of the server and
connection-user defaults is still sensible, but on its own it will not clear
it. Submitting a SET that changes sql_mode is refused on every
driver (see SQL guards).
Read credentials and timeouts
A read request runs in a read-only session on the target database, with a statement timeout and a lock timeout that QueryProxy sets. What that means per driver:
| Driver | Read-only session | Statement timeout | Lock timeout |
|---|---|---|---|
| PostgreSQL | SET TRANSACTION READ ONLY |
statement_timeout (and idle_in_transaction_session_timeout) |
lock_timeout |
| MySQL | SET SESSION TRANSACTION READ ONLY |
MAX_EXECUTION_TIME, SELECT only |
innodb_lock_wait_timeout |
| MariaDB | SET SESSION TRANSACTION READ ONLY |
max_statement_time |
innodb_lock_wait_timeout |
| SQL Server | none (the engine has no read-only session) | PDO query timeout | SET LOCK_TIMEOUT |
| SQLite | PRAGMA query_only |
none | busy timeout |
The statement timeout is QUERYPROXY_EXECUTION_TIMEOUT minus 5 seconds (at
least 1), and the lock timeout is QUERYPROXY_LOCK_TIMEOUT. Writes get the same
limits. On MySQL the statement timeout does not apply to writes, so a long write
there is not cut by the database; the job ends at the execution timeout and the
write may still run on the server. Stop query cancels it earlier.
MySQL and MariaDB set the read-only mode for the whole session, so a statement
that commits implicitly cannot slip out of it. On PostgreSQL, SELECT … FOR UPDATE and FOR SHARE are refused inside a read request.
A connection can carry an optional read username and password (encrypted
like the main credentials). Reads use them; writes never do. The connection test
tries both, and a failure of the read credentials starts with “Read
credentials:”. Give the read user SELECT privileges only. On SQL Server this
is the only database-side protection for reads, because the engine has no
read-only session; ApplicationIntent=ReadOnly does not protect against writes.
SQLite connections do not store a read identity.
TLS to target databases
Each network connection has a TLS setting in the connection form. New connections default to required TLS: they fail instead of falling back to plain text.
| Driver | Options | New connection |
|---|---|---|
| PostgreSQL | require, verify-ca, verify-full, prefer, disable |
require (encrypted, server not verified). verify-ca and verify-full use the CA you paste |
| MySQL, MariaDB | required or left to the driver default; “Verify the server certificate” |
required, server certificate not verified |
| SQL Server | required or left to the driver default; “Trust the server certificate” |
Encrypt=true, TrustServerCertificate=false |
| SQLite | none | none |
For PostgreSQL, MySQL and MariaDB you can paste a CA certificate, a client
certificate and a client key as PEM. They are stored encrypted, written at run
time to storage/app/tls/<connection id>/ (directory mode 0700, files 0600) and
removed with the connection. The form never sends them back to the browser, and
the client key must not be password protected. The SQL Server form has no PEM
fields.
require and required without verification encrypt the traffic but do not
prove who answers, so they do not stop a man in the middle. Turn verification on
where the network is not fully yours.
Connections that exist from before v0.3.0 keep their old behaviour and are
marked “TLS not enforced” (PostgreSQL, sslmode=prefer) or “TLS: driver
default” in the list; queryproxy:doctor names them. SQL Server requires the
pdo_sqlsrv extension for required TLS: without it Laravel would silently fall
back to a connection that ignores the encryption setting, so QueryProxy refuses
to connect. With Encrypt=true, refusing a server that offers no encryption is
the ODBC driver’s documented behaviour; QueryProxy’s tests cannot simulate such
a server.
Secrets from files
Docker and Kubernetes secrets arrive as files. For each variable below, set the
same name with a _FILE suffix instead, and the entrypoint (and the
application) read the value from that file:
| Variable | File variable |
|---|---|
APP_KEY |
APP_KEY_FILE |
APP_PREVIOUS_KEYS |
APP_PREVIOUS_KEYS_FILE |
DB_PASSWORD |
DB_PASSWORD_FILE |
MAIL_PASSWORD |
MAIL_PASSWORD_FILE |
REDIS_PASSWORD |
REDIS_PASSWORD_FILE |
SLACK_BOT_USER_OAUTH_TOKEN |
SLACK_BOT_USER_OAUTH_TOKEN_FILE |
QUERYPROXY_ADMIN_PASSWORD |
QUERYPROXY_ADMIN_PASSWORD_FILE |
QUERYPROXY_METRICS_TOKEN |
QUERYPROXY_METRICS_TOKEN_FILE |
QUERYPROXY_SIEM_WEBHOOK_SECRET |
QUERYPROXY_SIEM_WEBHOOK_SECRET_FILE |
QUERYPROXY_OIDC_CLIENT_SECRET |
QUERYPROXY_OIDC_CLIENT_SECRET_FILE |
QUERYPROXY_VAULT_TOKEN |
QUERYPROXY_VAULT_TOKEN_FILE |
QUERYPROXY_VAULT_SECRET_ID |
QUERYPROXY_VAULT_SECRET_ID_FILE |
QUERYPROXY_AZURE_CLIENT_SECRET |
QUERYPROXY_AZURE_CLIENT_SECRET_FILE |
services:
app:
environment:
APP_KEY_FILE: /run/secrets/queryproxy_app_key
DB_PASSWORD_FILE: /run/secrets/queryproxy_db_password
QUERYPROXY_METRICS_TOKEN_FILE: /run/secrets/queryproxy_metrics_token
secrets: [queryproxy_app_key, queryproxy_db_password, queryproxy_metrics_token]
In Kubernetes, point the _FILE variable at the path where a Secret volume is
mounted.
- Setting both
XandX_FILEis an error and the container does not start. A plain variable set tonull,(null)or(empty)counts as unset. - The file must exist, be readable and not be empty. Trailing line breaks are removed; everything else is kept.
- Error messages name the variable and the path, never the value.
php artisan config:cache(the image runs it on every start) writes the resolved values, secrets included, in plain text tobootstrap/cache/config.php. A secret that came from a file therefore also lives in that cache file.- The signing secrets and webhook URLs of the incoming-webhook mode of Slack and
Teams are not environment variables; they are stored encrypted in the
application database. The Slack app’s own credentials
(
QUERYPROXY_SLACK_CLIENT_SECRET,QUERYPROXY_SLACK_SIGNING_SECRET) are plain environment variables and have no_FILEform.
php artisan queryproxy:doctor reports which secrets are read from files
(secret_files) and fails when one cannot be read. See
Troubleshooting.
Single sign-on
Single sign-on through OpenID Connect is configured with the QUERYPROXY_OIDC_*
variables in the table above. Register {APP_URL}/auth/oidc/callback as the
redirect URI at the provider. The provider examples, the role map and the rules
for creating and linking accounts are on Single sign-on.
The client secret can come from a file (QUERYPROXY_OIDC_CLIENT_SECRET_FILE),
like the other secrets in Secrets from files.
Schema explorer
The Query Studio’s schema panel and name completion read the target database’s catalog through fixed queries, in a read-only session, without an approval. Only users who may use the connection can read it, and the answer lists tables, views and columns, never data.
QUERYPROXY_SCHEMA_TIMEOUTis the statement timeout of that read, in seconds. It is separate from the execution timeout of requests. A read also needs a free connection slot (QUERYPROXY_CONNECTION_SLOTS); with every slot busy the panel asks the user to try again shortly.- The result is cached per connection for
QUERYPROXY_SCHEMA_CACHE_TTLseconds, and editing the connection starts a fresh entry. A manual Refresh skips the cache and is limited per user and connection toQUERYPROXY_SCHEMA_REFRESH_LIMITa minute. The cache holds names and types only; the lock on masked columns is computed from the current masking rules on every answer. QUERYPROXY_SCHEMA_MAX_COLUMNScaps one read. A larger schema is cut at a table boundary with the default schema listed first, and the panel says it is truncated.- On MySQL and MariaDB only the connection’s own database is listed. The system
schemas (
information_schema,mysql,performance_schema,sys) are never listed. - Each read from the database is recorded as
schema.readin the audit log, with the object and column counts. A read served from the cache is not.
Application key and HTTPS
APP_KEY encrypts your stored connection credentials and chat secrets. For
production, set it explicitly and keep it stable — a regenerated key (e.g. on
container recreation) makes every stored credential unreadable. Generate one
with php artisan key:generate --show, and when rotating, move the old key into
APP_PREVIOUS_KEYS (comma-separated) so existing ciphertext still decrypts.
When serving over HTTPS behind a reverse proxy, set SESSION_SECURE_COOKIE=true.
Behind a reverse proxy
Breaking change — set
TRUSTED_PROXIESwhen a proxy sits in front of QueryProxy. Earlier versions trusted every proxy. From this version no proxy is trusted by default, and until you name yours the app reads the connecting peer as the client.
TRUSTED_PROXIES takes a comma-separated list of the IPs or CIDRs allowed to
set X-Forwarded-* headers:
TRUSTED_PROXIES=10.0.0.0/8
Until that list names your proxy, X-Forwarded-Proto is ignored — HTTPS is not
detected, so no HSTS header is sent — and audit entries record the proxy’s
address instead of the client’s.
Never set it to *. Trusting every proxy lets any client send its own
X-Forwarded-For, which forges the client IP in the audit trail and hands out
unlimited login and 2FA attempts, because both throttles are keyed per IP.
X-Forwarded-Host is not honoured at all, whatever the list says: a forged host
header rewrote the link in password-reset mail. As a second layer, the request
host is pinned to APP_URL’s host (and its www. sibling), so absolute URLs —
signed routes, password-reset mails — always point at your own installation. Set
APP_URL to the address users actually reach.
If the installation answers to more than one name, list the others in
TRUSTED_HOSTS:
TRUSTED_HOSTS=queryproxy.internal,db.example.com
That is the supported way to run a multi-domain deployment now that
X-Forwarded-Host is ignored — a hostname you have not listed still cannot
rewrite a password-reset link. The names are added to APP_URL’s host (and its
www. sibling), never replace it. Entries that do not parse as a hostname are
dropped silently, so a typo cannot take the installation down.
Both values are read through the application config, so setting them in .env
is enough. If you cache the configuration, re-run php artisan config:cache
after changing them; the Docker image re-caches on every start.
Application database
QueryProxy’s own data (users, teams, requests, audit log) lives in SQLite by default — zero configuration. For MySQL, MariaDB, PostgreSQL or SQL Server, set the standard Laravel variables:
DB_CONNECTION=pgsql # or mysql, mariadb, sqlsrv
DB_HOST=db
DB_PORT=5432
DB_DATABASE=queryproxy
DB_USERNAME=queryproxy
DB_PASSWORD=change-me
This is the application database — the databases you proxy are configured in the UI under Connections, per team, with encrypted credentials.
Moving from SQLite to another database. Stop QueryProxy, then run
php artisan queryproxy:migrate-appdb --to=<dsn>, for example
--to=pgsql://user:password@host:5432/queryproxy (URL-encode special characters
in the password). It runs the migrations on the target, copies every table in
foreign key order, compares the row count of every table and runs audit:verify
on the target. The audit triggers stay in place and the audit hashes and the
chain head are copied unchanged. The target must be empty (a database that was
only migrated is fine). --dry-run checks everything and writes nothing; without
--force the command asks you to confirm that QueryProxy is stopped. The copy is
one transaction, so a failure leaves a migrated, empty target you can retry. The
cache tables are not copied, and the target password is never printed. Afterwards
switch DB_CONNECTION and the other DB_* settings and start QueryProxy again.
SQL Server as the application database. Use the mssql image variant: the
default image has no pdo_sqlsrv (see SQL Server targets).
Move an installation there with
php artisan queryproxy:migrate-appdb --to="sqlsrv://user:password@host:1433/queryproxy?trust_server_certificate=true".
Options in the query string, such as trust_server_certificate and encrypt, go
to the connection of that command only. The application has no separate DB_*
variable for them, so after the move give it the same DSN, with the same options,
in DB_URL; otherwise a server with a self-signed certificate is reachable for
migrate-appdb but not for the running application. DB_URL carries the
password, so URL-encode special characters in it and keep it as secret as
DB_PASSWORD.
SQL Server refuses foreign keys that give a table more than one cascade path, and
the schema has several. So on SQL Server only, eight foreign keys are created with
ON DELETE NO ACTION: query_requests.team_id, masking_rules.team_id,
connection_grant_requests.team_id, connections.created_by,
connection_user.granted_by, connection_grant_requests.decided_by, and the two
query_requests keys to users (user_id and reviewed_by), which are RESTRICT
on every driver, so that request history is never deleted with a user (ADR-018). QueryProxy does what the database
would have done on a delete: deleting a team removes its requests, masking rules
and grant requests first, and hard-deleting a user who has no requests sets the
keys that referenced them to NULL.
What differs is raw SQL that deletes a team or a user. On SQL Server it is
refused only while rows still point at the team or user through one of those eight
keys: for a team, requests, masking rules or grant requests; for a user,
connections it created, grants it gave, grant requests it decided, or requests it
filed or reviewed. For the six keys that cascade or set NULL on the other drivers, the
delete is refused on SQL Server instead. The two request keys to users are
refused on every driver. Nothing changes on PostgreSQL, MySQL, MariaDB or SQLite:
no new migration, no change to an existing installation, the same delete results.
Tested versions. Target databases: PostgreSQL 17, MySQL 8.4, MariaDB 11.4
and 11.8, SQL Server 2022 and SQLite. Application database: SQLite, MySQL 8.4,
MariaDB 11.4 and 11.8, PostgreSQL 17, and SQL Server 2022 (the mssql image
variant). PostgreSQL-compatible target databases: CockroachDB v25.4 and
YugabyteDB 2025.2, each with known differences listed on
PostgreSQL-compatible databases, which includes what the
SQL guard does not cover on CockroachDB. Other versions may work but are not
tested.
Queue
The queue is database-backed by default (no Redis required). To move it to Redis:
QUEUE_CONNECTION=redis
REDIS_HOST=redis
REDIS_QUEUE_RETRY_AFTER is optional: when unset it is derived from
QUERYPROXY_EXECUTION_TIMEOUT + 30 seconds (see the note under the settings
table). Only set it to override that value, and keep it above the execution
timeout.
The worker command stays the same. Keep its --timeout a little above the
execution timeout (execution timeout + 10 seconds; QUERYPROXY_EXECUTION_TIMEOUT
is 300 by default, hence 310):
php artisan queue:work --queue=queries,default --tries=1 --timeout=310
In a manual setup this number is not derived for you: if you change
QUERYPROXY_EXECUTION_TIMEOUT, change --timeout to match (the Docker
entrypoint derives it itself).
The same warning (retry_after at or below the execution timeout) is logged
once when queue:listen or queue:work --once starts, not only for a daemon
queue:work. The short-lived queue:work --once processes that queue:listen
spawns do not repeat it.
In the repository’s docker-compose.yml, the separate worker example no
longer hard-codes --timeout=310: it passes QUERYPROXY_WORKER_TIMEOUT, which
the image entrypoint derives as QUERYPROXY_EXECUTION_TIMEOUT + 10. Set
QUERYPROXY_EXECUTION_TIMEOUT on that service and the worker follows. This
needs the image’s default entrypoint, so do not override entrypoint: on the
worker service.
Result storage on S3
Set QUERYPROXY_RESULT_DISK=s3 and the standard AWS_* variables. Result
files are written as NDJSON, streamed — memory use stays flat regardless of
result size.
The disk must be private. Result paths are predictable
(results/{team_id}/{id}.ndjson), so a publicly served disk would hand out
every team’s rows straight from the web root, bypassing the download
authorization entirely. QueryProxy therefore refuses to write results when
QUERYPROXY_RESULT_DISK points at the public disk or at any disk configured
with visibility => public, and the request fails with an explicit message
instead.
SQL Server targets
The default Docker image ships pdo_pgsql, pdo_mysql and pdo_sqlite. For
Microsoft SQL Server targets use the mssql image variant:
queryproxy/queryproxy:<version>-mssql, or queryproxy/queryproxy:mssql for the
latest stable release. It adds Microsoft’s ODBC Driver 18 and the pdo_sqlsrv
extension, is built for linux/amd64 and linux/arm64, and is scanned, signed
and attested like the default image. :mssql moves only with stable releases and
never takes latest. Microsoft’s driver is under Microsoft’s own licence
(https://aka.ms/odbc18eula); using the variant means accepting it.
To build the variant yourself, pass --build-arg INSTALL_SQLSRV=1 to the
repository’s Dockerfile. The extensions have to be present in the PHP build that
runs both the web process and the queue worker, because approved queries execute
on the worker: run the same variant for every role.
Privileges for Stop query
Stop query cancels the running statement from a second connection. It
needs no extra privilege on PostgreSQL, MySQL and MariaDB beyond what the
connection’s user has (pg_signal_backend or CONNECTION_ADMIN suffice for
other users’ sessions). On SQL Server the connection’s login needs:
VIEW SERVER STATE: without itsys.dm_exec_sessionsshows only the caller’s own session, the run cannot be found, and the request is markedcancelledwithout signalling the server (a warning appears and the statement ends at its timeout).ALTER ANY CONNECTION:KILL <spid>needs it.
On SQL Server, Stop ends the session, so an open transaction rolls back on the server; on the other drivers only the statement is cancelled. The button is not shown for SQLite.
After a statement has started, QueryProxy does not reconnect on a network drop; the run fails instead of continuing on a new session.