Deploy · Several instances

High availability

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.

Apps · partners · agentsLoad balancerCaddy · health checksInstance:5000Instance:5000unhealthy · skippedEvents:5002PostgreSQLshared storeRedislimits · cacheYour databases
01…
Several instances

High availability

✓PostgreSQL store and Redis: everything an instance owns is shared, so any instance answers any request.
✓Rate limits, quotas and concurrency caps counted across instances.
✓Rolling upgrades; queryapigate events fans live events out to many clients.
StorePostgreSQL
RedisRequired
Live eventsqueryapigate events, routed by the proxy
AvailabilityThe load balancer skips an unhealthy instance
ConfigurationEnvironment, Console or a file
Tested in CIThree real servers, every push
What it needs

What several instances need

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 eventsReads every instance's runs from the store. Route /events to it.
The same secrets everywhereQUERYAPIGATE_API_KEY, QUERYAPIGATE_SECRET_KEY, and every ${VAR} a connection uses.
One gunicorn worker per containerScale with containers, not workers, so each /metrics is one process.
The reference deployment

deploy/scale-out/

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.

shell
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
Caddyfile
{$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
}
Check it

Check that they act as one

shell
curl https://api.example.com/api/v1/instances -H 'X-API-Key: …'
# three "serve" instances and one "events", each "shared_limits": true, and "problems": []
In CI on every pushtests/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.
Verified end to end, before the reference deployment was published
CheckResult
Calls through the load balancerAnswered by the three instances in turn
A key limited to 4/minute, called across instances200 four times, then 429
One instance stopped, twelve callsAll twelve 200: Caddy retried on the others
/eventsRuns arrived through the proxy
/metrics through the proxy403
Rolling upgrades

Replace instances one at a time

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.

  1. Back up the store (pg_dump).
  2. Start one instance on the new version. It upgrades the store. Check its /health.
  3. Replace the others one by one. GET /api/v1/instances shows who runs which version.
  4. Replace queryapigate events along with them.
In CI on every pushThe previous release from PyPI keeps serving, writing and recording runs beside the current code (tests/test_rolling_upgrade.py).
LimitsUpgrade one minor version at a time when rolling (0.41 → 0.42, not 0.40 → 0.42). Once the store is upgraded, an instance can't start on the previous release: if one restarts mid-upgrade, bring it back on the new version. A release that can't keep this rule says so under Breaking, and every instance must stop first. The one case so far was 0.38 (workspaces), which changed the store's primary keys.
Day two

Running several instances

  • Store connections: about 9 per instance (threads + 1), plus one per events or MCP process. Three instances and an events server need about 30, well under PostgreSQL's default 100. For many more, use PgBouncer in session mode; transaction mode breaks the store's advisory locks.
  • Metrics: scrape each instance. The bundled Grafana dashboard sums them. The Console's Metrics screen shows only the instance that served it.
  • Backups: pg_dump of the store's schema, which is safe while serving. queryapigate backup prints the exact command.
  • Alerts to know: 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.
  • Faster calls: 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.

On Kubernetes without Helm

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.
  • A 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.

What changes when you move

Moving between shapes is a configuration change. This is what each step adds; the step for this page is highlighted.

01Single instance
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.
02+ High availability
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 →
03+ Kubernetes
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 →
Before go-live The production checklist, the ports and the measured numbers They apply to every shape, so they live on the Deploy overview.