---
title: "Proxy"
description: "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."
---

> Queen MQ documentation, for AI agents
> Complete self-contained summary of Queen MQ: https://queenmq.com/llms-brief.txt
> Fetch that first when the question is about the product rather than about this page.
> Index of all pages: https://queenmq.com/llms.txt

# Proxy

`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](/deploy/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

```bash
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.

```bash
curl -s http://127.0.0.1:6711/healthz
```

```json
{"enforce":false,"status":"ok","tenant_header":true}
```

## Kubernetes

```yaml
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:

```bash
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.

> **Caution**
>
> The proxy speaks **plaintext HTTP upstream to the broker**, whatever the listener does. That leg is
> assumed to be inside the cell network, so `QUEEN_PROXY_TLS_CERT` protects the client-to-proxy hop
> only.

### 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](/reference/security/postgres-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](/reference/multi-tenant/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](/reference/multi-tenant/auth)).

### Dev only

| Variable | Default | What it does |
| --- | --- | --- |
| `QUEEN_PROXY_DEV_INSECURE` | `false` | `true` skips authentication on the data plane entirely. |

> **Danger**
>
> `QUEEN_PROXY_DEV_INSECURE=true` does not weaken authentication, it removes it. Every request
> becomes an API-key principal with all four scopes and a nil key id, before any credential is read,
> so anything that can reach the port can produce, consume and administer queues on the cluster the
> `Host` header resolves to. Never set it anywhere a network can reach.

Two surfaces refuse to work in that mode, deliberately.
The [cluster console API](/reference/multi-tenant/console) 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.

> **Caution**
>
> **`QUEEN_PROXY_DEFAULT_CLUSTER` is not the way to do this.** It answers every unresolvable `Host`
> with that cluster, so an in-cluster caller using the Service DNS, a raw pod-IP request or a
> spoofed header all silently act on it. It is harmless while exactly one cluster row exists and a
> cross-cluster misroute the moment there are two. Leave it unset and let the `Host` resolve, even
> with a single tenant.

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](/deploy/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.

- [Multi-tenant](/deploy/multi-tenant) — Cells, tenants, clusters, API keys, quotas and the Google login policy.
- [Dashboard](/deploy/dashboard) — The same bundle the broker serves, with real users and per-cluster roles once the proxy is in front.
- [Route classes](/reference/multi-tenant/endpoints) — What the proxy forwards, what it serves itself, and what it refuses to name.

Source: https://queenmq.com/deploy/proxy/index.mdx
