The root Dockerfile is a multi-stage build producing a static binary on
distroless (nonroot), entrypoint pdbq, default command serve:
$ make docker-build
$ docker run -e PDBQ_DATABASE_URL=postgres://... -p 8080:8080 pdbq:devDistroless has no shell, so run health checks from the orchestrator against
GET /healthz (/readyz is an alias).
| Path | Purpose |
|---|---|
POST /graphql |
the API (GET with ?query= also supported) |
GET / |
GraphiQL (opt-in via server.graphiql: true; off by default) |
GET /healthz, /readyz |
liveness/readiness |
GET /schema.graphql |
generated SDL (opt-in via server.expose_schema: true; off by default) |
GET requests carry the query and variables in the URL, so proxy, load balancer, and server access logs along the path will record them. If your queries or variables are sensitive, use POST only and consider stripping or truncating the query string in intermediary log formats.
- Terminate TLS in front of pdbq. The server speaks plain HTTP only — JWTs, claims and all query data cross the wire in cleartext until a TLS-terminating reverse proxy, load balancer or ingress controller sits in front. Never expose the pdbq port directly to untrusted networks.
rls.enabled: truewith a non-superuser connection role that has been granted the request roles (GRANT anonymous, app_user TO pdbq_conn).rls.auth.mode: jwtwith a strongjwt_secret(orheadersstrictly behind a gateway that strips inboundX-Pdbq-Claim-*).errors.detail: prod(default) — constraint violations pass through, internals do not. Usestrictif constraint/column names in error messages are themselves sensitive (hides all database error messages).- Leave
server.graphiqlandserver.expose_schemaoff (their defaults) unless you want the playground and full SDL public — both hand an attacker a map of every table, column and relation. GraphQL introspection reveals the same map; setserver.disable_introspection: trueto close that channel too (breaks GraphiQL and schema-aware clients). - Boot from a schema cache (see caching.md) so the runtime
role needs no catalog privileges; leave
watch.enabledoff. - Set
server.max_depth/server.max_costto fit your workload;database.statement_timeout(default 30s) backstops runaway queries. - Resource limits:
database.max_connsbounds the pool; each request uses at most one connection.
server.apq: trueenables Apollo automatic persisted queries: clients sendextensions.persistedQuery = {version: 1, sha256Hash}instead of the query text; a miss returns the standardPersistedQueryNotFoundresponse and the client retries once with the full document, registering it in an in-memory cache (bounded, FIFO eviction; empty after every restart until clients re-register).server.persisted_queries_pathpreloads a JSON file of{"<sha256 hex>": "<GraphQL document>"}entries (validated at startup, never evicted).server.persisted_only: truelocks the endpoint down to persisted queries: requests without apersistedQueryextension are rejected. Combine with a file (andapq: false) for a strict build-time allowlist — client registration is refused withoutapq.
pdbq has no built-in rate limiter. The per-request protections
(server.max_body_bytes, server.request_timeout, server.max_depth,
server.max_cost, server.max_page_size, database.statement_timeout)
bound the cost of a single request, but nothing stops a client from sending
many maximally-expensive requests in parallel.
Run a rate limiter in front of pdbq in production:
- nginx:
limit_req_zone $binary_remote_addr zone=graphql:10m rate=10r/s;pluslimit_req zone=graphql burst=20;on the/graphqllocation. - Envoy:
envoy.filters.http.local_ratelimit(per-instance token bucket) or the global rate limit service for cluster-wide budgets. - Cloud load balancers / API gateways: most support per-client request budgets out of the box.
Key by client IP at minimum; if you terminate JWTs at a gateway, keying by the authenticated subject gives fairer budgets than IP alone.