QueryProxy
Documentation menu

Configuration

Every QueryProxy environment variable — limits, timeouts, TLS, secrets from files, metrics, SIEM export, proxies, the application database and the 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_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
QUERYPROXY_RUN_SCHEDULER true Docker only: run the scheduler (schedule:work) inside the container
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_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_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_after is derived: unless you set DB_QUEUE_RETRY_AFTER (or the Beanstalkd / Redis equivalent) yourself, it is QUERYPROXY_EXECUTION_TIMEOUT

  • 30 seconds (330 for the default 300). The Docker entrypoint derives the worker --timeout (execution timeout + 10) and stopwaitsecs (execution timeout + 20) from the same setting. If you set retry_after explicitly, 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 when queue:work (daemon or --once) or queue:listen starts. A fixed *_QUEUE_RETRY_AFTER=330 left over from the 0.2.0 .env.example stops 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
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 X and X_FILE is an error and the container does not start. A plain variable set to null, (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 to bootstrap/cache/config.php. A secret that came from a file therefore also lives in that cache file.
  • Slack and Teams signing secrets and webhook URLs are not environment variables; they are stored encrypted in the application database.

php artisan queryproxy:doctor reports which secrets are read from files (secret_files) and fails when one cannot be read. See Troubleshooting.

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_PROXIES when 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 or PostgreSQL, set the standard Laravel variables:

DB_CONNECTION=pgsql        # or mysql
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.

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, and PostgreSQL 17. 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. To proxy Microsoft SQL Server databases, extend the Dockerfile with Microsoft’s ODBC driver and the sqlsrv / pdo_sqlsrv PECL extensions, then build and run your own image in place of queryproxy/queryproxy. 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.

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 it sys.dm_exec_sessions shows only the caller’s own session, the run cannot be found, and the request is marked cancelled without 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.

Arrow keys to move, Enter to open.