Console
Sign in
Docs for Security engineer
On this page
  1. Deploy
  2. Shared state
  3. Roles
  4. Control plane
  5. Changing the route table
  6. Access
  7. Secrets
  8. Rotating the session secret
  9. Rotating the identity key
  10. Ingress
  11. Monitoring
  12. Revoking sessions
  13. Rollback
  14. Deployment review

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#

  1. Follow the platform first deployment.
  2. 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_SECRET and GATEWAY_IDENTITY_SIGNING_KEY on every replica.
  3. Choose a shared-state backend and create its roles.
  4. Put an ingress in front.
  5. Run the control plane to manage routes or sessions at runtime.
  6. 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_buckets and two functions) is built on connect when GATEWAY_POSTGRES_AUTO_MIGRATE=true, the default outside production. To apply it yourself, set false and run bun 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=off by 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).

BackendGatewayControl planeSetup
Postgresgateway_appcontrol_appbun run postgres:migrate -- --roles postgres://admin:<password>@host:5432/gateway, with POSTGRES_GATEWAY_PASSWORD and POSTGRES_CONTROL_PASSWORD (or ALTER ROLE).
Redisgatewaygateway-controlLoad deploy/redis/gateway.acl as the server's aclfile, then set passwords on every start (below).
sh
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.

  1. Keep its socket internal. It binds loopback by default (GATEWAY_CONTROL_HOST); never route it through the ingress.
  2. 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.
  3. 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.

  1. Read the active revision and table with GET /v1/config.
  2. Validate the new table with POST /v1/config/validate. The answer lists errors, conflicts, warnings and the diff against the active table; 422 means the table is invalid. Nothing is written.
  3. Write it with PUT /v1/config, naming the revision you read in If-Match (with or without quotes). A 409 means another write landed first (read again and retry) or the table changes a route in GATEWAY_PROTECTED_ROUTES (leave that route unchanged).
  4. Watch GET /v1/replicas until every replica reports the new revision and no last error.
  5. To undo, write an older revision again with POST /v1/config/rollback. History is in GET /v1/config/history, and every write is in GET /v1/audit.
bash
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/rollback

Access#

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:

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

The reference policy file maps subjects to workspaces (["*"] for all), roles, and routes for routes with accessCheck. Unknown subjects are denied.

json
{ "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.

SecretRotation
GATEWAY_SESSION_SECRETBelow.
GATEWAY_IDENTITY_SIGNING_KEYBelow.
GATEWAY_OIDC_CLIENT_SECRETThe provider accepts two secrets during rotation; switch, then retire the old one.
GATEWAY_ACCESS_TOKENAccess accepts two tokens during rotation.
Shared-state passwordsChange client and server in the same window.
GATEWAY_METRICS_TOKENChange scraper and deployment together.

Rotating the session secret#

  1. Add the new secret to the store.
  2. Roll replicas onto it quickly (maxUnavailable: 0 with 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.
  3. Remove the old secret.

Rotating the identity key#

The session secret does not affect the identity signature.

bash
bun run identity:key                     # new pair
GATEWAY_IDENTITY_SIGNING_KEY=<the pem>   # new private key
bun run identity:key --public            # public key for upstreams

Give 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.

SettingValue
Read timeout≥ GATEWAY_SHUTDOWN_TIMEOUT_MS; no write timeout (event streams outlive it)
Request and response bufferingOff
HTTP version1.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
PathUnchanged; use a route's rewrite instead
Forwarding headersx-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).

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

SignalMeans
/readyz answers 503Replica out of rotation.
gateway_access_unavailable_totalAccess unreachable; callers get 503.
gateway_access_stale_totalOutage covered by a grace; revocations are late.
gateway_shared_state_errors_totalShared state degraded.
gateway_rate_limit_fallback_totalLimits counted per replica, not per deployment.
gateway_config_reloads_total{result="failed"}Broken route table.
gateway_upstream_failures_total, gateway_request_errors_totalUpstream down.
gateway_rate_limited_totalA 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.

GoalHow
One sessionDEL <prefix>:s:<id>
One callerGATEWAY_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 planeDELETE /v1/sessions?subject=alice@example.test with an operate token
Whole deploymentDelete 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 with 1001, in-flight requests get GATEWAY_SHUTDOWN_TIMEOUT_MS. Give pods at least that long.
  • Sessions survive unless GATEWAY_SESSION_SECRET changes.
  • The gateway signs only v5, so upstreams keep verifying while GATEWAY_IDENTITY_SIGNING_KEY is unchanged. A pre-1.0 gateway signs v4, which is removed: update upstreams that verify v4 first (Upstream identity).

Deployment review#

Answer once per deployment.

AreaCheck
AuthenticationWhich routes take a cookie, a token, both or nothing (auth.mode)? Does each mode: none upstream authenticate callers itself?
WorkspacesWhich routes need one, from which placeholder (auth.workspace)?
HostsWhich hosts are served, is there a * catch-all, and does the ingress keep Host or send x-forwarded-host?
ProxiesWhich ranges are trusted, how many hops, and is the ingress the only client?
UpstreamsReachable only from the gateway, over TLS, verifying the signature, in separate trust domains?
MetricsToken set, scraper configured, endpoint not public?
SecretsIn the store, the same on every replica, with a rotation plan?
LimitsBody limit agrees with the ingress; route budgets exceed ingress timeouts; GATEWAY_RATE_LIMIT_TOTAL_MAX set?
CapacityConcurrent requests and WebSockets fit pod memory (measurements)?
ProbesPod grace period longer than the drain deadline; someone paged on readiness failure?
RevocationGATEWAY_ACCESS_CACHE_TTL_MS plus GATEWAY_WS_REVALIDATE_INTERVAL_MS acceptable?