Operations
How to deploy, run, monitor and roll back the gateway in production, for operators, DBAs and security operations. Platform-wide steps (first deployment, images, rollback) are in platform operations, every variable is in Configuration, and diagnosis by symptom is in Troubleshooting.
Read byPlatform operatorSecurity engineerDatabase administrator
The gateway is stateless: sessions, rate-limit windows and cached decisions live in Redis or Postgres, so you can replace any replica at any time. An ingress terminates TLS in front of it.
Deploy#
- Follow the platform first deployment.
- Set the variables production requires: session secret, identity signing key, OIDC client, Access, at least one rate limit, and shared state. Use the same
GATEWAY_SESSION_SECRETandGATEWAY_IDENTITY_SIGNING_KEYon every replica. - Choose a shared-state backend and create its roles.
- Put an ingress in front.
- Run the control plane to manage routes or sessions at runtime.
- Set up monitoring and walk the deployment review.
Shared state#
The URL scheme picks the backend, for example rediss://:<password>@redis:6379/0 or postgres://user:<password>@postgres:5432/gateway; cluster and Sentinel URLs are in Configuration. Verify the server certificate with GATEWAY_SESSION_TLS_CA and GATEWAY_SHARED_STATE_TLS_CA.
Both backends pass the same tests. Redis is faster; Postgres roughly halves request-path throughput. Compare them on your servers with bun run benchmark (measurements, spec §12.6).
Postgres notes:
- Schema (
gateway.sessions,gateway.access_decisions,gateway.rate_limit_bucketsand two functions) is built on connect whenGATEWAY_POSTGRES_AUTO_MIGRATE=true, the default outside production. To apply it yourself, setfalseand runbun run postgres:migrate -- postgres://user:<password>@host:5432/gateway. - Pool: ten connections per replica; the server needs replicas × pool. For many replicas add a pooler rather than a bigger pool.
- Durability:
GATEWAY_POSTGRES_SYNCHRONOUS_COMMIT=offby default; a crash costs a caller at most a few requests of budget (spec §12.5).
Roles#
The gateway reads the route table and writes nothing in it; only the control plane writes it (spec §12.8).
| Backend | Gateway | Control plane | Setup |
|---|---|---|---|
| Postgres | gateway_app | control_app | bun run postgres:migrate -- --roles postgres://admin:<password>@host:5432/gateway, with POSTGRES_GATEWAY_PASSWORD and POSTGRES_CONTROL_PASSWORD (or ALTER ROLE). |
| Redis | gateway | gateway-control | Load deploy/redis/gateway.acl as the server's aclfile, then set passwords on every start (below). |
redis-cli ACL SETUSER gateway resetpass >"$REDIS_GATEWAY_PASSWORD"
redis-cli ACL SETUSER gateway-control resetpass >"$REDIS_CONTROL_PASSWORD"Control plane#
gateway-control is the admin surface: same image, separate process, its own socket, acting on the shared state directly (spec §12.7). The gateway has no admin endpoint.
- Keep its socket internal. It binds loopback by default (
GATEWAY_CONTROL_HOST); never route it through the ingress. - Give it a token map in
GATEWAY_CONTROL_TOKENS_FILE, stored as a secret. Scopes:read;operate(also purges sessions and decisions);config(also writes the route table). Never reuse another secret as a token: a token equal to the shared-state password stops the process, as does an unreadable map. - Give it its own shared-state credentials.
With GATEWAY_CONFIG_SOURCE=store, replicas watch the active route-table revision in the shared state. An empty store falls back to the GATEWAY_CONFIG file; an unreachable one keeps the last valid table. Writes are compare-and-swap on If-Match, and GET /v1/replicas lists live replicas by heartbeat.
Changing the route table#
Send the token as Authorization: Bearer <token> (or in x-gateway-control-token when the client cannot set Authorization). Reads need the read scope; writes need config.
- Read the active revision and table with
GET /v1/config. - Validate the new table with
POST /v1/config/validate. The answer lists errors, conflicts, warnings and the diff against the active table;422means the table is invalid. Nothing is written. - Write it with
PUT /v1/config, naming the revision you read inIf-Match(with or without quotes). A409means another write landed first (read again and retry) or the table changes a route inGATEWAY_PROTECTED_ROUTES(leave that route unchanged). - Watch
GET /v1/replicasuntil every replica reports the new revision and no last error. - To undo, write an older revision again with
POST /v1/config/rollback. History is inGET /v1/config/history, and every write is inGET /v1/audit.
CONTROL=http://127.0.0.1:9090
curl -s -H "Authorization: Bearer $TOKEN" $CONTROL/v1/config | jq '.revision' # 41
curl -s -H "Authorization: Bearer $TOKEN" --data-binary @routes.json $CONTROL/v1/config/validate
curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H 'If-Match: "41"' \
--data-binary @routes.json $CONTROL/v1/config
curl -s -H "Authorization: Bearer $TOKEN" $CONTROL/v1/replicas
curl -s -X POST -H "Authorization: Bearer $TOKEN" -d '{"revision": 41}' $CONTROL/v1/config/rollbackAccess#
The gateway fails closed: no decision, no request. Point GATEWAY_ACCESS_URL at a service that speaks spec §6.1. Either run the reference implementation (services/access-service.ts, built from the access-service stage of the Dockerfile, deployed separately) with ACCESS_POLICY_FILE and ACCESS_SERVICE_TOKEN, or check your own with the conformance suite:
GATEWAY_TEST_ACCESS_URL=https://access.example.com \
GATEWAY_TEST_ACCESS_TOKEN=… \
GATEWAY_TEST_ACCESS_ALLOWED_SUBJECT=alice@example.com \
GATEWAY_TEST_ACCESS_ALLOWED_WORKSPACE=11111111-1111-4111-8111-111111111111 \
bun test test/integration/access-conformance.test.tsThe reference policy file maps subjects to workspaces (["*"] for all), roles, and routes for routes with accessCheck. Unknown subjects are denied.
{ "version": 1, "subjects": { "alice@example.com": { "workspaces": ["11111111-1111-4111-8111-111111111111"], "roles": ["editor"] }, "carol@example.com": { "workspaces": [], "routes": ["admin-api"] } } }Secrets#
Keep every secret in the secret store, never in the image, route table or repository. Upstreams get only the public key (bun run identity:key --public), so they can verify identities but not mint them.
| Secret | Rotation |
|---|---|
GATEWAY_SESSION_SECRET | Below. |
GATEWAY_IDENTITY_SIGNING_KEY | Below. |
GATEWAY_OIDC_CLIENT_SECRET | The provider accepts two secrets during rotation; switch, then retire the old one. |
GATEWAY_ACCESS_TOKEN | Access accepts two tokens during rotation. |
| Shared-state passwords | Change client and server in the same window. |
GATEWAY_METRICS_TOKEN | Change scraper and deployment together. |
Rotating the session secret#
- Add the new secret to the store.
- Roll replicas onto it quickly (
maxUnavailable: 0with a surge, or a maintenance window): replicas with different secrets cannot read each other's login state, and callers mid-login must sign in again. - Remove the old secret.
Rotating the identity key#
The session secret does not affect the identity signature.
bun run identity:key # new pair
GATEWAY_IDENTITY_SIGNING_KEY=<the pem> # new private key
bun run identity:key --public # public key for upstreamsGive upstreams the new public key and have them accept both keys, then roll out quickly: during the rollout, replicas sign with different keys.
Ingress#
The gateway does not rewrite paths. It terminates the TLS of the callers when it has a certificate of the deployment, and the deployment tells it the names of the certificate with GATEWAY_TLS_NAMES (spec §11.5). A deployment whose edge terminates the TLS gives the gateway no certificate: the ingress of an environment of container apps, for instance, issues and renews the certificate of its custom domains and terminates the TLS itself.
The gateway may be the only public service of a deployment — the front door of a platform — and then it serves several names: the name of the API, the name of the issuer of the tokens, the name of the console. The ingress still terminates the TLS of every name, and the table of the routes names the names of the gateway (hosts, spec §3.1): the endpoints the gateway serves itself answer on those names, and on another name the path belongs to the service that is reached under it. A route that forwards to such a service sets upstreamHost: "original", so the service sees the name the callers use.
| Setting | Value |
|---|---|
| Read timeout | ≥ GATEWAY_SHUTDOWN_TIMEOUT_MS; no write timeout (event streams outlive it) |
| Request and response buffering | Off |
| HTTP version | 1.1, passing Upgrade and Connection for WebSockets |
| Body limit | ≥ the largest route maxBodyBytes (the gateway answers 413) |
| Idle timeout | ≥ GATEWAY_WS_IDLE_TIMEOUT_SECONDS |
| Connections | ≥ the deployment's WebSocket count |
| Path | Unchanged; use a route's rewrite instead |
| Forwarding headers | x-forwarded-for, x-forwarded-proto, x-forwarded-host |
List the ingress ranges, as narrowly as possible, in GATEWAY_TRUSTED_PROXIES, and set GATEWAY_TRUSTED_PROXY_HOPS to the number of proxies in front (2 for a load balancer plus an ingress), so a client cannot choose its own address (spec §7.3).
location / {
proxy_pass http://gateway:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 3600s;
}Monitoring#
Set GATEWAY_METRICS_TOKEN and have the scraper send it as a bearer token to GET /metrics. Metrics and labels are listed in spec §12.1; logs in spec §12.2. Alert on:
| Signal | Means |
|---|---|
/readyz answers 503 | Replica out of rotation. |
gateway_access_unavailable_total | Access unreachable; callers get 503. |
gateway_access_stale_total | Outage covered by a grace; revocations are late. |
gateway_shared_state_errors_total | Shared state degraded. |
gateway_rate_limit_fallback_total | Limits counted per replica, not per deployment. |
gateway_config_reloads_total{result="failed"} | Broken route table. |
gateway_upstream_failures_total, gateway_request_errors_total | Upstream down. |
gateway_rate_limited_total | A flood. |
To trace a request, search the logs for its x-request-id; request and refusal lines name the caller and workspace.
Revoking sessions#
Sessions live under GATEWAY_SESSION_KEY_PREFIX; logout also destroys one.
| Goal | How |
|---|---|
| One session | DEL <prefix>:s:<id> |
| One caller | GATEWAY_SESSION_URL=redis://127.0.0.1:6379/0 bun run sessions -- --subject alice@example.test (the provider's sub; prints the count removed) |
| One caller, through the control plane | DELETE /v1/sessions?subject=alice@example.test with an operate token |
| Whole deployment | Delete the prefix's keys (SCAN + DEL, or FLUSHDB if it owns the database); repeat per prefix. |
To make a revocation take effect before the decision cache expires, purge the cached decisions too: DELETE /v1/decisions?subject=<sub> or ?workspace=<id> (or both) with an operate token.
Redis indexes sessions by subject as they are created, so older sessions are missed and expire on their own. In Postgres, postgres:migrate indexes older sessions.
Rollback#
Deploy the previous image (platform rollback) with the same configuration and secrets.
- Old pods drain: readiness
503, WebSockets close with1001, in-flight requests getGATEWAY_SHUTDOWN_TIMEOUT_MS. Give pods at least that long. - Sessions survive unless
GATEWAY_SESSION_SECRETchanges. - The gateway signs only
v5, so upstreams keep verifying whileGATEWAY_IDENTITY_SIGNING_KEYis unchanged. A pre-1.0gateway signsv4, which is removed: update upstreams that verifyv4first (Upstream identity).
Deployment review#
Answer once per deployment.
| Area | Check |
|---|---|
| Authentication | Which routes take a cookie, a token, both or nothing (auth.mode)? Does each mode: none upstream authenticate callers itself? |
| Workspaces | Which routes need one, from which placeholder (auth.workspace)? |
| Hosts | Which hosts are served, is there a * catch-all, and does the ingress keep Host or send x-forwarded-host? |
| Proxies | Which ranges are trusted, how many hops, and is the ingress the only client? |
| Upstreams | Reachable only from the gateway, over TLS, verifying the signature, in separate trust domains? |
| Metrics | Token set, scraper configured, endpoint not public? |
| Secrets | In the store, the same on every replica, with a rotation plan? |
| Limits | Body limit agrees with the ingress; route budgets exceed ingress timeouts; GATEWAY_RATE_LIMIT_TOTAL_MAX set? |
| Capacity | Concurrent requests and WebSockets fit pod memory (measurements)? |
| Probes | Pod grace period longer than the drain deadline; someone paged on readiness failure? |
| Revocation | GATEWAY_ACCESS_CACHE_TTL_MS plus GATEWAY_WS_REVALIDATE_INTERVAL_MS acceptable? |