---
title: "Multi-tenant"
description: "Put queen_proxy in front of a broker: per-tenant credentials, quotas and metering, and the path from an empty cell to a tenant pushing through it."
---

> 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

# Multi-tenant

`queen_proxy` is a second Rust binary that stands in front of a broker and turns it into
something you can hand to other people. Both are Apache-2.0, so the multi-tenant service is
yours to run: for internal teams, for customers, or under a product of your own.

The broker already scopes queues by tenant on its own, behind `QUEEN_TENANCY_HEADER`
([broker-native tenancy](/reference/multi-tenant/broker-tenancy)). What the proxy adds is the
reason to trust that header: it spends the client's credential at the edge, strips it, and
injects the tenant itself. Around that it holds what a shared broker has no place holding.
Per-cluster API keys and human logins
([credentials](/reference/multi-tenant/auth)), plan limits on rate, size and count
([quotas](/reference/multi-tenant/quotas)), a route classifier that forwards nothing it cannot
name ([endpoints](/reference/multi-tenant/endpoints)), and usage counted from the per-item
statuses the broker actually returned ([metering](/reference/multi-tenant/metering)).

## The shape

A cell is one proxy, one broker, one PostgreSQL for the messages. The proxy needs a second
PostgreSQL of its own, *pxdb*, carrying the tenants, clusters, keys, plans and usage of the
whole fleet. The proxy reads it through a 30-second cache, which is what keeps a pxdb outage
from stopping the data plane.

Three nouns carry the model:

- **Tenant**: the organisation. Owns users and clusters.
- **Cluster**: the tenant-visible Queen. One hostname, one plan, one queue namespace.
- **Cell**: one physical stack, on one failure domain.

A cluster lives on exactly one cell and never spans two. Every limit rests on that: a cluster's
traffic can only arrive at the one proxy fronting its cell, so the counters are plain in-process
state, exact with no Redis and nothing to coordinate. One proxy per cell is the assumption the
code is written against.

Routing is a DNS label. The proxy reads the cluster slug from the first label of the `Host`
header, so `acme-prod.eu1.example.com` is the cluster `acme-prod`, and one wildcard record
covers every cluster on the cell.

## Bring up a cell

1. **Mint the cell secret.** The broker holds an HMAC secret; the cell row holds a token signed
   with it. The same string does not go in two places.

```bash
CELL_JWT_SECRET=$(openssl rand -hex 32)
b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
header=$(printf '{"alg":"HS256","typ":"JWT"}' | b64url)
claims=$(printf '{"sub":"queen-proxy","role":"admin"}' | b64url)
sig=$(printf '%s.%s' "$header" "$claims" | openssl dgst -binary -sha256 -hmac "$CELL_JWT_SECRET" | b64url)
CELL_SECRET="$header.$claims.$sig"
```

   The `admin` role is required, because the proxy forwards queue and consumer-group deletions.
   There is no `exp` on purpose: nothing refreshes this value, so rotate it by minting a new
   one.

2. **Run the broker,** on a network policy that admits the proxy and nobody else.

```bash
JWT_ENABLED=true JWT_ALGORITHM=HS256 JWT_SECRET="$CELL_JWT_SECRET" QUEEN_TENANCY_HEADER=true QUEEN_KV_TRUSTED_PROXY=true ./bin/queen
```

   The second of those two is not optional and the broker **exits 1** without it. The tenant
   header is opaque and validated against nothing, and [key/value state](/use/kv) is addressable
   purely by name, so a forged header reads and writes another tenant's state knowing only that
   name. Queues survive a forged header because a caller must still name a queue and an ack
   carries a lease id it cannot guess; the KV has no such id. Since the KV is part of the engine
   on every cell and no flag can take it away, the requirement is unconditional: setting
   `QUEEN_KV_TRUSTED_PROXY=true` is you stating that step 3 is what supplies the header and
   strips the client's. If that is not true of your deployment, do not run with the header.

3. **Run the proxy.** `PXDB_HOST` is the only variable it refuses to start without, and the six
   migrations that create the `queen_proxy` schema are embedded in the binary and applied at
   boot. Generate `QUEEN_PROXY_JWT_SECRET` once and reuse it: sessions are signed with it, so
   changing it logs every human out.

```bash
SESSION_SECRET=$(openssl rand -hex 32)
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="$SESSION_SECRET" -v queen-proxy-spool:/app/spool ghcr.io/queen-mq/queen-proxy:latest
```

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

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

   How to run it in production, with Docker or Kubernetes, and why it stays on one replica:
   [Proxy](/deploy/proxy).

4. **Register the cell.** No SQL function creates one: cells are fleet infrastructure.
   `base_url` must be `http://`, an assertion that broker traffic is cell-internal with TLS
   terminated at the proxy edge.

```bash
psql -h 10.0.3.20 -U postgres -d queen_proxy -v cell_secret="$CELL_SECRET" <<'SQL'
INSERT INTO queen_proxy.cells (slug, region, base_url, class, cell_secret)
VALUES ('eu1-shared-a', 'eu1', 'http://10.0.3.11:6632', 'shared', :'cell_secret');
SQL
```

5. **Point a wildcard DNS record at the proxy,** so every cluster slug on this cell resolves to
   it.

