Skip to content

Proxy

Deploy queen_proxy with Docker or Kubernetes, in front of one broker as a single-tenant front door or as the multi-tenant edge, and why it runs one replica.

Updated View as Markdown

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:latest

PXDB_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: 20

Routing 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/queues

Configuration

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

  1. Create the database or schema and the role the proxy will use.

  2. Boot the proxy once. It applies its migrations and starts answering /healthz. Until the control plane has rows, the dashboard’s own API calls answer 421 and the site looks dead rather than empty, so do not announce it yet.

  3. Bootstrap the control plane: the cell row, then bootstrap_tenant (multi-tenant).

  4. Register the OAuth redirect as <public url>/auth/google/callback. The path matters: /api is the proxy’s own prefix, so a redirect under it is rejected before it reaches the OAuth handler rather than logging anyone in.

  5. Point DNS at it, wildcard if there will be more than one cluster.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close