Kubernetes, Helm and high availability
Run QueryProxy on Kubernetes with the Helm chart, from a one-pod trial on SQLite, or a high-availability setup with PostgreSQL, S3 and separate pods.
Updated:
QueryProxy ships a Helm chart in the repository, at
deploy/helm/queryproxy.
It runs the one published image. With an external application database it starts
that image as separate web, worker and scheduler Deployments that
scale independently. This page is a summary; the chart’s
README
has the full list of values and the details. The chart needs Kubernetes 1.27 or
newer. From 0.4.1 the chart is also published to a registry (see
Install from the OCI registry); otherwise
install it from the chart directory of a checkout at the release tag you want to
run.
Two modes
database.connection |
What the chart renders | Replicas |
|---|---|---|
sqlite (default) |
One Deployment that runs web, worker and scheduler | Exactly 1, Recreate, a volume at storage/app |
pgsql, mysql, mariadb |
web, worker and scheduler Deployments, plus a migration Job |
web and worker as many as you set, scheduler always 1 |
SQLite means one pod. A SQLite file lives on one volume and cannot be shared between pods, so with SQLite the chart installs a single all-in-one Deployment, as docker-compose does. It refuses to render with more than one web replica, with worker autoscaling, or without a persistent volume. Use this mode to evaluate QueryProxy or for a small team; to scale, move to an external database first.
The role split applies only with an external application database. There the
roles are switched with QUERYPROXY_RUN_WORKER and QUERYPROXY_RUN_SCHEDULER: the
web Deployment runs neither, the worker Deployment runs only the queue worker, and
the scheduler Deployment runs only the scheduler. A crash loop or an expensive
query in a worker no longer takes the UI with it. docker-compose is unchanged and
still runs everything in one container.
Install from the OCI registry
Every release tag publishes the chart to GitHub Container Registry as
oci://ghcr.io/queryproxy/charts/queryproxy. The chart version is the release
version without the v. Use these commands in place of the local path in the
examples below:
helm upgrade --install queryproxy oci://ghcr.io/queryproxy/charts/queryproxy --version 0.4.1 \
--namespace queryproxy --create-namespace \
--set admin.email=admin@example.com --set secrets.adminPassword="$(openssl rand -hex 16)"
# Or fetch it first, for example to read values-ha.yaml:
helm pull oci://ghcr.io/queryproxy/charts/queryproxy --version 0.4.1 --untar
The release workflow signs the chart keylessly with cosign (Sigstore, GitHub OIDC identity), the same way it signs the images, and checks the signature itself before it finishes. Verify it with cosign v3 or later before you install. The identity must be the release workflow of the QueryProxy repository at a version tag, issued by GitHub Actions:
cosign verify \
--certificate-identity-regexp '^https://github\.com/QueryProxy/QueryProxy/\.github/workflows/release\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/queryproxy/charts/queryproxy:0.4.1
Replace 0.4.1 with the version you install. A release is only published when
the tag matches version and appVersion in the chart’s Chart.yaml, so the
chart you pull always belongs to the image of the same number.
Try it on SQLite
helm upgrade --install queryproxy deploy/helm/queryproxy \
--namespace queryproxy --create-namespace \
--set admin.email=admin@example.com \
--set secrets.adminPassword="$(openssl rand -hex 16)"
kubectl -n queryproxy port-forward svc/queryproxy 7432:80
Then open http://localhost:7432. config.appUrl defaults to that address.
QueryProxy answers only to the host of APP_URL, the Service’s in-cluster names
(which the chart adds) and config.trustedHosts, so for a real install set
config.appUrl to the address users type, usually the HTTPS URL of the
Ingress.
High availability
The reference profile is values-ha.yaml:
Ingress / LoadBalancer
|
Service (80 -> 7432)
|
+---------------------+----------------------+
| |
web x N (nginx + php-fpm) worker x M (queue:work) scheduler x 1
| | |
+--------------------+-----------------------+------------------+
|
external PostgreSQL / MySQL (users, requests, queue, sessions, audit chain)
|
S3 bucket (result files) target databases (reached by the workers)
The profile sets an external PostgreSQL with sslMode: require, S3 result
storage, two web and two worker pods, spread over zones where the scheduler can
(ScheduleAnyway), one scheduler, an Ingress
with TLS, a PodDisruptionBudget for web and worker, and a NetworkPolicy. Install
it like this:
- Provision PostgreSQL (a managed service or an operator) with a database and a role for QueryProxy, and a private S3 bucket for result files.
- Create the Secret named in
secrets.existingSecretbefore you install; the migration hook needs it. - Replace every
examplevalue invalues-ha.yaml, then:
helm upgrade --install queryproxy deploy/helm/queryproxy \
-f deploy/helm/queryproxy/values-ha.yaml \
--namespace queryproxy --create-namespace
What you size yourself:
- PostgreSQL is the one stateful dependency. It holds the queue, the sessions and the append-only audit chain, so use a replicated, backed-up service. Every pod opens its own connections; put a pooler in front for large worker counts.
- Workers run approved queries. Parallelism is
worker.replicastimesconfig.workerProcesses. A stopping worker pod getsconfig.executionTimeout + 30seconds to finish a running query. - The scheduler is one replica with the
Recreatestrategy. Two schedulers would run every scheduled task (retention pruning, reminders, expiry of time-bound grants) twice.
Secrets
All pods need the same APP_KEY, plus the database password and, when used, the
Redis password, the metrics token and the S3 keys.
- Your own Secret (production). Set
secrets.existingSecret. The expected keys areAPP_KEY,DB_PASSWORDand, as needed,REDIS_PASSWORD,QUERYPROXY_METRICS_TOKEN,QUERYPROXY_ADMIN_PASSWORD,AWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEY. The Secret can come from the External Secrets Operator; the chart README has an example. Restart the pods yourself after rotating it. - Chart-managed Secret (evaluation). Leave
secrets.existingSecretempty. The chart generates theAPP_KEYon first install and keeps it. The Secret surviveshelm uninstallon purpose, because stored connection credentials cannot be decrypted without the key: back it up. - Secrets as files.
secrets.mountAsFiles=truemounts the Secret and sets the*_FILEvariables, so the values do not appear in the pod spec. With IRSA or Workload Identity you can leave the S3 keys out (results.s3.useSecretCredentials=false).
Connection passwords do not have to live in QueryProxy at all. With
external secret references a pod reads them from
Vault, AWS Secrets Manager or Azure Key Vault when a connection opens: IRSA for
AWS, workload identity for Azure and Vault’s Kubernetes login need no stored
credential. Pass the plain settings (QUERYPROXY_VAULT_ADDR,
QUERYPROXY_VAULT_AUTH, QUERYPROXY_VAULT_K8S_ROLE, QUERYPROXY_AWS_REGION,
QUERYPROXY_AZURE_TENANT_ID and the like) through config.extraEnv. Values in
config.extraEnv land in a ConfigMap and in the pod spec as plain text, so never
put QUERYPROXY_VAULT_TOKEN, QUERYPROXY_VAULT_SECRET_ID or
QUERYPROXY_AZURE_CLIENT_SECRET there. Keep them in a Kubernetes Secret whose
keys carry those names and reference it from config.extraEnvFrom:
config:
extraEnvFrom:
- secretRef:
name: queryproxy-secret-store
For single sign-on, pass the QUERYPROXY_OIDC_* variables through
config.extraEnv, and keep the client secret in a Secret that you reference from
config.extraEnvFrom. See Single sign-on.
Migrations and upgrades
With an external database a pre-install,pre-upgrade Job runs
php artisan migrate --force before any pod of the new version starts. If it
fails, the release does not proceed. The ConfigMap, Secret and ServiceAccount are
ordinary release resources, so helm rollback restores the previous
configuration. A rollback does not reverse migrations; follow
Upgrading and rolling back and keep a database backup.
To move an existing SQLite installation to PostgreSQL, stop it and use
php artisan queryproxy:migrate-appdb --to=<dsn>, then install the chart with the
new database.* values.
Autoscaling the workers
worker.autoscaling.enabled=true renders a HorizontalPodAutoscaler on the
external metric queryproxy_queue_depth. It is off by default, because it needs:
metrics.enabled=truewithsecrets.metricsToken.- Prometheus scraping
/metricsthrough the Service name. QueryProxy answers only to trusted host names, so a scrape of a pod IP gets400. - A metrics adapter that serves the metric; the chart’s
values.yamlhas a prometheus-adapter rule.
Scale-down is deliberately slow, because stopping a pod that runs a query waits for it. See Monitoring for the metric.
Network, availability and hardening
- Probes call
GET /upwith the host ofconfig.appUrl. - PodDisruptionBudget with
maxUnavailable: 1for web and worker. The scheduler has none: it is one pod, and a drain restarts it. - NetworkPolicy (
networkPolicy.enabled): onlynetworkPolicy.ingressFrompeers reach port 7432; with an emptyingressFrom(the default) any source can still reach it, and every other port is closed. NetworkPolicy takes effect only if your network plugin enforces it, for example Calico or Cilium. Egress is open by default, because workers must reach every target database you register. WithnetworkPolicy.egress.restrict=truelist DNS, the application database, S3, your target databases and your chat and SIEM endpoints innetworkPolicy.egress.rules. The ingress rule covers every pod of the release, so add the namespace of a Prometheus that scrapes/metricstoingressFrom. - Security context. The pods run as a non-root user with all capabilities dropped and no privilege escalation.
- The root filesystem is still writable. The image’s entrypoint writes to it
at start, so
readOnlyRootFilesystemis off. The chart cannot promise more today.
S3 and Redis
The image carries the S3 adapter and the predis Redis client. Both are optional.
results.disk=s3 stores result files on S3 or an S3-compatible endpoint; without
S3, use a ReadWriteMany volume shared by web and worker. redis.enabled moves
cache, queue and sessions to Redis; everything also works on the database alone.
Next steps
- Production hardening for the checklist that still applies.
- Configuration for every environment variable the chart can pass.
- Backup and restore for the database and the
APP_KEY.