# Configuration

> Every QueryProxy environment variable — limits, timeouts, TLS, secrets, SCIM, Slack, schema explorer, metrics, SIEM export, proxies, database, queue.

- Documentation: Reference
- Updated: 2026-11-04
- Source: https://queryproxy.com/docs/configuration/
- Language: en-US
- Author: Muhammet Şafak

---
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](#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](#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](/docs/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](#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](#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](/docs/scim/) |
| `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](/docs/api/) |
| `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](/docs/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](/docs/external-secrets/) |
| `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](/docs/external-secrets/#settings) |
| `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](/docs/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](/docs/audit-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](/docs/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](/docs/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` |

```yaml
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.
- 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 `_FILE` form.

`php artisan queryproxy:doctor` reports which secrets are read from files
(`secret_files`) and fails when one cannot be read. See
[Troubleshooting](/docs/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](/docs/sso-oidc/).
The client secret can come from a file (`QUERYPROXY_OIDC_CLIENT_SECRET_FILE`),
like the other secrets in [Secrets from files](#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_TIMEOUT` is 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_TTL` seconds,
  and editing the connection starts a fresh entry. A manual **Refresh** skips the
  cache and is limited per user and connection to `QUERYPROXY_SCHEMA_REFRESH_LIMIT`
  a 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_COLUMNS` caps 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.read` in 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_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:

```dotenv
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`:

```dotenv
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:

```dotenv
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](#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](/docs/compatibility/), 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:

```dotenv
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`):

```bash
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 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.
