Skip to content

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.

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. —
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.

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.

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.