## Onboard a tenant

Nothing over HTTP creates a tenant. Every mutation of tenants, clusters, users, keys and roles
is a SQL function in the `queen_proxy` schema, which is what keeps the audit row and the cache
invalidation from being optional. One call does the whole onboarding, and unwinds completely if
any part of it raises.

```sql
SELECT queen_proxy.bootstrap_tenant(
  'acme',                    -- tenant slug
  'Acme Inc',                -- tenant name
  'acme-prod',               -- cluster slug, and the subdomain
  'free',                    -- plan code
  (SELECT id FROM queen_proxy.cells WHERE slug = 'eu1-shared-a'),
  'ops@acme.example',        -- admin email
  'initial-password',        -- NULL for an OAuth-only admin
  'default'                  -- API key name
);
```

It returns `{tenant_id, cluster_id, user_id, api_key}`. The key is plaintext, shown exactly
once, and only its sha256 hash is stored; a re-run on the same slugs returns the existing ids
with a null key.

That key is everything a client needs. Same routes, same SDKs, same wire format as a bare
broker, with the hostname doing the routing:

```bash
curl -s -X POST http://acme-prod.eu1.example.com:6711/api/v1/push \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"items":[{"queue":"orders","partition":"cust-42","payload":{"hello":"world"}}]}'
```

The plan named in that call decides the numbers: requests and messages a second, queues,
partitions per queue, parked long polls, payload and batch size, retained bytes and retention
window. Four plans ship seeded, and a single cluster is raised above its plan with a jsonb
column rather than a forked plan
([the data model](/reference/multi-tenant/tenants)).

Afterwards, the same schema carries the rest of the lifecycle, one function per operation so the
audit row and the cache invalidation are never optional:

```sql
-- another human on the tenant, and their role on one cluster
SELECT queen_proxy.create_user(:tenant_id, 'dev@acme.example', NULL, 'google');
SELECT queen_proxy.grant_cluster_role(:cluster_id, 'dev@acme.example', 'admin');

-- a second API key, for a service that should be revocable on its own
SELECT queen_proxy.issue_api_key(:cluster_id, 'ingest-worker', :key_hash, ARRAY['push']);
```

A password hash of `NULL` is the OAuth-only user: they exist, they have a role, and they can
only arrive through Google.

## Human logins with Google

API keys are for programs. People sign in to the dashboard, and the proxy speaks OAuth for that:
Google and GitHub, plus local bcrypt passwords. Google needs two variables.

```bash
GOOGLE_CLIENT_ID=...apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=...
GOOGLE_ALLOWED_DOMAINS=acme.example,acme.io   # optional, comma separated
```

In the Google Cloud console the authorised redirect URI is `<public base>/auth/google/callback`,
and getting that base right is the one thing that breaks a first setup. The proxy uses
`QUEEN_PROXY_PUBLIC_URL` when it is set, otherwise it rebuilds the URL from the request's `Host`
and `X-Forwarded-Proto`. **With `QUEEN_PROXY_AUTH_HOST` on and no public URL set, OAuth is
switched off rather than guessed**, which is the correct behaviour for a dedicated login host and
a confusing one if you did not mean it.

`GOOGLE_ALLOWED_DOMAINS` restricts who may sign in, by the account's hosted domain or its email
domain. A single entry is also passed to Google as the `hd` hint, so the account chooser shows
that domain first.

What happens when someone arrives is worth knowing exactly, because two of the three outcomes are
refusals:

- The Google identity is already linked to a user: they are logged in.
- No link, but the **verified** email matches a user: the identity is linked to that user. An
  unverified email never links, which is what stops an account being claimed by someone who
  merely typed the address.
- Neither: the login is **denied**, unless `QUEEN_PROXY_AUTOPROVISION` is on, in which case a
  user is created with the role in `QUEEN_PROXY_DEFAULT_ROLE`.

And the part that makes this safe to leave on: **a session is not cluster-scoped**. The role
inside the session token is a placeholder, and the real permission is resolved live from the
cluster role rows. A brand-new Google user with no `grant_cluster_role` can sign in and see
nothing at all, so auto-provisioning hands out an account, never an authorisation.

## Turn enforcement on

`QUEEN_PROXY_ENFORCE` defaults to `false`, and that is shadow mode: every rate and quota
decision is computed, logged and metered, and the request proceeds anyway. Size caps and the
two push-block quotas are hard regardless of the flag
([quotas](/reference/multi-tenant/quotas)). Watch a real workload through it, then set it to
`true` and confirm on `/healthz`, which reports the flag precisely because a boot log survives
the process that wrote it.

One proxy per cell, one cell per cluster, one SQL call per tenant, and a `429` contract the SDKs
already honour.

- [Data model](/reference/multi-tenant/tenants) — Tables, statuses, the seeded plans, and every provisioning function.
- [Quotas and rate limits](/reference/multi-tenant/quotas) — What is enforced, what only warns, and what ignores shadow mode.
- [Isolation](/reference/multi-tenant/isolation) — What the design guarantees, and which broker surfaces a tenant must never reach.
- [Dashboard](/deploy/dashboard) — The same bundle, served by the proxy with real users and per-cluster roles.

Source: https://queenmq.com/deploy/multi-tenant/index.mdx
