queen_proxy is a second Rust binary that stands in front of a broker. It does two jobs, and
most deployments want only one of them.
As a front door, it terminates human identity: the dashboard, Google or GitHub logins, and per-cluster roles. The broker has no concept of a user, so this is where one lives.
As a multi-tenant edge, it also spends API keys, injects the tenant, enforces plan quotas and meters usage. That is multi-tenant; this page is how to run the process either way.
What it needs
One PostgreSQL of its own, pxdb, which can be a separate database or just a separate schema on
the broker’s. The proxy owns the queen_proxy schema there and applies its six migrations at
boot, so nothing has to be created first.
Point it directly at PostgreSQL, not through a pooler. It holds a permanent dedicated
LISTEN connection outside its pool, which is exactly the kind of long-lived session a
transaction-mode pooler recycles underneath.
Docker
docker run -d --name queen-proxy -p 6711:6711 \
-e PXDB_HOST=10.0.3.20 \
-e PXDB_PASSWORD=change-me \
-e QUEEN_PROXY_JWT_SECRET="$(openssl rand -hex 32)" \
-v queen-proxy-spool:/app/spool \
ghcr.io/queen-mq/queen-proxy:latestPXDB_HOST is the only variable it refuses to start without. Keep QUEEN_PROXY_JWT_SECRET
stable: sessions are signed with it, so changing it logs every human out. The spool volume is
where metering that could not be written yet is parked, so losing it loses usage rather than
traffic.
curl -s http://127.0.0.1:6711/healthz{"enforce":false,"status":"ok","tenant_header":true}Kubernetes
apiVersion: apps/v1
kind: Deployment
metadata:
name: queen-proxy
spec:
# One. See "Why one replica" below before changing this.
replicas: 1
selector:
matchLabels: { app: queen-proxy }
template:
metadata:
labels: { app: queen-proxy }
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
containers:
- name: queen-proxy
image: ghcr.io/queen-mq/queen-proxy:1.0.0
command: ["/app/bin/queen-proxy"]
ports:
- containerPort: 6711
env:
- name: PXDB_HOST
value: postgres.internal
- name: PXDB_PORT # direct, not the pooler
value: "5432"
- name: PXDB_DB
value: queen
- name: PXDB_USER
value: queen_owner
- name: PXDB_PASSWORD
valueFrom:
secretKeyRef: { name: queen-postgres, key: PG_PASSWORD }
- name: QUEEN_PROXY_JWT_SECRET
valueFrom:
secretKeyRef: { name: queen-proxy, key: JWT_SECRET }
- name: QUEEN_PROXY_PUBLIC_URL
value: https://queen.example.com
- name: QUEEN_PROXY_COOKIE_DOMAIN
value: queen.example.com
startupProbe:
httpGet: { path: /healthz, port: 6711 }
periodSeconds: 5
readinessProbe:
httpGet: { path: /healthz, port: 6711 }
periodSeconds: 10
livenessProbe:
httpGet: { path: /healthz, port: 6711 }
periodSeconds: 20Routing is the first DNS label of the Host header, matched against a cluster slug, so
queen-prod.example.com acts on the cluster queen-prod and one wildcard record covers the rest.
A port-forward therefore needs the header spelled out, or the request has no cluster:
curl -H "Host: queen-prod.example.com" http://127.0.0.1:6711/api/v1/queuesConfiguration
Every setting is an environment variable, parsed the way the broker parses its own: only the
literal true is truthy, an integer that does not parse falls back to its default instead of
failing the boot, and an optional variable set to the empty string counts as unset.
The listener and TLS
| Variable | Default | What it does |
|---|---|---|
QUEEN_PROXY_PORT |
6711 |
Bind port, plaintext or TLS: there is one listener either way. |
QUEEN_PROXY_BIND_ADDR |
0.0.0.0 |
Bind host, every interface by default. A host or IP only, so a value carrying its own port exits 1 at boot instead of binding somewhere unintended. |
QUEEN_PROXY_TLS_CERT |
unset | Path to a PEM certificate chain. |
QUEEN_PROXY_TLS_KEY |
unset | Path to the matching PEM private key. |
The two TLS variables are both or neither. Set both and the proxy serves HTTPS itself on rustls
with the ring provider, logging TLS listener enabled (rustls/ring); set neither and it serves
plaintext, which is the default. Setting exactly one is a boot failure, not a downgrade: the
process logs the missing half and exits 1 before the port is bound, so a half-configured listener
can never quietly answer in the clear. Both files are read and parsed before the bind for the same
reason. The key may be a PRIVATE KEY, RSA PRIVATE KEY or EC PRIVATE KEY block, and the cert
file must contain at least one CERTIFICATE block.
The control-plane database
| Variable | Default | What it does |
|---|---|---|
PXDB_HOST |
unset | Hostname of pxdb. Its presence is the switch: unset means no control plane at all. |
PXDB_PORT |
5432 |
Port. Point it at PostgreSQL directly, not at a pooler. |
PXDB_USER |
postgres |
Role the proxy connects as. |
PXDB_PASSWORD |
empty | Password for that role. |
PXDB_DB |
queen_proxy |
Database name. The proxy owns the queen_proxy schema inside it. |
PXDB_USE_SSL |
false |
true connects over TLS. |
PXDB_SSL_REJECT_UNAUTHORIZED |
true |
With SSL on, true validates the chain against the Mozilla roots compiled into the binary; false encrypts and accepts any certificate. |
PXDB_POOL_SIZE |
16 |
Maximum pooled connections. The dedicated LISTEN connection sits outside this pool, so budget one more. |
The two SSL variables behave exactly like the broker’s PG_USE_SSL and PG_SSL_REJECT_UNAUTHORIZED
(PostgreSQL TLS), including the part that catches people: most
managed PostgreSQL presents a privately rooted chain, so true fails validation and false is the
usual answer, at the cost of not proving whose key answered.
Two things about pxdb are boot failures rather than degraded modes. An unreachable database is one:
the proxy takes a connection and runs SELECT 1 before it serves anything, and a failure there, or
in the migrations that follow, logs and exits 1. Leaving PXDB_HOST unset is the other, unless
QUEEN_PROXY_DEV_CELL_URL is set instead; with neither, the process logs nothing to serve and
exits 1 rather than starting an empty proxy.
Timeouts
| Variable | Default | What it does |
|---|---|---|
QUEEN_PROXY_UPSTREAM_TIMEOUT_MS |
35000 |
Ceiling on one broker call, for every request that is not a long-poll pop. |
QUEEN_PROXY_LONGPOLL_MAX_MS |
90000 |
Clamp on the wait a client may ask for on wait=true. |
QUEEN_PROXY_LONGPOLL_MARGIN_MS |
10000 |
Added on top of that clamped wait. |
A long-poll pop does not use QUEEN_PROXY_UPSTREAM_TIMEOUT_MS at all. Its budget is the client’s
timeout query parameter, or 30000 ms when absent, clamped to QUEEN_PROXY_LONGPOLL_MAX_MS and
then increased by QUEEN_PROXY_LONGPOLL_MARGIN_MS, so the broker’s own wait always expires first
and the client gets an empty pop rather than a gateway error. When a timeout does fire, the proxy
answers its own 504 {"code":"upstream_timeout"}, records the request and does not bill it
(usage metering).
Cache staleness and token revocation
| Variable | Default | What it does |
|---|---|---|
QUEEN_PROXY_STALE_GRACE_MS |
600000 |
How long past a cache entry’s TTL the last known-good cluster row may still be served, and only when pxdb failed to answer. |
QUEEN_PROXY_REVOCATION_SWEEP_MS |
3600000 |
Interval of queen_proxy.sweep_revoked_tokens(). 0 disables the sweep. |
QUEEN_PROXY_STALE_GRACE_MS is fail-open for known-good rows only. A clean answer of “no such row”
is refused immediately whatever the window says, so the grace covers a pxdb outage and never covers
a deleted cluster. It is also the number to read as the worst case in the other direction: nothing
is refreshed while pxdb is down, so a suspension, a deletion or a key revocation decided during the
outage takes up to this long to bite. Ten minutes is the default trade.
The revocation sweep is pure garbage collection, since a deny-list row whose own exp has passed
can no longer change a verification’s outcome. Its first tick fires immediately at startup, and the
whole task is skipped with a log line when there is no pxdb. The deny-list policy for a failed
lookup is a different variable, QUEEN_PROXY_REVOCATION_STRICT
(credentials and authorization).
Dev only
| Variable | Default | What it does |
|---|---|---|
QUEEN_PROXY_DEV_INSECURE |
false |
true skips authentication on the data plane entirely. |
Two surfaces refuse to work in that mode, deliberately.
The cluster console API requires a human session and answers
403 to the synthetic API-key principal, and x-queen-act-cluster is rejected with a 403 naming
dev-insecure, because there is no session to check the act-as against.
Why one replica
The broker scales by starting more copies, and the proxy does not. Rate limits, the parked long-poll gauges and the admission registry are per-process, with no shared store, so three replicas admit three times the configured caps.
That is a deliberate trade: exact counters with nothing to coordinate, on the assumption of one proxy per cell. Run one replica and let Kubernetes restart it; a rolling update briefly runs two, which is a short overshoot of the limits rather than a correctness problem, since the proxy holds no session state that a restart could lose.
The single-tenant front door
The way this is deployed at Smartness is worth describing, because it is the common case and it is not the multi-tenant one.
The proxy is not on the data path. Every workload dials the broker’s own Service directly, carries no credential, and never meets the proxy. The proxy exists for the dashboard and the humans who log into it, which is why it can be deployed, upgraded and broken independently of the brokers moving messages.
With one tenant there is one cluster row, and the broker’s tenancy is the fixed default tenant
00000000-0000-0000-0000-000000000001, which the proxy injects and the broker recognises. Nothing
else changes: the same binary, the same pxdb, one bootstrap_tenant call instead of many.
For a laptop or a demo, where there is no pxdb at all, the static form is the honest one:
QUEEN_PROXY_DEV_CELL_URL points at a broker, QUEEN_PROXY_DEV_CELL_TOKEN carries its cell
token, and QUEEN_PROXY_DEV_TENANT defaults to the same fixed tenant.
Bringing one up in order
-
Create the database or schema and the role the proxy will use.
-
Boot the proxy once. It applies its migrations and starts answering
/healthz. Until the control plane has rows, the dashboard’s own API calls answer421and the site looks dead rather than empty, so do not announce it yet. -
Bootstrap the control plane: the cell row, then
bootstrap_tenant(multi-tenant). -
Register the OAuth redirect as
<public url>/auth/google/callback. The path matters:/apiis the proxy’s own prefix, so a redirect under it is rejected before it reaches the OAuth handler rather than logging anyone in. -
Point DNS at it, wildcard if there will be more than one cluster.