Several interchangeable instances behind a load balancer, sharing a PostgreSQL store and Redis, so any instance answers any request and limits are counted once. Tested with three real servers on every push.
queryapigate events fans live events out to many clients.queryapigate events, routed by the proxy| Why | |
|---|---|
A PostgreSQL store (QUERYAPIGATE_DATABASE_URL) | Connections, saved queries and versions, keys, roles, history and the audit log are shared. Separate SQLite volumes would make separate servers. |
Redis (QUERYAPIGATE_REDIS_URL) | Rate limits, quotas, concurrency caps and the response cache are counted once. Without Redis each instance counts on its own, and an alert says so. |
queryapigate events | Reads every instance's runs from the store. Route /events to it. |
| The same secrets everywhere | QUERYAPIGATE_API_KEY, QUERYAPIGATE_SECRET_KEY, and every ${VAR} a connection uses. |
| One gunicorn worker per container | Scale with containers, not workers, so each /metrics is one process. |
Three instances, PostgreSQL, Redis, the events server and Caddy, in one Compose file. Caddy round-robins across the instances, drops one that fails /health, and retries a request on another if one can't be reached. To add an instance, copy a block.
cd deploy/scale-out cp .env.example .env && chmod 600 .env # pin QUERYAPIGATE_IMAGE; set the key, passwords and SITE_ADDRESS docker compose up -d docker compose ps # six services, healthy
{$SITE_ADDRESS} {
reverse_proxy /events* events:5002
reverse_proxy queryapigate-1:5000 queryapigate-2:5000 queryapigate-3:5000 {
lb_policy round_robin
lb_try_duration 5s
health_uri /health
health_interval 10s
}
respond /metrics 403 # scrape the instances directly
}curl https://api.example.com/api/v1/instances -H 'X-API-Key: …'
# three "serve" instances and one "events", each "shared_limits": true, and "problems": []tests/test_multinode.py runs three real servers and an events server on one PostgreSQL and Redis. Every check makes a change through one node and looks for it on another: keys, query versions, connections, rate limits, the cache, history, the audit log and live events.| Check | Result |
|---|---|
| Calls through the load balancer | Answered by the three instances in turn |
A key limited to 4/minute, called across instances | 200 four times, then 429 |
| One instance stopped, twelve calls | All twelve 200: Caddy retried on the others |
/events | Runs arrived through the proxy |
/metrics through the proxy | 403 |
Within a release line the store changes only additively: new tables, columns and indexes, never a rename or a drop. So instances still on the previous release keep working on a store the new release has upgraded.
pg_dump)./health.GET /api/v1/instances shows who runs which version.queryapigate events along with them.tests/test_rolling_upgrade.py).pg_dump of the store's schema, which is safe while serving. queryapigate backup prints the exact command.instances_not_shared (some instances run without Redis), instances_versions_differ (a rolling upgrade hasn't finished), rate_limits_not_shared (Redis is unreachable, so limits fall back to per instance). A Redis outage degrades the cache to a miss and never fails a request.QUERYAPIGATE_METADATA_CACHE_TTL=5 gives 16-23% more per worker and halves the store's load. The trade-off: a key revoked on another instance keeps working here for up to that many seconds.deploy/kubernetes/ has the same shape as plain manifests: three replicas, the events server, a PodDisruptionBudget and an Ingress. A test keeps them in step with what the chart renders. Settings that running it turned up:
enableServiceLinks: false: a Service named queryapigate-events would otherwise inject QUERYAPIGATE_EVENTS_PORT=tcp://…, which is one of QueryAPIGate's own settings.preStop pause and a 45-second grace period. Without them a rolling update dropped 2 of 1,633 requests; with them, 0 of 1,896.maxUnavailable: 0, maxSurge: 1, and readiness and liveness probes on /health.Moving between shapes is a configuration change. This is what each step adds; the step for this page is highlighted.
QUERYAPIGATE_API_KEY=…
QUERYAPIGATE_SECRET_KEY=…
One process or container, with the store as SQLite on a volume. The key is required in a container; the secret key encrypts connection passwords at rest.
QUERYAPIGATE_DATABASE_URL=postgres://…
QUERYAPIGATE_REDIS_URL=redis://…
A shared PostgreSQL store and Redis, so any instance answers any request and limits are counted once. Run queryapigate events behind the proxy for live events. Guide 42 →
store.type: postgres · replicas: 3
existingSecret: queryapigate
The same two URLs and the key live in one Secret; the chart refuses several pods on SQLite, several without Redis, PostgreSQL without a database URL, and secrets put in config. Guide 54 →