Self-hosting
What you need
Section titled “What you need”- Docker with Compose v2, and permission to use its socket. The instance starts a Postgres of its own to plan your queries in — see Planner containers.
- A host your CI runners can reach. A GitHub-hosted runner cannot reach
localhoston your laptop. - Room for the planner images. About 256 MB per Postgres major you analyse, kept after the first pull, plus a running Postgres for each analysis in flight.
1. Save the compose file
Section titled “1. Save the compose file”# Query Doctor, self-hosted — the reference topology.## One platform container (the API, serving the SPA from the same origin) and one# Postgres holding this instance's own data. Analysis runs here: the platform# starts a planner container per analysis through the host's docker socket and# costs your queries in it. The socket below is what makes a verdict possible.## cp .env.self-host.example .env # or the .env block from the guide# $EDITOR .env # QD_SESSION_SECRET, QD_PUBLIC_URL, QD_COOKIE_DOMAIN# docker compose -f docker-compose.self-host.yml up -d## Then open the instance and set it up. Every variable is explained at# https://docs.querydoctor.com/reference/self-host-env/.
name: query-doctor
# Written once so the three variables that carry it cannot disagree. Keep it in# step with QD_PORT: this is the address other people reach the instance at, and# the port below is where it listens on this host.x-public-url: &public-url "${QD_PUBLIC_URL:-http://localhost:8080}"
# The docker network the platform shares with everything it starts — planners# and Collectors alike — written once so the name the platform is told is the# name the network has.x-planner-network: &planner-network "${QD_PLANNER_NETWORK:-qd-planners}"
services: platform: image: ghcr.io/query-doctor/platform:latest restart: unless-stopped ports: - "${QD_PORT:-8080}:3000" environment: # This instance's public origin — the address a browser, a CI runner and # an MCP client all reach it at. It is the OAuth issuer and the endpoint # baked into the CI workflows this instance hands out, so a wrong value # is not cosmetic: those runs post somewhere else. PUBLIC_API_URL: *public-url # The same origin again. The SPA is served by the API and reads its own # address off the page, but the server still needs these two to decide # which origins may call it and where to point links it renders itself. VITE_APP_URL: *public-url VITE_SITE_URL: *public-url # Extra origins allowed to call the API: a comma-separated list, and a # regular expression, respectively. Empty is the normal answer. SITE_URL_ALT: "${QD_SITE_URL_ALT:-}" APP_URL_ALT: "${QD_APP_URL_ALT:-}" # Whether this instance sells anything. True is somebody's own instance: # no plan, no upgrade. Our cloud runs this same file with it false, and # then the API refuses to boot without the three keys below. SELF_HOSTED: "${QD_SELF_HOSTED:-true}" # Transactional mail. Its one job is the password-reset email, and an # instance is the only place its accounts exist, so without this a # person who forgets their password has no way back in. SENDGRID_API_KEY: "${QD_SENDGRID_API_KEY:-}" # Errors and traces, to a project you own. Empty initializes no client # and sends nothing. An instance on hardware we do not own reports to us # only if its operator asks for it (ADR 0023). SENTRY_DSN: "${QD_SENTRY_DSN:-}" # Billing. Empty is the normal answer and every checkout route then says # "not configured on this instance". Set the key and the four values # under it become mandatory, because a key with no prices is a # misconfiguration rather than an instance that sells nothing. STRIPE_API_KEY: "${QD_STRIPE_API_KEY:-}" STRIPE_PRICE_ID_PRO_LIFETIME: "${QD_STRIPE_PRICE_ID_PRO_LIFETIME:-}" STRIPE_PRICE_ID_PRO_MONTHLY: "${QD_STRIPE_PRICE_ID_PRO_MONTHLY:-}" STRIPE_PRICE_ID_PRO_YEARLY: "${QD_STRIPE_PRICE_ID_PRO_YEARLY:-}" STRIPE_ENDPOINT_SECRET: "${QD_STRIPE_ENDPOINT_SECRET:-}" # Whether new people need a team owner to approve them. On by default: # an address somebody can reach is an address somebody can sign up at, # and approving them is how you decide who gets a workspace on this host. # Set it to false and anyone who can load the page signs themselves up. REGISTRATION_INVITE_ONLY: "${QD_REGISTRATION_INVITE_ONLY:-true}" # The host session cookies are scoped to — the hostname of QD_PUBLIC_URL, # without the scheme or port. COOKIE_DOMAIN: "${QD_COOKIE_DOMAIN:-localhost}" # Signs session cookies. Changing it logs everyone out; leaking it lets # anyone forge a session. SESSION_SECRET: "${QD_SESSION_SECRET:?generate one with: openssl rand -hex 32}" DATABASE_URL: "postgres://postgres:${QD_POSTGRES_PASSWORD:-postgres}@postgres:5432/query_doctor" # Where planner base images are pulled from, one tag per Postgres major. # Ours are public and need no registry credential; point this at a mirror # if this host cannot reach ghcr.io. PLANNER_BASE_IMAGE_REPOSITORY: "${QD_PLANNER_BASE_IMAGE_REPOSITORY:-ghcr.io/query-doctor/postgres}" # The Collector image this instance names, beside a connected database # and to CI runs (ADR 0039). Empty names the one built from this image's # own commit, which speaks its protocol; a mirror goes here. COLLECTOR_IMAGE: "${QD_COLLECTOR_IMAGE:-}" # The network the platform starts things on, which is this one. The # platform is a container too, so a planner published on the host's # loopback is at an address it cannot reach: 127.0.0.1 in here is in # here. A Collector joins it for the neighbouring reason — the platform # checks a connection string from in here before handing it over, so a # Collector anywhere else is dialling names this instance never checked. PLANNER_NETWORK: *planner-network networks: # `default` as well, because naming any network drops the implicit one # and postgres is on it. - default - planners volumes: # The container runtime the platform launches planners through. # This is root on the host by any other name: whoever can reach this # socket can start a privileged container, so an instance that hands it # over should be one you would already trust with the machine. - "/var/run/docker.sock:/var/run/docker.sock" group_add: # The group that owns that socket, so the unprivileged user the image # runs as may write to it. The mount without this is a socket the # platform can see and not use. - "${QD_DOCKER_GID:-0}" depends_on: postgres: condition: service_healthy
postgres: image: postgres:18 restart: unless-stopped environment: POSTGRES_PASSWORD: "${QD_POSTGRES_PASSWORD:-postgres}" POSTGRES_DB: query_doctor # Bound to the host's loopback by default, so the database is not reachable # off the machine. Set QD_POSTGRES_BIND=0.0.0.0 and a QD_POSTGRES_PORT to # let another machine connect a client. ports: - "${QD_POSTGRES_BIND:-127.0.0.1}:${QD_POSTGRES_PORT:-5432}:5432" volumes: - platform_data:/var/lib/postgresql healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 2s timeout: 5s # A first boot on a small host pays for initdb before it can answer, # and the platform waits on this. retries: 15
networks: # Named rather than left to compose, which would prefix it with the project # and leave the platform telling the fleet a name that depends on how someone # invoked `docker compose`. Override it to run two instances on one host. # # Put the database you connect on this network too, or give it an address that # resolves from it: the Collector reads it from in here. planners: name: *planner-network
volumes: platform_data:Save it as docker-compose.self-host.yml.
2. Fill in the environment
Section titled “2. Fill in the environment”Save this as .env beside the compose file and edit it.
# Copy to .env beside docker-compose.self-host.yml and edit.# Each variable is explained at https://docs.querydoctor.com/reference/self-host-env/.
# The address this instance answers at, as everyone else reaches it — including# GitHub Actions runners. Behind a reverse proxy, the proxy's URL, not the# container's.QD_PUBLIC_URL=http://localhost:8080
# The host session cookies are scoped to: QD_PUBLIC_URL's hostname, no scheme# and no port.QD_COOKIE_DOMAIN=localhost
# Signs session cookies. Generate with: openssl rand -hex 32QD_SESSION_SECRET=
# Whether new people need a team owner to approve them. On by default: anyone# who can reach this address can otherwise sign up and get a workspace on your# hardware. With it on, the register page asks them to request access, and you# let them in from your account page.QD_REGISTRATION_INVITE_ONLY=true
# Host port the platform listens on. Change it if 8080 is taken; keep it in# step with QD_PUBLIC_URL.QD_PORT=8080
# Postgres password for this instance's own database. Set before the first# start — changing it later leaves the volume expecting the old one.QD_POSTGRES_PASSWORD=postgres
# The database is reachable only from the host by default (127.0.0.1). To let# another machine connect a client to it, set the bind address to 0.0.0.0 and# choose a host port. Set a real QD_POSTGRES_PASSWORD above and firewall the# port to trusted addresses first.QD_POSTGRES_BIND=127.0.0.1QD_POSTGRES_PORT=5432
# The group that owns the docker socket on this host. It is how the platform# gets permission to start the planner containers it costs your queries in — an# instance without it reports that it cannot plan. Use 0 on Docker Desktop for# Mac and Windows; on Linux, run:# stat -c '%g' /var/run/docker.sockQD_DOCKER_GID=0
# Where planner base images are pulled from, one tag per Postgres major. Ours# are public and need no registry credential. Change this only to point at a# mirror that carries the same tags.QD_PLANNER_BASE_IMAGE_REPOSITORY=ghcr.io/query-doctor/postgres
# The docker network the platform shares with everything it starts — the# planners it costs queries in, and the Collectors it stands beside your# databases. Change it only to run two instances on one host, where one name# cannot serve both.QD_PLANNER_NETWORK=qd-planners
# The Collector image the platform names, both beside each connected database# and to the CI runs that ask it which Collector to pull. Empty is the normal# answer: the platform then names the Collector built from its own commit,# which is the one that speaks its protocol. Set it to a mirror carrying the# same tags if this host or your CI runners cannot reach ghcr.io.QD_COLLECTOR_IMAGE=
# Extra origins allowed to call the API, beyond QD_PUBLIC_URL: a comma-separated# list, and a regular expression, respectively. Empty is the normal answer.QD_SITE_URL_ALT=QD_APP_URL_ALT=
# Whether this instance sells anything. Leave it true: false is our own cloud# running this same file, and the platform then refuses to boot without the# Sendgrid, Sentry and Stripe keys below.QD_SELF_HOSTED=true
# Transactional mail, which is only ever the password-reset email. This# instance is the only place its accounts exist, so leaving this empty means a# person who forgets their password has no way back in.QD_SENDGRID_API_KEY=
# Errors and traces, to a Sentry project you own. Empty initializes no client# and sends nothing. Set, it reports to your project and not to ours.QD_SENTRY_DSN=
# Billing. Empty is the normal answer, and every checkout route then says it is# not configured on this instance. Setting the key makes the four values under# it mandatory: a key with no prices is a misconfiguration, not an instance# that sells nothing.QD_STRIPE_API_KEY=QD_STRIPE_PRICE_ID_PRO_LIFETIME=QD_STRIPE_PRICE_ID_PRO_MONTHLY=QD_STRIPE_PRICE_ID_PRO_YEARLY=QD_STRIPE_ENDPOINT_SECRET=QD_PUBLIC_URL is the one to get right: the address other people reach the instance at, not the one the container binds. Behind a reverse proxy, that is the proxy’s address.
QD_COOKIE_DOMAIN has to be its hostname. Change one without the other and sign-in appears to work, then the next request comes back anonymous.
Every other variable the platform reads is in the environment reference.
3. Start it
Section titled “3. Start it”docker compose -f docker-compose.self-host.yml up -ddocker compose -f docker-compose.self-host.yml logs -f platform4. Set the instance up
Section titled “4. Set the instance up”Open QD_PUBLIC_URL. An instance nobody has set up asks for the first account, a team, a first project, and the database to watch, and writes all of it in one step. You end up signed in, looking at that project.
5. Let other people in
Section titled “5. Let other people in”The compose file above requires approval. Somebody else opening QD_PUBLIC_URL gets a form that asks for access rather than one that signs them up: it creates their account and leaves it holding nothing until you approve it.
A band appears across the top of whatever page you open, saying how many people are waiting. Expand it and each one shows their username and email with Let in and Not now beside them; if you own more than one team, a dropdown picks which they join. Only a team owner sees it, and it is the only notice anyone gets. An instance has no outgoing mail unless you give it a SENDGRID_API_KEY, so nobody was emailed.
Set QD_REGISTRATION_INVITE_ONLY=false if you would rather anyone who can reach the address sign themselves up.
6. Point a repository at it
Section titled “6. Point a repository at it”Setup creates a project for you. Open Setup → Continuous integration and link your repository. The workflow appears on that repository’s page with this instance’s address already in it, so the runs post here.
On the same page, set Comparison branch to the branch your pull requests target, usually your default branch. Set it before you copy the workflow: the workflow’s push trigger names that branch, and only a push to it records the baseline pull requests are compared against. Left empty, a pull request compares against its base branch, a push compares against its own branch’s previous run and fires no alerts, and every run’s comment carries a warning that no comparison branch is set.
Copy the analyzer token from the same screen and store it as the repository’s QUERYDOCTOR_TOKEN secret, then commit the workflow and push it to the comparison branch once, so the first pull request has a baseline to compare against. Or point your agent at this instance and let the MCP server write it.
Planner containers
Section titled “Planner containers”Query Doctor answers “is this query slow” by planning it on a real Postgres — the same major your production runs, carrying your schema and costed under your table sizes. That Postgres is a container the instance starts for itself on this host, and /var/run/docker.sock in the compose file above is how it starts one.
What starts. One container per analysis, on the major the capture came from and holding the extensions its schema uses. Nothing else plans in it, and it stops when the run ends. An instance killed mid-analysis cannot stop the container it was planning in; it removes the ones it left behind when it next starts, and every five minutes after that. Two runs on one pull request are two containers.
What it costs. One base image per major you analyse: about 30 MB to pull and 256 MB on disk, kept after the first time. A running Postgres for each analysis in flight, which is where the memory goes; nothing caps how many run at once. Each run loads your statistics into a fresh container, and the built image is all it inherits from the run before. A schema that needs an extension we do not ship prebuilt has it compiled here on the first analysis, which takes minutes rather than seconds; every run after that reuses the image.
What the socket costs you. Anything that can reach a docker socket can start a privileged container on the host, so handing one over is close to handing over root. Run the instance on a host you would already trust it with, and keep it behind a network boundary you control.
Setting QD_DOCKER_GID. It is the group that owns the socket, and it is what gives the platform’s unprivileged user permission to use it. On Docker Desktop for Mac and Windows the answer is 0. On Linux:
stat -c '%g' /var/run/docker.sockSet it wrong and the instance still starts. The first analysis is what fails, with a message naming this variable.
How the platform reaches them. On a docker network the two share, named by QD_PLANNER_NETWORK. The platform is a container itself, so a planner published on the host’s loopback would be at an address it has no route to — 127.0.0.1 inside a container is that container. Nothing on the host can reach a planner, which is the point: it holds your schema.
The Collectors this instance starts beside your connected databases join that same network. So the database you connect has to be reachable from it: a container on it is addressed by its name, one elsewhere on the host is host.docker.internal, and anything further away by whatever address routes. The instance checks the connection string from that network before it starts a Collector, and tells you there and then when nothing answers.
Which Postgres majors. 13 through 19. A capture from outside that window comes back unsupported rather than planned against the nearest major: a plan from a different major is a different plan, and a verdict read off one would be a guess.
With no runtime at all. An instance with the socket unmounted fails each analysis with a message naming the cause and the change that fixes it, and posts nothing to the pull request. It does not sit waiting for a planner that cannot start.
Postgres versions
Section titled “Postgres versions”The instance’s own database is postgres:18 in the reference compose. To use your own server instead, point DATABASE_URL at a role holding CREATE on the database.
The database Query Doctor analyzes — match the postgres service container in your
workflow to your production major. Query Doctor plans the capture on a planner of the major
that container reports, so that one setting decides the version everything is costed under.
The Collector reads the schema with a pg_dump of the same major as that container, so the
container’s image is the only version setting.
The instance can plan 13 through 19 — see Planner containers.
Upgrading
Section titled “Upgrading”docker compose -f docker-compose.self-host.yml pulldocker compose -f docker-compose.self-host.yml up -dPin a commit SHA instead of latest to choose when you move:
image: ghcr.io/query-doctor/platform:0ca7ba52d8f1c19e3a4b6d7e8f90123456789abcConnecting a database client
Section titled “Connecting a database client”The instance’s database listens only on the machine it runs on. To reach it from a client on that machine, such as psql, a GUI, or a migration tool, connect to 127.0.0.1:
postgres://postgres:[email protected]:5432/query_doctorTo connect from another machine, open the port. Set QD_POSTGRES_BIND=0.0.0.0 and QD_POSTGRES_PORT in your .env, then start the instance again. Set a real QD_POSTGRES_PASSWORD and restrict the port with a firewall first. An open port on the default password exposes the whole database.
Backing up
Section titled “Backing up”docker compose -f docker-compose.self-host.yml exec -T postgres \ pg_dump -U postgres query_doctor > query-doctor-$(date +%F).sql