Self-hosted environment
This is the complete list.
Every name here is read by the platform itself. The QD_ names in the reference compose file are that file’s own — they are what set these — and the self-hosting guide explains them.
To install an instance, start with the self-hosting guide, which sets these variables for you.
Required
Section titled “Required”The API refuses to boot without these eight.
| Variable | What it is | A wrong value looks like |
|---|---|---|
SELF_HOSTED |
Whether this is somebody’s own instance. true here. One image serves both this and our hosted cloud, so the image cannot answer it and you do. With it false the API additionally requires STRIPE_API_KEY, SENDGRID_API_KEY and SENTRY_DSN. |
Boot fails, naming the variable. There is no default: guessing either way produces an instance that looks fine and is not. |
DATABASE_URL |
Postgres for the instance’s own data. The role needs CREATE on the database: the API creates the pg_trgm extension and applies its migrations at boot. |
Boot fails, naming the connection error. |
SESSION_SECRET |
Signs session cookies. Generate one with openssl rand -hex 32. |
Changing it logs everyone out. Leaking it lets anyone forge a session. |
COOKIE_DOMAIN |
The host cookies are scoped to — the hostname of PUBLIC_API_URL, without the scheme or port. |
Sign-in appears to work and the next request is anonymous. |
VITE_APP_URL |
Where the UI lives. Read server-side for the CORS allowlist, the login redirect, and the links in alerts and PR comments. | Alert links point somewhere nobody can open. |
VITE_SITE_URL |
Joins the CORS allowlist. A self-hosted instance has no separate marketing site, so this is the instance’s own origin. | The browser blocks the SPA’s own API calls. |
SITE_URL_ALT |
Extra allowed origins, comma-separated. Empty is the normal answer. | — |
APP_URL_ALT |
One more allowed origin, as a regular expression. Empty is the normal answer. | — |
Required for anything real
Section titled “Required for anything real”| Variable | What it is |
|---|---|
PUBLIC_API_URL |
This instance’s public origin: the OAuth issuer, the MCP resource-server URL, the SITE_API_ENDPOINT in the workflows the setup_ci MCP tool writes, and the address the Collectors this instance starts report back to. |
Set it on any instance whose agents call setup_ci. The workflow that tool writes carries this address, and without it carries a .invalid host that fails on DNS.
The Setup → Continuous integration page in the app reads the browser’s address instead, so its copy is right either way.
Connecting a database needs it too, and needs it to be an address other than localhost. The Collector this instance starts runs in its own container, where localhost is that container rather than the instance, so a Collector told to report to http://localhost:8080 reports to nothing. Set this to the hostname or IP other machines reach the instance at. An instance that has never been given one refuses to connect a database and says so, rather than starting a Collector that cannot report.
Serving the instance over HTTPS on a real hostname is the arrangement this is written for. Plain HTTP works only on localhost or 127.0.0.1, because the OAuth metadata this instance publishes must have an HTTPS issuer and the MCP SDK exempts only those two — so a plain-HTTP instance on any other address refuses to boot. Put a reverse proxy with a certificate in front of it, or accept that connecting a database is unavailable.
Defaulted
Section titled “Defaulted”Change these only with a reason.
| Variable | Default | What it does |
|---|---|---|
PORT |
3000 |
The port inside the container. Map it on the host instead. |
VITE_DOCS_URL |
https://docs.querydoctor.com |
The product’s docs, linked from alerts and PR comments. The same for every instance. |
THROTTLE_TTL |
60 |
The request-throttling window, in milliseconds. See the note below. |
THROTTLE_LIMIT |
100 |
Requests allowed per window. |
REGISTRATION_INVITE_ONLY |
false |
Whether new people need a team owner to approve them. On in the shipped compose file. With it off, anyone who can reach the instance signs themselves up. |
SEED_DEV_DATA |
false |
Creates a developer account and sample projects. Never set it on a real instance. |
PLANNER_BASE_IMAGE_REPOSITORY |
ghcr.io/query-doctor/postgres |
Where planner base images are pulled from, one tag per major. Ours are public and need no credential. Point it at a mirror carrying the same tags if this host cannot reach ghcr.io. |
PLANNER_RECIPES_REPOSITORY |
https://github.com/Query-Doctor/postgres-recipes.git |
Where extension build recipes are fetched from, for a schema needing an extension the base image does not ship. Out of reach, those schemas get no planner; every other schema is unaffected. |
PLANNER_RECIPES_REF |
an immutable src-<commit> tag |
The recipe tree to build against. Never a branch, and never moved on its own: an extension compiled against a tree the base images were not built from loads and then reports nonsense costs. |
PLANNER_NETWORK |
unset | The docker network the platform starts planners and Collectors on. Set it when the platform is itself a container: there a planner’s published port is on the host’s loopback, which is not an address the platform has, and a Collector on docker’s default bridge resolves no container names at all. Unset publishes on loopback instead, which is right for a platform running as a host process. |
COLLECTOR_IMAGE |
the Collector built from the same commit as the platform image | The Collector the platform names: the one it starts beside a connected database, and the one query-doctor/ci pulls for a CI run. Unset, it matches the platform, so both speak one protocol. Point it at a mirror carrying the same tags if this host or your CI runners cannot reach ghcr.io. An image that cannot be pulled leaves every connection reporting that its Collector could not start, and every CI run failing at the pull with this setting named. |
QD_COMMIT |
the commit the image was built from | Set by the image, not by you. It names the Collector built from the same commit, which is what an unset COLLECTOR_IMAGE resolves to. |
COLLECTOR_MEMORY_LIMIT |
1g |
The most memory each Collector may use: a whole number of k, m or g, such as 1536m. One that reaches it is killed and restarted, and its connection reports a Collector that keeps exiting. A new value reaches only Collectors started after the change. A Collector image built before this limit existed peaks above 1 GB at start: pull the image again, or set 2g. |
PLANNER_IDLE_TIMEOUT_MS |
900000 |
How long a shared planner stays up with nothing using it. Nothing shares one today: every analysis is a CI run, and each run’s planner stops with the run. Changing this changes nothing you can see. |
THROTTLE_TTL reaches the throttler as milliseconds, so the default of 60 is a 60 ms window rather than a minute. That is a bug on our side (#4109), not a unit to design around.
COLLECTOR_IMAGE is live for the same reason: connecting a database asks the platform to start a Collector beside it, rather than handing you a docker run command to paste. CI runs ask it too, through query-doctor/ci, so a workflow never picks a Collector this instance cannot read.
The PLANNER_ variables are live on a self-hosted instance: analysis runs there, in Postgres containers the platform starts for itself. Planner containers says what starts, what it costs, and what the instance needs before any of it works. The defaults are right unless this host cannot reach ghcr.io or github.com. PLANNER_IDLE_TIMEOUT_MS is the one exception, and the reference compose file leaves it out.
Optional integrations
Section titled “Optional integrations”Leave any of these out and the instance still boots; the feature reports that it is off.
| Variable | Absent means |
|---|---|
SENDGRID_API_KEY |
No outbound mail. Password reset stops working. |
SENTRY_DSN |
No telemetry. Nothing leaves the instance. |
STRIPE_API_KEY |
No billing. Nothing on a self-hosted instance is gated on payment. |
STRIPE_ENDPOINT_SECRET |
No Stripe webhooks, so billing never hears back. |
STRIPE_PRICE_ID_PRO_LIFETIME |
No lifetime plan to buy. |
STRIPE_PRICE_ID_PRO_MONTHLY |
No monthly plan to buy. |
STRIPE_PRICE_ID_PRO_YEARLY |
No yearly plan to buy. |
An empty string counts as unset for all seven.
Set STRIPE_API_KEY and the other four stop being optional: the boot fails on the first one missing. Configure billing fully or leave all five out.
A VITE_ name means one thing to the server and another to the browser
Section titled “A VITE_ name means one thing to the server and another to the browser”Every VITE_ variable above configures the server. The browser’s copies are separate, fixed at image build time, and no run-time value changes them.
| Name | The server reads | The bundle carries |
|---|---|---|
VITE_APP_URL |
your instance’s origin | same-origin, resolved against the page |
VITE_SITE_URL |
your instance’s origin | https://querydoctor.com |
VITE_DOCS_URL |
https://docs.querydoctor.com |
https://docs.querydoctor.com |
VITE_API_URL |
only the Sentry environment label, if any | same-origin, resolved against the page |
VITE_API_URL is the odd one: it is not in the config schema, and the server touches it only to name the environment it reports to Sentry. Without SENTRY_DSN nothing reads it at all.
The SPA’s links to the product’s site and docs point at ours, so an instance with no internet route renders them dead. Changing them means building your own image.