keycloak: the Gateway API as an alternative to the two Ingresses (#114)

Two routes in Gateway mode, mirroring the two Ingresses, with
httpRoute.admin.parentRefs falling back to httpRoute.parentRefs the way
ingress.admin.className falls back to ingress.className - which data path is a
property of the Gateway in this API, not of the route.

Two things this page did not say before and now does. The admin Ingress did
not hang off ingress.enabled: ingress.admin.enabled gated it alone, so
ingress.enabled: false still emitted the admin object and clusterIssuer stayed
required for it. And the annotations this installation actually depends on -
proxy-buffer-size 16k and proxy-buffers-number 4, measured on BOTH live
Ingress objects on 2026-10-12 - are set by the operator, not by the chart, and
have no counterpart in the Gateway API. Keycloak's large headers make a
too-small buffer answer 502 at the proxy while the application logs success,
so the section says to set the equivalent at the Gateway before moving DNS.

Also recorded: the four keycloak-realm-*-ingress objects in the same
namespace belong to jk-cluster-config, share this release's Service rather
than its hostname, and keep the host migration unfinished after this chart
switches modes.
jklein committed 2026-10-12 21:29:00 +00:00
1 parent 52588f0d74
commit 1c27a6430f
1 file changed
+92 -12
+92 -12
@@ -12,7 +12,7 @@ What it costs is the thing to know before the first upgrade: **Keycloak supports
| Versions | not listed on purpose — read the current pair off `keycloak/Chart.yaml`, see [Chart index](Charts) |
| Workloads | Deployment, **2** replicas, **two init containers** — see [Why two init containers](#why-this-chart-has-two-init-containers) |
| Storage | **none.** No PVC: Keycloak 26 keeps everything in the database, user sessions included (persistent sessions are the default; volatile ones are deprecated as of 26.8) |
| Ingress | **two**: the public one and the admin hostname (switchable off). The admin one is **not** an access control — see [the first trap](#the-admin-hostname-is-not-an-access-control) |
| Routing | **two mutually exclusive modes**, Ingress (default) or Gateway API (`httpRoute.enabled`) — and **two objects in either mode**: the public hostname and the admin hostname (switchable off). The admin one is **not** an access control — see [the first trap](#the-admin-hostname-is-not-an-access-control). In Gateway mode the **proxy-buffer annotations this installation depends on are gone** and have no counterpart — see [Routing: Ingress or Gateway API](#routing-ingress-or-gateway-api) |
| External dependencies | an external PostgreSQL. Nothing else |
| Secrets | 2 × `OnePasswordItem`, four fields in total |
| Network | ClusterIP Service on 8080 (`http`) and a **separate** ClusterIP Service on 9000 (`management`). The management port serves `/health/*` and `/metrics` **unauthenticated** and is in no Ingress. JGroups uses 7800 pod-to-pod; no NetworkPolicy yet |
@@ -23,7 +23,7 @@ What it costs is the thing to know before the first upgrade: **Keycloak supports
- **An external PostgreSQL**, reachable from the cluster, with a database and a role **that can create its own schema**: Keycloak builds and migrates the entire schema itself through Liquibase on first start. A read-write grant on existing tables is not enough.
- **1Password operator** in the cluster, plus **two** items — see [What the 1Password items must contain](#what-the-1password-items-must-contain).
- **Two DNS names and a cert-manager cluster issuer**, unless the admin Ingress is switched off — one for the public endpoint, one for the administration console.
- **Two DNS names and a cert-manager cluster issuer**, unless the admin hostname is switched off — one for the public endpoint, one for the administration console. In **Gateway mode** the cluster issuer is not needed from this release at all: both certificates sit on the Gateway's listeners. What is needed instead is the **Gateway-API CRDs** plus a Gateway whose listener admits routes from this namespace — and, before DNS is pointed at it, the proxy-buffer equivalent configured next to that Gateway ([why](#routing-ingress-or-gateway-api)).
- **Nothing else.** No volume, no storage class, no cache service.
## Installation
@@ -59,6 +59,32 @@ ingress:
Five values, and four of them have no default because no default could be right. `helm install` prints the chart's `NOTES.txt`, which names the four vault fields, the verification sequence and the traps in short form. Read it — it is the per-release version of this page.
The same installation in **Gateway mode** instead — the Gateways, their classes and their certificates belong to the cluster, not to this release:
```yaml
ingress:
enabled: false # mutually exclusive with httpRoute.enabled
domain: id.example.com # STILL REQUIRED: it is KC_HOSTNAME as well
admin:
domain: id-admin.example.com
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway
sectionName: https
admin:
# Empty falls back to httpRoute.parentRefs. An INTERNAL Gateway here is
# what an internal ingress class is in the other mode.
parentRefs:
- name: internal
namespace: gateway
sectionName: https
```
This renders two routes, `<release>-keycloak-httproute` and `<release>-keycloak-admin-httproute`, and nothing else of the routing path. `ingress.clusterIssuer` is not used; `ingress.domain` is **still required** because the Deployment reads it as `KC_HOSTNAME`. **Set the proxy-buffer equivalent at the Gateway before pointing DNS at it** — see [Routing: Ingress or Gateway API](#routing-ingress-or-gateway-api).
### What the 1Password items must contain
The chart renders two `OnePasswordItem`s; the operator turns each into a Kubernetes Secret whose **keys are the field names of the 1Password item**. A missing or misnamed field leaves the pod in `CreateContainerConfigError` — with no message naming the field.
@@ -280,17 +306,65 @@ Four properties of this route that cost time if they are not known:
| Key | Type | Default | Description |
|---|---|---|---|
| `ingress.enabled` | bool | `true` | Gates the **public** Ingress object only. |
| `ingress.domain` | string | `null` | **Required even with `enabled: false`** — [§12.1](Chart-Style-Guide#12-values-that-must-not-lie) checked in both directions: it is read a second time as `KC_HOSTNAME`, and Keycloak refuses to start in production mode without a hostname (`hostname-strict` defaults to `true`). The chart renders it as the **full URL** `https://<domain>`, which upstream requires as soon as `hostname-admin` is in play. |
| `ingress.clusterIssuer` | string | `null` | Required while **either** Ingress is enabled. Read only in the Ingress template, so switching both off frees it. |
| `ingress.className` | string | `nginx` | |
| `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. |
| `ingress.admin.enabled` | bool | `true` | The administration console on a hostname of its own. **Not an access control** — see [the first trap](#the-admin-hostname-is-not-an-access-control). |
| `ingress.admin.domain` | string | `null` | Required while enabled. Also rendered as `https://<domain>`. |
| `ingress.admin.className` | string | `null` | Falls back to `ingress.className`. An **internal** ingress class here is the useful half of the admin hostname. |
| `ingress.admin.annotations` | object | `{}` | Where an IP allow-list would go — with the caveat below firmly in mind. |
| `ingress.enabled` | bool | `true` | Gates **both** Ingress objects. **Mutually exclusive with `httpRoute.enabled`.** Until the Gateway-API support landed it gated only the public one, so `ingress.enabled: false` left the admin Ingress rendering — see [Routing](#routing-ingress-or-gateway-api). |
| `ingress.domain` | string | `null` | **Required in every mode, including with both of them off** — [§12.1](Chart-Style-Guide#12-values-that-must-not-lie) checked in both directions: it is read a second time as `KC_HOSTNAME`, and Keycloak refuses to start in production mode without a hostname (`hostname-strict` defaults to `true`). The chart renders it as the **full URL** `https://<domain>`, which upstream requires as soon as `hostname-admin` is in play. Also the public `HTTPRoute`'s hostname while `httpRoute.hostnames` is empty. |
| `ingress.clusterIssuer` | string | `null` | Required while the Ingress is enabled. Read only in the Ingress template, so Gateway mode frees it — the certificates sit on the Gateway's listeners there. |
| `ingress.className` | string | `nginx` | No counterpart in Gateway mode: the class sits on the Gateway. |
| `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. **No counterpart in Gateway mode** — and for this chart that is the consequential one: this installation's proxy-buffer annotations live here. See [Routing](#routing-ingress-or-gateway-api). |
| `ingress.admin.enabled` | bool | `true` | The administration console on a hostname of its own, and **not an Ingress switch**: it also decides whether `KC_HOSTNAME_ADMIN` is set on the Deployment, so it gates the admin object in **both** modes and switching to Gateway mode does not require turning it off. **Not an access control** — see [the first trap](#the-admin-hostname-is-not-an-access-control). |
| `ingress.admin.domain` | string | `null` | Required while enabled. Also rendered as `https://<domain>`. Also the admin `HTTPRoute`'s hostname while `httpRoute.admin.hostnames` is empty. |
| `ingress.admin.className` | string | `null` | Falls back to `ingress.className`. An **internal** ingress class here is the useful half of the admin hostname. Its Gateway-mode counterpart is `httpRoute.admin.parentRefs`, not a class. |
| `ingress.admin.annotations` | object | `{}` | Where an IP allow-list would go — with the caveat below firmly in mind. No counterpart in Gateway mode. |
TLS secrets: `tls-<release>-keycloak-ingress` and `tls-<release>-keycloak-admin-ingress`. Two separate certificates, because these are two hostnames that may well sit on two different ingress classes — the shared-secret form in the style guide is for several objects on **one** host.
TLS secrets: `tls-<release>-keycloak-ingress` and `tls-<release>-keycloak-admin-ingress`. Two separate certificates, because these are two hostnames that may well sit on two different ingress classes — the shared-secret form in the style guide is for several objects on **one** host. In Gateway mode the chart renders neither secret and neither certificate.
### `httpRoute`
The Gateway-API alternative to the two Ingresses, **off by default**. Added with group 3 of the Gateway-API sweep (Gitea #114); the form is the pilot's and is binding for every chart ([style guide §4](Chart-Style-Guide#4-ingress-and-gateway-api)).
| Key | Type | Default | Description |
|---|---|---|---|
| `httpRoute.enabled` | bool | `false` | Renders `<release>-keycloak-httproute` and — while `ingress.admin.enabled` — `<release>-keycloak-admin-httproute`, instead of the two Ingresses. **Mutually exclusive with `ingress.enabled`**: with both `true` the render aborts naming both values. |
| `httpRoute.parentRefs` | list | `[]` | The Gateway(s) the **public** route attaches to — Gateway API `ParentReference` entries (`name`, and optionally `namespace`, `sectionName`, `port`, `group`, `kind`), passed through unchanged. **Required while `httpRoute.enabled` is true**: the render aborts on an empty list. The schema requires `name` on every entry and is otherwise left open, because Kubernetes owns that schema and adds fields to it across releases. |
| `httpRoute.hostnames` | list | `[]` | Empty means `[ingress.domain]`, so the public hostname has a single source of truth. Setting it is for a second or additional name — and note `ingress.domain` stays mandatory regardless, because it is also `KC_HOSTNAME`. |
| `httpRoute.admin.parentRefs` | list | `[]` | The **admin** route's own parent. Empty falls back to `httpRoute.parentRefs` — the exact counterpart of `ingress.admin.className` falling back to `ingress.className`, because "which data path" is a property of the **Gateway** in this API rather than of the route. An internal Gateway here is what an internal ingress class is in the other mode, and still not the access control the first trap is about. |
| `httpRoute.admin.hostnames` | list | `[]` | Empty means `[ingress.admin.domain]`. |
## Operating notes
### Routing: Ingress or Gateway API
Two mutually exclusive modes, and exactly one of them may be on. `ingress.enabled` ships `true`, `httpRoute.enabled` ships `false`. **Two routing objects in either mode** — the public hostname and the admin hostname.
| Rule | Why |
|---|---|
| `ingress.enabled` and `httpRoute.enabled` cannot both be true — the render aborts naming **both** value paths | two data paths onto one Service under one hostname is a conflict at DNS level already. For Keycloak it is worse than for a stateless app: the server derives `KC_HOSTNAME` from `ingress.domain` and generates **absolute** redirect and asset URLs for it, so a login that starts on one path and finishes on the other fails on the state cookie, not with an error naming the cause. **A migration runs over a second hostname** (`httpRoute.hostnames` set explicitly while the Ingress keeps `ingress.domain`), never in parallel. |
| `httpRoute.parentRefs` must not be empty — the render aborts | a route without a parent attaches to **no** Gateway. The object is created, it is schema-valid, no controller rejects it, and it routes nothing: no event, no condition, and the only symptom is a hostname that does not answer. |
| `ingress.admin.enabled` gates the admin object in **both** modes | it is not an Ingress switch. It also decides whether `KC_HOSTNAME_ADMIN` is set on the Deployment, i.e. whether the admin hostname is in play at all — so switching to Gateway mode must not require turning it off, which would silently remove the feature. |
| `httpRoute.admin.parentRefs` falls back to `httpRoute.parentRefs` | the counterpart of `ingress.admin.className` falling back to `ingress.className`. "Which data path" is a property of the **Gateway** in this API, not of the route, so an internal Gateway here is what an internal ingress class is there. Without the fallback the admin route could only ever hang off the same Gateway as the public one, and the one useful half of the second hostname would be gone in this mode. |
**The admin Ingress did not use to hang off `ingress.enabled`,** and that was fixed with this support rather than worked around. `ingress.admin.enabled` gated it alone, so `helm template --set ingress.enabled=false` emitted `<release>-keycloak-admin-ingress` and nothing else of the routing path: "no Ingress" did not mean no Ingress, and `ingress.clusterIssuer` stayed required for an object nobody asked for. It also made Gateway mode impossible — switching `httpRoute` on leaves `ingress.enabled` false, and the admin Ingress would have gone on claiming the admin host next to the admin route. The default render is unchanged; what changed is a combination that could not be coherent.
### What Gateway mode costs this chart, and it is not hypothetical
Measured on the live cluster on 2026-10-12, on **both** of this chart's Ingress objects:
```
nginx.ingress.kubernetes.io/proxy-buffer-size: 16k
nginx.ingress.kubernetes.io/proxy-buffers-number: 4
```
**The chart sets neither.** They are operator-supplied, through `ingress.annotations` and `ingress.admin.annotations` — which is why reading this chart, or grepping the repository, does not reveal them. And they are not cosmetic: Keycloak's responses carry large headers (JWTs, SAML assertions, long `Set-Cookie` chains during the login flow), and a proxy whose buffer is too small answers **502 itself** while Keycloak logs the request as successful. The symptom points at the application; the cause is at the proxy.
In Gateway mode both are gone, and the Gateway API has **no counterpart**: buffer sizing is implementation policy, not routing, and this chart renders no implementation-specific policy object on purpose — that would bind it to one Gateway implementation and to a CRD whose presence it cannot check ([style guide §4](Chart-Style-Guide#4-ingress-and-gateway-api)). So the chart **names** the loss instead of rebuilding it: the Gateway branch of `NOTES.txt` prints it, and `values.yaml` and the schema carry it.
**Before moving this host: set the equivalent next to the Gateway**, in that implementation's own object, and raise it rather than merely matching 16k if long-lived tokens or SAML are in play.
### Four more Ingress objects in this namespace, and they are not this chart's
The live `keycloak` namespace carries four further objects — `keycloak-realm-bekemeyer-klein-ingress`, `keycloak-realm-jk-family-ingress`, `keycloak-realm-mail-ingress`, `keycloak-realm-muth-event-ingress` — each with `nginx.ingress.kubernetes.io/app-root` pointing at a realm's account console, and three of the four also carrying the proxy-buffer pair. They belong to the Helm release `jk-cluster-config-fleet-cd-keycloak-realm-ingress`, i.e. to the **`jk-cluster-config` repository**, and this chart renders exactly the two objects its own two Ingresses cover.
What they share with this chart is the **backend Service**, not the hostname: they publish `bk.id.bekemeyer-klein.de`, `id.mail.bekemeyer-klein.de`, `muth-event.id.bekemeyer-klein.de` and `id.klein.pub` — four further names, two of them subdomains of this chart's public host. So a wildcard listener for `*.id.bekemeyer-klein.de` would cover them, and a migration of the public host is **not finished** when this chart switches modes: those four objects stay Ingresses until `jk-cluster-config` moves them, and `app-root` has no Gateway-API counterpart either (a `RequestRedirect` filter on an `Exact` match is the nearest thing, and it would have to be written there, not here).
## Traps
@@ -417,6 +491,12 @@ It also closes three things a render cannot: that the two init containers work a
**What CI cannot prove**: that `hostname-admin` serves the console and its resources cleanly on the second hostname. No ingress controller answers in the test cluster and nothing drives a browser. What it does check is that both Ingress objects are accepted, that the Service port they name exists, and that Keycloak accepts both hostname values as full URLs at start.
**The Gateway mode is held at render level**, by `ci/httproute/keycloak.yaml` plus `ci/run-httproute-match-test.sh` — which runs over every chart on every PR ([CI/CD](CI-CD#httproute-render-test)). The overlay switches this chart into Gateway mode and covers **both** routing objects: the public route with its hostname from `ingress.domain` (the `httpRoute.hostnames`-empty fallback, which is the path an installation actually takes) and the admin route with its parents falling back to `httpRoute.parentRefs`. **4 assertions** — a `parentRefs` check and a `backendRefs` check per route — and both references are held against the serving Service of the same render **by port number**, because `backendRefs` takes a `PortNumber` and cannot name the port `http` the way the Ingress does.
An overlay rather than a change to the install-test fixture, and that is a deliberate trade: the install-test runs exactly one configuration per chart, and this chart's fixture is the expensive and informative one (two replicas, two init containers, `kc.sh build` under a read-only root, the Infinispan cluster forming over `jdbc-ping`). Trading all of it for a CRD schema check that `cyberchef`'s fixture already performs against the real Gateway-API CRDs would be a bad swap.
**What that does not cover**, named so it is not mistaken for coverage: that either hostname answers — no Gateway controller runs in CI, so no route ever reaches `Accepted: True` — and the proxy-buffer annotations, which have no counterpart in this mode at all. That loss is documented in `values.yaml` and printed by `NOTES.txt`, and documentation is not something a test can assert. **Nothing in CI reads `NOTES.txt`** either: `helm template` does not emit it and no script under `ci/` reads it, so its text carries review-only assurance.
## See also
- [Chart style guide](Chart-Style-Guide) — §11 for the PodDisruptionBudget convention this chart introduced