Serving: Ingress vs Gateway API¶
Since v0.3.0 a KeycloakInstance can be served two ways, chosen per instance
with spec.ingress.mode:
Ingress (default) |
GatewayAPI |
|
|---|---|---|
| Objects rendered | networking.k8s.io/v1 Ingress × 2 |
gateway.networking.k8s.io/v1 HTTPRoute × 2 |
| Object names | <name> (public), <name>-admin |
<name>-public, <name>-admin |
| Attaches to | an Ingress class (class, adminClass) |
a pre-existing Gateway (gateway.parentRef, gateway.adminParentRef) |
| Who creates the entry point | the ingress controller, from the class | you — the operator never creates a Gateway |
| TLS | operator-managed via clusterIssuer (cert-manager annotation + TLS secret) |
terminates at the Gateway listener; the operator manages no certificates |
| Admin-console block | path routing to an endpoint-less Service → 503 |
identical: same Service, same 503 |
| NetworkPolicy default peer | ingress-nginx; the admin rule follows the resolved adminClass |
envoy-gateway-system for both rules (Envoy Gateway runs its proxies in the controller namespace) |
Bare / on the public host |
503 always |
503, or a 301 to hosts.rootRedirect when set |
| Status | — | adds the RoutesAccepted condition |
Leaving mode unset means Ingress, so an existing CR keeps behaving
exactly as before — the whole change is additive.
The routing policy is identical in both modes. Moving an instance between them changes which object kind exists and nothing about who can reach what: the admin console stays blocked on the public host either way (see Admin-console isolation).
Verified implementation
The operator renders only spec-conformant gateway.networking.k8s.io/v1
objects, so any conformant implementation is expected to work. What
b'nerd has actually verified is Envoy Gateway v1.9.0 / Gateway API
v1.6.1, on kind and on a Gardener shoot (Cilium, Kubernetes 1.35) with
managed Percona PostgreSQL and a Let's Encrypt (staging) certificate issued
through cert-manager's Gateway solver. Other implementations — Traefik,
Cilium's own, and the rest — are untested here; we do not claim coverage we
have not run.
Prerequisites (GatewayAPI mode)¶
- Gateway API with
HTTPRouteserved atv1(bundle v1.0.0 or newer, standard channel). That is exactly what the operator checks: a bundle serving only the olderv1beta1does not count and is reported as unavailable (seeGatewayAPIUnavailable). Most Gateway implementations ship and own their own CRD bundle — prefer that over installing one separately. - A Gateway implementation (Envoy Gateway, ...) running and owning a
GatewayClass. -
A
Gatewaywith an HTTPS listener your instance's namespace may attach to. The operator never creates one — see below.Pin the public route to that HTTPS listener with
gateway.parentRef.sectionName. Gateway API resolves hostname specificity before path, so a route carrying your hostname attached to a plain:80listener out-ranks the platform's hostname-less HTTP→HTTPS redirect: the app is then served in plaintext on:80even though a redirect exists and looks correct. Naming the HTTPS listener is what stops the route attaching to:80at all.The HTTPS part is a requirement, not a preference. In GatewayAPI mode the operator emits no TLS configuration and no HTTP→HTTPS redirect — TLS is the listener's, by design (D4). Attach an instance to a plain
:80listener and Keycloak will happily serve the login form, and accept the password, in cleartext, while the CR reportsReady=TrueandRoutesAccepted=True. Nothing in the operator's status can tell you this is happening; it is a property of the listener you attached to. 4. A restart of the operator if Gateway API was installed after the operator started. See Installing Gateway API later.
What the Gateway owner has to provide¶
The Gateway is platform infrastructure. The operator only writes
HTTPRoutes that reference it, so whoever owns the Gateway must admit the
instance's namespace — and how narrowly they admit it is a security
decision, not a formality. See the warning after the example.
This is the Gateway half of the full example
below, copied verbatim from svc01, where it is live and verified
(2026-08-21): two hostname-pinned HTTPS listeners and the :80 one
cert-manager's Gateway shim needs. The namespace label is ours — match
whatever your own listeners select on:
apiVersion: v1
kind: Namespace
metadata:
name: kc-e2e-gw
labels:
# Whatever YOUR listeners' allowedRoutes.namespaces.selector matches on;
# this key is b'nerd's, not a convention. Get it wrong and the routes are
# refused (RoutesAccepted=False / NotAllowedByListeners) while the instance
# itself still reports healthy — check the condition after applying.
gateway.bnerd.com/public-routes: "true"
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: public
namespace: envoy-gateway-system
annotations:
# cert-manager's Gateway shim: it watches this Gateway, solves HTTP-01 for
# each listener hostname over the :80 listener, and writes the Secrets
# named in certificateRefs. TLS is the Gateway owner's job in this mode.
cert-manager.io/cluster-issuer: letsencrypt-staging
spec:
gatewayClassName: envoy
listeners:
# Plain :80, hostname-less — for the ACME challenge and an HTTPS redirect.
- name: http
protocol: HTTP
port: 80
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels:
gateway.bnerd.com/public-routes: "true"
# One hostname-pinned HTTPS listener per host the instance serves.
- name: https-kc-e2e
protocol: HTTPS
port: 443
hostname: kc-gw.212.34.182.26.nip.io
tls:
mode: Terminate
certificateRefs:
- name: kc-gw-e2e-tls
allowedRoutes:
namespaces:
# Admit ONLY the namespaces that should be able to serve this
# hostname. See the warning below before reaching for `from: All`.
from: Selector
selector:
matchLabels:
gateway.bnerd.com/public-routes: "true"
- name: https-admin-kc-e2e
protocol: HTTPS
port: 443
hostname: admin-kc-gw.212.34.182.26.nip.io
tls:
mode: Terminate
certificateRefs:
- name: kc-gw-e2e-admin-tls
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels:
gateway.bnerd.com/public-routes: "true"
Two things about that listener are load-bearing:
hostnamepins what the listener will serve. A listener without one accepts any hostname a route claims.allowedRoutesdecides who may claim it. If the listener does not admit the instance's namespace the route is rejected, the instance reportsRoutesAccepted=False/NotAllowedByListeners, and the CR tells you why — the safe failure.
Path normalisation is the data plane's job
The admin-console block is a PathPrefix match, so it only holds if the
data plane normalises the request path before matching — collapsing
//admin, decoding %2F, and resolving . / .. segments. Conformant
implementations do, and the operator's kind e2e asserts 503 for
//admin/master/console/, /admin%2Fmaster/console/,
/./admin/master/console/ and /foo/../admin/master/console/, with and
without curl --path-as-is.
One observable quirk: with Envoy's default escapedSlashesAction
(UNESCAPE_AND_REDIRECT), /admin%2Fmaster/console/ answers 307 with
Location: /admin/master/console/ rather than 503 — and that redirect
target is itself blocked, so a client following it gets the 503. The origin
is never reached either way; the e2e follows redirects and asserts the
final status for exactly this reason.
On Envoy Gateway this behaviour is configurable per Gateway via a
ClientTrafficPolicy (path.mergeSlashes, path.escapedSlashesAction).
The defaults are the safe ones. If your platform team sets a
ClientTrafficPolicy that disables slash merging or passes escaped slashes
through unchanged, it weakens this block — the operator cannot see that
policy and cannot compensate for it. Re-run the four checks above after any
ClientTrafficPolicy change on a Gateway that serves a Keycloak host.
from: All lets a co-tenant hijack the admin console
The public route keeps the admin console off the public host with a deny
rule: PathPrefix /admin and Exact / go to an endpoint-less Service, so
they answer 503. Gateway API resolves conflicts by match specificity,
and specificity wins regardless of which namespace the route came from.
So on a listener with allowedRoutes.namespaces.from: All, anyone who can
create an HTTPRoute in any admitted namespace can attach a route for your
hostname with a more specific match than the deny rule — an exact
/admin/master/console/, say — and out-specify it. Their route wins, the
deny rule never fires, and the admin console is served on the public host
from a namespace you do not control. Nothing in this operator can prevent
that: the operator does not own the Gateway, and the conflict is resolved
by the implementation.
Use from: Selector (or from: Same) and label only the namespaces that
are supposed to serve this hostname. Reserve from: All for a Gateway
whose namespaces are all equally trusted — a single-tenant cluster, or a
disposable test rig like this operator's own kind e2e.
The operator creates no ReferenceGrant: every backendRef it writes
points at a Service in the instance's own namespace, so none is needed.
Full example — the svc01 case¶
This is svc01's environment (Gardener shoot, Envoy Gateway v1.9.0 / Gateway API
v1.6.1, cert-manager v1.21.1, managed Percona PostgreSQL). The Gateway half
(listeners, annotation, namespace selector) is live and was verified on svc01
on 2026-08-21, and the operator's kind e2e suite verifies the instance half end
to end. It is in the repo as
examples/gatewayapi-public-admin.yaml — namespace, Gateway and
instance in one file — and a test applies that file against the real CRDs on
every build, so it cannot drift from the schema. Apply it after the
Gateway above.
v0.3.2 is rolled out on svc01 (verified 2026-08-22), so hosts.adminExposure
now exists in its CRD and this instance half is applyable there — the blocker
that made this a "target configuration" pending the rollout is gone. What svc01
itself actually serves today is still the Internal variant
below, admin host on a separate private Gateway; this Public/same-Gateway CR
documents a supported shape rather than one applied on svc01.
apiVersion: k8s.bnerd.com/v1alpha1
kind: KeycloakInstance
metadata:
name: kc-gw
namespace: kc-e2e-gw
spec:
version: "26.6.4"
replicas: 1
hosts:
host: kc-gw.212.34.182.26.nip.io
adminHost: admin-kc-gw.212.34.182.26.nip.io
# Public = the admin host may be served by the same Gateway. The default
# since v0.3.2, spelled out here because it is the security-relevant knob.
adminExposure: Public
# Without this the bare "/" answers 503 — correct, but unhelpful.
rootRedirect: /realms/master/account/
ingress:
enabled: true
mode: GatewayAPI
gateway:
parentRef:
name: public
namespace: envoy-gateway-system
sectionName: https-kc-e2e # pin the HTTPS listener
adminParentRef:
name: public
namespace: envoy-gateway-system
sectionName: https-admin-kc-e2e # pin the ADMIN HTTPS listener
postgres:
managed: true
topology: standalone
What that serves:
| Request | Answer |
|---|---|
https://kc-gw.212.34.182.26.nip.io/ |
301 → /realms/master/account/ |
https://kc-gw.212.34.182.26.nip.io/admin/master/console/ |
503 (the admin-block sink) |
https://kc-gw.212.34.182.26.nip.io/realms/... |
Keycloak |
https://admin-kc-gw.212.34.182.26.nip.io/admin/master/console/ |
Keycloak — unblocked, by design |
Why both sectionNames are there¶
adminParentRef here names the same Gateway as parentRef. That is allowed
under adminExposure: Public (see Admin exposure) — and
because the public route pins a listener, the operator requires the admin
sectionName too, rejecting the spec at apply time without it. This is why.
A parentRef without a sectionName attaches the route to every listener on
that Gateway that admits the namespace and matches the route's hostname —
including the plain :80 one. Gateway API resolves hostname specificity
before path, so the hostname-carrying route out-ranks the platform's
hostname-less HTTP→HTTPS redirect, and the host is then served in cleartext
on :80 while everything reports healthy. Naming the HTTPS listener is what
stops the route attaching to :80 at all.
It is worse for the admin route than for the public one: the admin route is
deliberately unblocked — it goes straight to Keycloak — so an unpinned admin
route serves the admin console in cleartext on the public address. That is
why it is a validation error rather than advice, once the public sectionName
tells the operator this Gateway has listeners to choose between. Choosing the
right public listener stays your job: the operator cannot tell which of yours
terminates TLS.
The Internal variant¶
If the admin console must not be reachable from the public entry point at all,
set adminExposure: Internal and point adminParentRef at a separate,
internal-only Gateway — a private LoadBalancer, or a ClusterIP data plane.
This is the topology svc01 runs, verified 2026-08-21: the same public host
on the https-kc-e2e listener, and the admin host attached to
Gateway/internal, whose data plane is a private OpenStack LoadBalancer
(10.251.2.50) — reachable over VPN/ClusterLink and from inside the cluster,
never from the internet. It was verified under v0.3.1, where that separation was
unconditional and adminExposure did not exist; v0.3.2 turns it into a choice,
and this is how you keep it:
spec:
version: "26.6.4"
hosts:
host: kc-gw.212.34.182.26.nip.io
adminHost: admin-kc-gw.212.34.182.26.nip.io
adminExposure: Internal
rootRedirect: /realms/master/account/
ingress:
enabled: true
mode: GatewayAPI
gateway:
parentRef:
name: public
namespace: envoy-gateway-system
sectionName: https-kc-e2e
# A DIFFERENT Gateway, not just a different listener. No sectionName
# needed here: the internal Gateway serves this hostname alone.
adminParentRef:
name: internal
namespace: envoy-gateway-system
postgres:
managed: true
topology: standalone
Under Internal the operator enforces that: adminParentRef is required
whenever hosts.adminHost is set, and it must name a different Gateway — a
different name, or a different namespace once an omitted one is defaulted to
the instance's. The API server rejects anything else at apply time.
A different listener on the same Gateway is not enough, and is rejected too.
Listeners share their Gateway's addresses, so an admin route on another
sectionName is still served by the public load balancer — which is exactly
what Internal is preventing. It is the Gateway API analogue of the
nginx-internal ingress class that Ingress mode uses for the same job.
Rules that hold in both variants¶
class, adminClass and clusterIssuer are rejected in this mode rather
than silently ignored — there is no Ingress to put a class on, and TLS is the
listener's. The API server rejects such a spec at apply time, naming the
offending field.
An instance that declares mode: GatewayAPI in its own spec must also
carry its own gateway block — the API server validates the object as written,
before any profile is merged, so a profile cannot supply the gateway for an
instance that names the mode itself. The workable combinations are: let the
profile supply both mode and gateway and say nothing in the instance, or
set both in the instance.
spec.ingress.annotations are applied to the HTTPRoutes in this mode (not to
Ingresses).
Admin exposure¶
spec.hosts.adminExposure decides how strictly the admin host is kept apart
from the public entry point. It only matters when hosts.adminHost is set.
Public (default) |
Internal |
|
|---|---|---|
| GatewayAPI | gateway.adminParentRef optional while parentRef is unpinned (the admin route then attaches to the public Gateway and the Gateway picks the listener by hostname). Once parentRef.sectionName is set, adminParentRef and its sectionName are required — whichever Gateway it names. |
gateway.adminParentRef required, and must name a different Gateway |
| Ingress | ingress.adminClass optional; omitted = the admin Ingress takes the public class |
ingress.adminClass required, and must differ from ingress.class |
Neither value changes the public host. /admin on hosts.host routes to the
endpoint-less sink and answers 503 under both. adminExposure governs where
the admin host is served, not whether the public host leaks the console.
Public — shared entry point, pinned once the public one is¶
The full example above is this case: the admin
host served by a second HTTPS listener on the same Gateway, named with
adminParentRef.sectionName.
Public relaxes which Gateway the admin host may use. What it demands depends
on one thing — whether the public route had to name a listener:
parentRef.sectionName |
adminParentRef |
|---|---|
| unset | optional. Omitted, the admin route attaches to the public Gateway with no sectionName and the Gateway selects the listener by hostname. |
| set | required, with its own sectionName — on the same Gateway or another one. The spec is rejected at apply time otherwise. |
# Single-listener or owner-managed Gateway: nothing to name.
spec:
hosts:
host: kc-gw.212.34.182.26.nip.io
adminHost: admin-kc-gw.212.34.182.26.nip.io
ingress:
mode: GatewayAPI
gateway:
parentRef: {name: public, namespace: envoy-gateway-system}
Why the pin is enforced: an unpinned route lands on the plaintext listener
A parentRef without sectionName attaches the route to every listener
on that Gateway that admits the namespace and whose hostname intersects the
route's — including the hostname-less :80 listener a Gateway keeps for
ACME and the HTTP→HTTPS redirect. The admin route is deliberately
unblocked, so that serves the admin console in cleartext. And because
the route carries a hostname while the redirect does not, Gateway API's
hostname-before-path precedence means the admin route out-ranks the very
redirect that would have upgraded it: the redirect stays in place, looks
correct, and never fires for that host.
Measured on Envoy Gateway with one hostname-less :80 listener and one
hostname-pinned listener elsewhere: unpinned, the :80 listener reported
attachedRoutes=1 and answered the admin hostname (503 from an
endpoint-less backend, against 404 for a control hostname — i.e. the
route matched); pinned, attachedRoutes=0 and 404.
Why parentRef.sectionName is the trigger. The operator has no read on
Gateway objects (dropped in the v0.3.0 security review, least privilege),
so it cannot see how many listeners you have or which terminates TLS. A
pinned public route is its only evidence that the Gateway is multi-listener.
If the public route did not need a pin, the public host already rides that
same listener by your choice, and the admin host follows the same posture.
Public means unrestricted, and the operator does not check
Under Public the operator places no constraint on where the admin host
is served, and it has no way to verify how exposed the chosen Gateway or
ingress class actually is. Attaching the admin host to an
internet-facing Gateway puts the Keycloak admin console on the internet,
and the CR will look entirely healthy.
Public is the right default only where the entry point is not, in fact,
public — a cluster-internal Gateway, or an estate where the admin hostname
is not resolvable outside. Otherwise use Internal.
Internal — enforced isolation¶
Shown in full under The Internal variant above:
adminParentRef becomes required and must name a different Gateway, not
another listener on the same one — listeners share their Gateway's addresses,
so a second listener would still put the admin console on the public load
balancer. In the b'nerd stack topology that means the private-LB Gateway,
the Gateway API analogue of the nginx-internal ingress class.
In Ingress mode the same switch makes ingress.adminClass required, and it
must differ from ingress.class.
Set it once for a whole estate on a KeycloakProfile:
apiVersion: k8s.bnerd.com/v1alpha1
kind: KeycloakProfile
metadata: {name: org-defaults}
spec:
adminExposure: Internal
An instance can still opt out with an explicit adminExposure: Public.
The bare / on the public host¶
By default https://<host>/ answers 503. That is deliberate and predates
Gateway API: Keycloak redirects / to /admin/, so serving / on the public
host would hand out the admin console — the same reason /admin is blocked.
The block is right; the bare 503 is unhelpful. Set hosts.rootRedirect to a
path and the public route answers a 301 there instead:
spec:
hosts:
host: iam.example.com
rootRedirect: /realms/master/account/ # 301 instead of 503 on "/"
- The redirect is served by the Gateway, not proxied — the rule carries a
RequestRedirectfilter and no backend. - It stays on the host and scheme the client used; the operator sets neither, so an HTTP request redirects to HTTP and an HTTPS one to HTTPS.
/adminis unaffected: it keeps its own rule to the endpoint-less sink and keeps answering 503.- Leaving it unset keeps the 503 exactly as before.
The value is validated, because it becomes a Location the Gateway emits on
your public host: absolute path, at least one segment (/ alone would redirect
/ to itself), no // anywhere (a leading // is protocol-relative — an open
redirect to another host), not under /admin (a 301 into the block, i.e. a
guaranteed 503), and at most 256 characters.
GatewayAPI mode only
hosts.rootRedirect is honoured in GatewayAPI mode only. In Ingress
mode / keeps answering 503 whatever the field says.
networking.k8s.io/v1 Ingress has no controller-agnostic way to express a
redirect — the usual answer is a controller-specific annotation such as
nginx's configuration-snippet or server-snippet, and the operator
deliberately emits neither: a hardened ingress-nginx
(allow-snippet-annotations=false, the standard secure posture) rejects the
whole Ingress object at admission if such an annotation is present at all.
A field that worked on one ingress controller and broke the Ingress outright
on another would be worse than the 503.
If you want the redirect, that is a reason to move the instance to GatewayAPI mode.
RoutesAccepted¶
GatewayAPI mode adds one condition, mirroring what the Gateway controller says about the routes:
$ kubectl get kci iam -o jsonpath='{range .status.conditions[?(@.type=="RoutesAccepted")]}{.status} {.reason}: {.message}{end}'
True RoutesAccepted: every HTTPRoute parent reports Accepted and ResolvedRefs
| Status / reason | Meaning | Fix |
|---|---|---|
True / RoutesAccepted |
Every parent of every route reports Accepted and ResolvedRefs. |
— |
False / NotAllowedByListeners |
The Gateway exists but its listener does not admit this namespace. | Fix allowedRoutes.namespaces on the listener, or move the instance. |
False / NoMatchingListenerHostname |
No listener's hostname matches hosts.host / hosts.adminHost. |
Widen the listener hostname (e.g. *.example.com) or pick the right sectionName. |
False / BackendNotFound |
The route was accepted but its backend Service is missing. | Usually transient during first reconcile; if it persists, check the instance's Services exist. |
Unknown / RoutesPending, message no Gateway controller has reported on parentRef <ns>/<name> |
Nobody has adjudicated this attachment. Almost always a parentRef naming a Gateway that does not exist, or one whose GatewayClass no installed controller owns. |
Check the name and namespace in gateway.parentRef; kubectl get gateway -A. |
Unknown / RoutesPending, generic message |
The controller has not written status yet. | Normal for a few seconds after creation; the operator re-checks every 30s. |
Ready is deliberately not gated on RoutesAccepted. That is parity with
Ingress mode, where the operator never knew whether a controller had picked the
Ingress up either. A healthy Keycloak with a misconfigured Gateway reports
Ready=True and RoutesAccepted=False — the workload is fine, the path to it
is not, and the two conditions say so separately.
NetworkPolicy defaults follow the mode¶
The operator-managed NetworkPolicy admits :8080 from whatever actually
proxies to Keycloak, and that differs per mode:
- Ingress mode:
kubernetes.io/metadata.name=ingress-nginx(public). The admin rule follows the class the operator resolved:…=ingress-nginx-internalwheningress.adminClassdiffers from the public class, and otherwise the same namespace as the public rule, in which case the two collapse into a single:8080rule. These are legacy defaults — ingress-nginx is retired in the b'nerd estate; setnetworkPolicy.ingressControllerSelectorexplicitly for Traefik or anything else. -
GatewayAPI mode:
kubernetes.io/metadata.name=envoy-gateway-systemfor both the public and the admin rule.This is a named default for Envoy Gateway, not derived from your
gateway.parentRef. Envoy Gateway runs every proxy in the controller's namespace, whatever namespace the Gateway itself lives in — the generated proxy Deployment is calledenvoy-<gateway-namespace>-<gateway-name>-<hash>exactly because the Gateway's namespace has to be encoded in the name. Because both rules resolve to the same namespace, the operator renders the:8080ingress rule once rather than twice.Set
ingressControllerSelector/adminIngressControllerSelectorexplicitly if you rundeploy.type: GatewayNamespace, a non-default controller namespace, or another implementation.
An explicit selector always wins over both. Getting this wrong fails closed,
never open: a policy naming the wrong namespace admits nobody, so the instance
is Ready, the route is Accepted, and every request hangs. That is the safe
direction to be wrong in, but it is quiet — check here first if a Gateway-served
instance looks healthy and serves nothing.
Migrating an instance from Ingress to GatewayAPI¶
The switch is not zero-downtime by itself: entering GatewayAPI mode prunes both Ingresses immediately, so traffic stops arriving at the old entry point the moment the operator reconciles. Sequence it so DNS moves last:
- Have the Gateway, its listener and its certificate ready and
Programmed. - Lower the DNS TTL for the host well in advance.
-
Patch the instance: set
ingress.mode: GatewayAPIandingress.gateway, and removeclass,adminClassandclusterIssuer(they are rejected in the new mode, so a patch that leaves them in place is refused — no half-migrated state).The same applies when a profile flips the mode: an instance that still carries its own
ingress.classthen fails validation rather than being silently migrated, because the two say different things and only a human can say which was meant. Removeingress.classfrom the instance, or keep the profile on Ingress mode. 4. VerifyRoutesAccepted=Trueand curl the Gateway's address directly with aHost:header, before touching DNS:ADDR=$(kubectl -n envoy-gateway-system get svc \ -l gateway.envoyproxy.io/owning-gateway-name=public \ -o jsonpath='{.items[0].status.loadBalancer.ingress[0].ip}') curl -sS -o /dev/null -w '%{http_code}\n' -H 'Host: iam.example.com' \ "http://$ADDR/realms/master" # expect 200 curl -sS -o /dev/null -w '%{http_code}\n' -H 'Host: iam.example.com' \ "http://$ADDR/admin/master/console/" # expect 503 — blocked on the public host curl -sS -o /dev/null -w '%{http_code}\n' -H 'Host: iam-admin.internal.example.com' \ "http://$ADDR/admin/master/console/" # expect 200 on the admin hostNote the console path: Keycloak serves it at
/admin/master/console/and answers404for a bare/admin/from Keycloak itself, so a bare path tells you nothing about whether routing works. 5. Repoint DNS at the Gateway's address.
Going back is the mirror image: setting mode: Ingress and clearing gateway
prunes both HTTPRoutes and re-renders the Ingresses. Nothing is orphaned in
either direction — the operator deletes only objects it owns, so a same-named
object it did not create is left alone.
Installing Gateway API later¶
The operator probes for Gateway API once at startup and decides two things
from it: whether to watch HTTPRoutes, and whether to include them in its
informer cache. Neither can change while the process runs.
So if you install Gateway API on a cluster where the operator is already running:
Until you do, a GatewayAPI-mode instance stays
Failed / GatewayAPIUnavailable
— and the message tells you which situation you are in:
- "HTTPRoute is not served…" — the CRDs genuinely are not there. Install them.
- "Gateway API IS installed, but it was installed after the operator started…" — the CRDs are there and only the restart is missing.
The instance does not start working on its own when the CRDs appear. The HTTPRoute watch and the informer cache are fixed at startup, so the operator deliberately keeps failing the instance until it is restarted rather than half-serving it: applying routes it cannot watch would leave them orphaned by the next mode switch, still routing the host. It retries every 5 minutes, so the instance recovers by itself once the restart happens.
The operator logs which branch it took at startup, so you can always check what it believes:
$ kubectl logs -n bnerd-keycloak-system deploy/<release>-controller-manager | head
INFO setup capability probe {"gatewayAPIPresent": true}
INFO keycloakinstance-setup Gateway API present: watching HTTPRoute and enabling spec.ingress.mode=GatewayAPI
Limitations¶
- The operator never creates a
Gateway,GatewayClassorReferenceGrant. The Gateway is platform infrastructure with its own lifecycle, usually shared by many workloads; an operator creating one per instance would fight whoever owns the cluster's edge. - Verified on Envoy Gateway v1.9.0 only (on kind). Other conformant implementations should work; b'nerd has not tested them.
- The HTTPRoute watch needs an operator restart if the Gateway API CRDs arrive after startup (above).
Readyis not gated onRoutesAccepted(above).- No
TLSRoute,TCPRouteorGRPCRoute— Keycloak is served over HTTP, and TLS terminates at the listener.