CRD Reference — bnerd-keycloak-operator¶
API group: k8s.bnerd.com/v1alpha1
KeycloakInstance¶
Namespaced | Short name: kci
One CR = one Keycloak deployment (StatefulSet + Services + optional Ingresses).
Spec fields¶
| Field | Type | Required | Description |
|---|---|---|---|
spec.profile |
string | no | Name of a cluster-scoped KeycloakProfile whose fields are applied as defaults beneath this spec (instance fields win). Lets you stamp a tiny instance CR against a shared org profile. A referenced-but-missing profile is transient — the instance waits (Pending, ProfilePending, 30s requeue), it does not fail — mirroring a missing KeycloakVersionMap. See the KeycloakProfile section below for the merge rules. |
spec.version |
string | no | Keycloak version to deploy ("26.6.4", "stable", "latest", "26"). Resolved via KeycloakVersionMap/default. Defaults to the latest alias when empty. Ignored when spec.image is set. |
spec.replicas |
int32 | no | StatefulSet replica count. Defaults to 2 (the reference default); must be >= 1 when set — rejected by kubectl apply (CRD schema), not just at reconcile. |
spec.hosts.host |
string | yes | Public account/auth hostname. Drives KC_HOSTNAME=https://<host> and the public Ingress rule. Structurally required even when spec.ingress.enabled: false. |
spec.hosts.adminHost |
string | no | Admin console hostname. Empty = no admin Ingress and KC_HOSTNAME_ADMIN is left unset. Does not gate whether the admin console is reachable on the public host — that block is unconditional; see spec.ingress.annotations below and the Ingress & TLS guide. Setting it is how you actually reach the admin console, not what keeps it off the public one; spec.hosts.adminExposure decides how exposed that admin host may be. |
spec.hosts.adminExposure |
string | no | Public (default when unset) or Internal. Only meaningful with adminHost. Public: unrestricted as to where — the admin host may share the public Gateway/class. ingress.adminClass may be omitted (the admin Ingress then takes the public class); gateway.adminParentRef may be omitted only while parentRef is unpinned, and once parentRef.sectionName is set it must carry its own sectionName. Internal: adminParentRef / adminClass is REQUIRED and must name a different Gateway / class. Neither value changes the public host's /admin block. Carries no CRD default, so a KeycloakProfile can supply it. |
spec.hosts.rootRedirect |
string | no | Path a request for the bare / on the public host is redirected to (301). Empty (default) keeps the historical behaviour: / is part of the admin-console block and answers 503. GatewayAPI mode only — Ingress mode keeps the 503, because networking.k8s.io/v1 has no controller-agnostic redirect and the operator will not emit a controller-specific annotation. Validated: absolute path with at least one segment (/ alone would be a redirect loop), 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), max 256 characters. |
spec.ingress.enabled |
bool | no | Defaults to true. false = render Services only (dev/kind); no Ingress objects are created. |
spec.ingress.mode |
string | no | Serving mode: Ingress (default when unset) or GatewayAPI. Ingress renders networking.k8s.io/v1 Ingress objects; GatewayAPI renders gateway.networking.k8s.io/v1 HTTPRoutes attached to the Gateways named in spec.ingress.gateway. Deliberately has no CRD default, so a profile's mode can still apply to an instance that leaves it unset. See the Serving guide. |
spec.ingress.class |
string | no | Public Ingress class. Defaults to "nginx". Ingress mode only — rejected at apply time in GatewayAPI mode. |
spec.ingress.adminClass |
string | no | Admin Ingress class. Defaults to the public class (spec.ingress.class, or nginx) since v0.3.2 — it defaulted to "nginx-internal" before, see the v0.3.2 upgrade notes. Required, and must differ from class, when hosts.adminExposure: Internal. Ingress mode only — rejected at apply time in GatewayAPI mode. |
spec.ingress.clusterIssuer |
string | no | cert-manager.io/cluster-issuer annotation value. Empty = no TLS annotation/secret on either Ingress (plain HTTP, dev/kind). Ingress mode only — rejected at apply time in GatewayAPI mode, where TLS terminates at the Gateway listener and the operator manages no certificates. |
spec.ingress.gateway |
object | iff mode: GatewayAPI |
The platform-owned Gateways the HTTPRoutes attach to. Required in GatewayAPI mode, rejected in Ingress mode. The operator never creates a Gateway, GatewayClass or ReferenceGrant. |
spec.ingress.gateway.parentRef |
object | yes (in GatewayAPI mode) | The Gateway the public HTTPRoute (hosts.host) attaches to. |
spec.ingress.gateway.parentRef.name |
string | yes | Gateway name. |
spec.ingress.gateway.parentRef.namespace |
string | no | Gateway namespace. Defaults to the instance namespace. A different namespace requires that Gateway listener's allowedRoutes.namespaces to admit the instance namespace — the Gateway owner's configuration, not something the operator can arrange. |
spec.ingress.gateway.parentRef.sectionName |
string | no | Selects a single listener on the Gateway. Empty attaches to every listener that admits the route. |
spec.ingress.gateway.parentRef.kind |
string | no | (v0.3.4) Kind of the referent. Empty means Gateway API's own default (Gateway) — unchanged from every release before v0.3.4, which always attached to a Gateway. Lets parentRef/adminParentRef name something other than a Gateway (e.g. a per-app ListenerSet for a custom domain). Passed through to the rendered HTTPRoute verbatim, unvalidated — a Gateway implementation that cannot resolve the referent surfaces that through the existing RoutesAccepted condition, the same way an unpinned sectionName or a disallowed cross-namespace attachment already does. Group stays fixed to gateway.networking.k8s.io (Gateway API's own inferred default) — not exposed as a separate field. |
spec.ingress.gateway.adminParentRef |
object | Internal: iff hosts.adminHost is set. Public: iff hosts.adminHost is set and parentRef.sectionName is set |
The Gateway the admin HTTPRoute (hosts.adminHost) attaches to. Same field shape as parentRef, including kind. Under Internal it must name a different Gateway than parentRef. Under Public it may name any Gateway including the public one — but once parentRef.sectionName is set it is required and must carry its own sectionName: the admin route is unblocked, and unpinned it also attaches to the Gateway's plaintext listener, bypassing any HTTP→HTTPS redirect. With parentRef unpinned it may be omitted; the admin route then attaches to the public Gateway and the Gateway selects by hostname. |
spec.ingress.annotations |
map[string]string | no | Extra annotations merged onto both rendered objects — the Ingresses in Ingress mode, the HTTPRoutes in GatewayAPI mode; the cluster-issuer annotation (if clusterIssuer is set) always takes precedence. The operator itself never emits an nginx.ingress.kubernetes.io/configuration-snippet annotation — the admin-console block on the public host is implemented via Ingress path routing to an always-empty Service, not a snippet (see the Ingress & TLS guide), so it also works on hardened ingress-nginx (allow-snippet-annotations=false) and non-nginx classes. This field is passed through unvalidated: if you hand-set a configuration-snippet (or any other controller-specific) annotation here yourself, a cluster that rejects it at admission will reject the whole Ingress. |
spec.postgres.managed |
bool | no | true = operator applies a PerconaPGCluster named <name>-pg (capability-gated on the pgv2.percona.com CRD). false = BYO via credentialsSecret. |
spec.postgres.topology |
"" | ha | standalone |
no | Managed-only. ha provisions 3 Postgres instance replicas; anything else (including empty) provisions 1. Any other value is rejected by kubectl apply itself (CRD enum). |
spec.postgres.pgbouncer |
bool | no | Managed-only. Enables the PgBouncer proxy sidecar (2 replicas) in the PerconaPGCluster; when false the proxy block is still emitted (Percona 2.8.2 requires it) with 0 replicas. |
spec.postgres.nodes |
int | no | Managed-only. Overrides the Postgres instance replica count derived from topology: 0 (default) uses the topology default (ha → 3, standalone → 1); any value > 0 wins over it. A negative value is rejected by kubectl apply (CRD schema); setting it with managed: false is rejected at reconcile validation (a schema can't see across sibling fields). |
spec.postgres.credentialsSecret |
string | required when managed: false |
BYO Secret name. Keys: host, dbname, user, password (+ optional port). A Secret missing a required key permanently fails the instance (CredentialsSecretInvalid). |
spec.adminSecret |
string | no | Name of a pre-existing bootstrap admin Secret (keys username, password). Empty (default) = the operator generates <name>-admin-credentials once (random 32-char password, never rotated) and tracks it in status.secrets for finalizer GC. |
spec.providers[] |
[]ProviderSource | no | Each entry is a discriminated union: set exactly one of image, objectRef, configMapRef or secretRef to choose how the JAR reaches the shared providers volume. Setting zero or more than one is rejected at validation (and by a CRD CEL rule). |
spec.providers[].name |
string | yes, per entry | Unique, DNS-label-safe suffix; the initContainer is named provider-<name>. For non-image sources it is also the placed filename (<name>.jar). |
spec.providers[].image |
string | union source | Image carrying the JARs to inject (theme, SPI provider, ...). Must run as a numeric non-root user (e.g. USER 1000:1000) — the pod's runAsNonRoot: true policy makes the kubelet reject root (or unresolvable-username) provider images, and the pod never starts. Its initContainer copies path/*.jar into the shared providers volume mounted internally at /bnerd-providers (distinct from path so the empty volume never shadows the image's JARs); Keycloak reads them at /opt/keycloak/providers. |
spec.providers[].path |
string | no | Directory inside the image holding *.jar files. Only valid with image. Defaults to /providers (the .ref theme image instead uses /theme — set explicitly for images that don't use the default layout). |
spec.providers[].pullPolicy |
corev1.PullPolicy | no | initContainer image pull policy (image source). Defaults to IfNotPresent. |
spec.providers[].objectRef |
ProviderObjectRef | union source | A JAR in S3-compatible object storage. Replaces the removed url source. The operator presigns a short-lived GET for exactly this object and passes it to the fetcher initContainer by reference (a Secret-sourced env var), never as a literal in the pod spec. Requires sha256. |
spec.providers[].objectRef.bucket |
string | yes, with objectRef | Bucket holding the JAR. |
spec.providers[].objectRef.key |
string | yes, with objectRef | Object key within the bucket. |
spec.providers[].sha256 |
string | required with objectRef |
Lowercase-hex SHA-256 (64 chars), verified before the JAR is placed. Required with objectRef, forbidden otherwise. Mandatory rather than advisory: the presigned URL authenticates the source, the digest authenticates the content, and neither substitutes for the other. |
spec.providers[].configMapRef |
string | union source | Name of a ConfigMap whose key holds the JAR bytes (use binaryData). Projected in and copied by a fetcher initContainer. Small JARs only — the object must fit the ~1 MiB etcd value limit. |
spec.providers[].secretRef |
string | union source | Name of a Secret whose key holds the JAR bytes. Same ~1 MiB etcd limit as configMapRef. |
spec.providers[].key |
string | required with configMapRef/secretRef |
The ConfigMap/Secret data/binaryData key holding the JAR. Required with configMapRef/secretRef, forbidden otherwise. |
spec.truststore.secretRefs[] |
[]TruststoreSecretRef | no | Outbound trust: CA certificates Keycloak uses when it dials your services (e.g. AD/LDAPS under a private PKI). This is not the serving certificate — inbound exposure TLS is a separate surface. Each entry is projected into one read-only directory and passed to Keycloak as KC_TRUSTSTORE_PATHS. Appends to the system CAs, never replaces them, so public-CA endpoints keep verifying. Empty (default) renders no volume, no mount and no environment variable. |
spec.truststore.secretRefs[].name |
string | yes, per entry | Name of the Secret holding the CA certificate. |
spec.truststore.secretRefs[].key |
string | yes, per entry | Key within that Secret whose value is the PEM-encoded CA. Required rather than optional on purpose: two CA Secrets both keyed ca.crt is the ordinary case, and a per-entry key lets the operator give each CA its own filename instead of letting one silently shadow the other. |
spec.adopt |
bool | no | Defaults to false. true = this instance runs against an imported, already-populated database, so the operator provisions no admin credential and omits the KC_BOOTSTRAP_ADMIN_* variables entirely. Mutually exclusive with spec.adminSecret (enforced by CEL), so "adopt" unambiguously means the operator supplies no admin. Omitting the variables is honesty, not an optimisation: Keycloak only consumes them when it CREATES the master realm, so against a populated database they are inert — setting them would advertise a credential that does not work. Day-2 recovery for an adopted instance is spec.recoveryAdmin. |
spec.recoveryAdmin |
*RecoveryAdminSpec | no | Day-2 escape hatch: creates an additional admin account on an instance whose credential has been lost. Only meaningful with spec.adopt. Runs a one-shot Job that calls kc.sh bootstrap-admin user and then authenticates as the new account before reporting success — see Recovering admin access for why that second step is not optional. Adds an account; never modifies, resets or removes an existing one. Absent = nothing is created and no condition is reported. |
spec.recoveryAdmin.requestID |
string | yes | Free-form identifier making the action idempotent and non-retrying. The operator acts only while this differs from status.recoveryAdmin.completedRequestID / .failedRequestID, so a reconcile storm cannot re-run a privileged action, and a failed attempt stays failed until a human issues a new ID. Re-submitting the same ID is a deliberate no-op. |
spec.recoveryAdmin.credentialsSecret |
string | yes | Name of a Secret (same namespace) holding the new account's username and password keys. You provide both; the operator generates neither and never writes to this Secret — it takes no ownerReference, so deleting the instance cannot garbage-collect the credential. The username must be generated, not chosen: kc.sh bootstrap-admin exits 0 when the username already exists while creating nothing, so a hand-picked name can silently collide. |
spec.imagePullSecrets |
[]LocalObjectReference | no | Pod-level pull secrets; cover the Keycloak image and every provider image. |
spec.realmImport.secretName |
string | no | Secret containing the realm JSON to import. Mounted read-only at /opt/keycloak/data/import; presence adds --import-realm to the start command. |
spec.image |
string | no | Full image override (registry/repo:tag); bypasses KeycloakVersionMap resolution entirely. status.observedVersion becomes the tag portion of this value. |
spec.resources |
corev1.ResourceRequirements | no | Compute resources for the Keycloak container. Defaults to the reference envelope: 500m/1700Mi request, 2000m/2000Mi limit. |
spec.placement.nodeSelector |
map[string]string | no | Node selector for the Keycloak pod. |
spec.placement.tolerations |
[]corev1.Toleration | no | Tolerations for tainted nodes. |
spec.env |
[]corev1.EnvVar | no | Raw extra environment variables, merged last onto the container env (escape hatch — can override any operator-set variable by name). |
spec.metrics.serviceMonitor |
bool | no | true = create a Prometheus Operator ServiceMonitor (<name>) scraping the client Service's management port at /metrics every 30s. Capability-gated on the monitoring.coreos.com ServiceMonitor CRD: if absent the operator sets MetricsExporterReady=False/ServiceMonitorCRDMissing and continues (it does not fail the instance). Toggling back to false removes the ServiceMonitor. |
spec.networkPolicy.enabled |
bool | no | Defaults to true. Renders an owned <name>-workload NetworkPolicy that default-denies ingress/egress on the instance's pods except the explicit rules below. false removes it (owner-verified — a foreign same-named NetworkPolicy is left untouched). Enforcement depends on the CNI; see the Network isolation guide. |
spec.networkPolicy.ingressControllerSelector |
*metav1.LabelSelector | no | Namespace selector for the "may reach :8080" ingress rule. The default depends on spec.ingress.mode: in Ingress mode kubernetes.io/metadata.name=ingress-nginx (a legacy default — ingress-nginx is retired in the b'nerd estate, set this explicitly for Traefik etc.); in GatewayAPI mode kubernetes.io/metadata.name=envoy-gateway-system — a named default for Envoy Gateway's CONTROLLER namespace, where it runs every proxy regardless of the Gateway's own namespace, and deliberately not derived from spec.ingress.gateway. Set explicitly for deploy.type: GatewayNamespace or another implementation. An explicit selector always wins, and a wrong value fails closed (requests hang), never open. |
spec.networkPolicy.adminIngressControllerSelector |
*metav1.LabelSelector | no | Namespace selector for the SEPARATE "may reach :8080" rule for the admin host. Only rendered when spec.hosts.adminHost is set. Default per spec.ingress.mode: in Ingress mode it follows the resolved admin class — kubernetes.io/metadata.name=ingress-nginx-internal when adminClass differs from the public class, otherwise the public rule's own namespace (the :8080 rule is then rendered once instead of twice); in GatewayAPI mode …=envoy-gateway-system, the same value as the public rule, since both Gateways' proxies run in the controller namespace. Check/override this before upgrading an existing instance with adminHost set; see Upgrading the operator itself. |
spec.networkPolicy.monitoringSelector |
*metav1.LabelSelector | no | Namespace selector for the "may reach :9000" scrape rule. Defaults to kubernetes.io/metadata.name=monitoring. |
spec.networkPolicy.dbEgressCIDR |
string | no | Egress CIDR for a BYO Postgres backend (ignored when postgres.managed: true, which is selected by Percona pod label instead). Defaults to 0.0.0.0/0 on port 5432; set a narrower CIDR to tighten it, or add an extraEgress rule for a non-5432 port. |
spec.networkPolicy.dnsEgressCIDR |
string | no | Additional ipBlock peer for the DNS egress rule, additive to the default peers (a namespaceSelector+podSelector match on CoreDNS's own pods, a plain kube-system namespace-selector, and node-local-dns). Not for the DNS Service's ClusterIP — a NetworkPolicy egress rule is evaluated against the packet's post-DNAT destination, so an ipBlock naming the ClusterIP does not reliably work on an enforcing CNI (confirmed on Calico); the podSelector peer is what fixes DNS resolution by default, with no configuration needed. This field is an escape hatch for a non-DNAT'd DNS endpoint the podSelector peer cannot express (an external resolver, or an unusual topology). No default (unlike every other CIDR field here) for the same failure-asymmetry reason as dbEgressCIDR's counterpart-field reasoning: a permissive default would fail silently open. See Network isolation. |
spec.networkPolicy.egressWhitelist |
[]EgressWhitelistTarget | no | External destinations the running Keycloak process may reach over egress — the ergonomic, purpose-named option for external Identity Provider federation (SAML/OIDC), which the default egress rules do not cover. One entry per destination; name is documentation only, cidr is required (CIDR only, no FQDN), port defaults to 443. Empty/unset renders no additional rules. See Network isolation and the operator-upgrade pre-check. |
spec.networkPolicy.extraIngress |
[]networkingv1.NetworkPolicyIngressRule | no | Appended verbatim to the rendered ingress rules — for a sidecar, an extra scraper, or anything the defaults don't cover. |
spec.networkPolicy.extraEgress |
[]networkingv1.NetworkPolicyEgressRule | no | Appended verbatim to the rendered egress rules — a general escape hatch (e.g. a BYO Postgres on a non-standard port). For external IdP federation specifically, use egressWhitelist above instead. |
spec.cacheMtls |
*bool | no | Defaults to false. true enables mutual TLS on the embedded cache (JGroups/Infinispan) — KC_CACHE_EMBEDDED_MTLS_ENABLED=true. Zero-config: Keycloak 26 auto-generates and rotates the certificates itself. |
Status fields¶
| Field | Type | Description |
|---|---|---|
status.phase |
string | Lifecycle phase: Pending | Provisioning | Deploying | Ready | Failed. |
status.host |
string | Set to spec.hosts.host once the instance reaches Ready. |
status.observedVersion |
string | Keycloak version (or image tag, when spec.image is set) last observed as resolved/running. |
status.observedGeneration |
int64 | The metadata.generation this status reflects — lets a consumer (e.g. kubectl wait, an hq status sync) distinguish a status that reflects the current spec from a stale one. |
status.conditions |
[]metav1.Condition | Validated, BackendsReady, Progressing, Ready, UpgradeInProgress, MetricsExporterReady, RecoveryAdminReady (only when spec.recoveryAdmin is set), and RoutesAccepted (GatewayAPI mode only — removed when the instance leaves the mode). See reason vocabulary below. |
status.recoveryAdmin.completedRequestID |
string | The spec.recoveryAdmin.requestID whose Job succeeded — meaning the account was created and authenticated. |
status.recoveryAdmin.failedRequestID |
string | The requestID whose Job failed. Recorded deliberately without retrying; issue a new requestID to try again. |
status.secrets |
[]string | Names of operator-owned Secrets tracked for finalizer GC (currently: the generated admin-credentials Secret, when the operator created it). |
Condition reason vocabulary¶
| Condition | Reason | Meaning |
|---|---|---|
Validated |
SpecValid |
Spec passed ValidateSpec. |
Validated |
ValidationFailed |
Permanent — bad spec (missing BYO secret name, invalid topology, missing host with ingress enabled, invalid replica count, negative or BYO-only postgres.nodes, invalid provider name). |
Ready |
ProfilePending |
Transient — spec.profile names a KeycloakProfile that does not exist yet. Requeues after 30s; phase stays Pending. |
Validated |
EffectiveSpecInvalid |
Permanent — the profile+instance merged (effective) spec fails validation (e.g. a profile default provider collides in a way that violates a spec rule). |
Ready |
VersionResolutionPending |
Transient — KeycloakVersionMap/default absent, or the requested version/alias not found in it. Requeues after 30s; phase stays Pending. |
BackendsReady |
WaitingForCredentials |
Transient — BYO credentialsSecret not found yet. Requeues after 30s. |
BackendsReady |
CredentialsSecretInvalid |
BYO Secret found but missing a required key. Phase Failed, but bounded-retried (~60s requeue) because the fix — correcting the Secret — is user-side and neither owned nor watched by the operator; a corrected Secret recovers without editing the CR. |
BackendsReady |
PerconaCRDMissing |
Permanent — spec.postgres.managed: true but pgv2.percona.com/PerconaPGCluster is not installed. Never silently falls back. |
BackendsReady |
PostgresProvisioning |
Transient — managed PerconaPGCluster applied, waiting for its <name>-pg-db-credentials Secret. |
BackendsReady |
PostgresReady |
Database credentials are available. |
Ready / Progressing |
WorkloadNotReady / Deploying |
StatefulSet applied, waiting for it to become ready and for the rollout to converge (updatedReplicas >= spec.replicas, updateRevision == currentRevision, observedGeneration caught up) so no old-revision pods still serve. |
Ready |
WorkloadReady |
StatefulSet is ready and the rollout has fully converged onto the current revision; phase is Ready, Progressing is cleared, observedVersion is the running version. |
MetricsExporterReady |
ServiceMonitorApplied |
spec.metrics.serviceMonitor: true and the ServiceMonitor was applied. |
MetricsExporterReady |
ServiceMonitorCRDMissing |
Warning (not Failed) — spec.metrics.serviceMonitor: true but the monitoring.coreos.com ServiceMonitor CRD is absent; deployment continues without metrics scraping. |
Validated |
GatewayAPIUnavailable |
spec.ingress.mode: GatewayAPI but the cluster does not serve HTTPRoute at gateway.networking.k8s.io/v1. Phase Failed, retried every 5 minutes so it recovers on its own once the CRDs are installed — but the HTTPRoute watch still needs an operator restart. Checks the served version, not mere CRD presence: a bundle serving only v1beta1 does not count. |
RecoveryAdminReady |
RecoveryAdminInProgress |
Unknown — the Job exists and has neither succeeded nor failed yet. |
RecoveryAdminReady |
RecoveryAdminCreated |
True — the Job succeeded. Because the Job authenticates as the new account before exiting 0, this means the account exists, is enabled, and accepts the password in your Secret. |
RecoveryAdminReady |
RecoveryAdminFailed |
False — the attempt failed and will not be retried. Inspect the Job's pod logs within 24h (the Job sets ttlSecondsAfterFinished: 86400 and then removes itself), then issue a new spec.recoveryAdmin.requestID. Automatic retry is deliberately absent: a failure means something ambiguous happened against a live identity database, and repeating a privileged write is worse than stopping. |
RecoveryAdminReady |
(condition absent) | No spec.recoveryAdmin was ever requested. Absent rather than False on purpose — False means "attempted and did not work", and reporting it for an instance that never asked would alert on a healthy instance. |
RoutesAccepted |
RoutesAccepted |
Every parent of every rendered HTTPRoute reports Accepted=True and ResolvedRefs=True. |
RoutesAccepted |
NotAllowedByListeners / NoMatchingListenerHostname / BackendNotFound / ... |
False — the reason and message are copied verbatim from the first failing parent, so the Gateway controller's own diagnosis reaches the CR. |
RoutesAccepted |
RoutesPending |
Unknown — no verdict yet. Either the controller has not written status (normal for seconds after creation), or nobody has adjudicated a parentRef, in which case the message names it: a Gateway that does not exist, or one whose GatewayClass no installed controller owns. Re-checked every 30s. |
Ready is not gated on RoutesAccepted — parity with Ingress mode, where the operator never knew whether a controller had picked the Ingress up either. A healthy Keycloak behind a misconfigured Gateway reports Ready=True and RoutesAccepted=False at the same time: the workload is fine, the path to it is not.
On entering Failed or Pending, the operator keeps conditions consistent: Ready is set False with a reason mirroring the failure/pending cause, and any stale Progressing is removed — status is never Failed/Pending while Ready=True. A genuine in-flight UpgradeInProgress is preserved (it is only set during a real cross-minor recreate).
Print columns (kubectl get kci)¶
Phase, Host, Mode, Version, Age
Mode shows spec.ingress.mode, so it is empty for a CR that never set one (which means Ingress). The field carries no CRD default on purpose — see spec.ingress.mode above.
HA / production hardening¶
These are applied automatically — there is no spec surface to disable them:
- PodDisruptionBudget — an owned PDB named
<name>(maxUnavailable: 1) is created when the desired replica count is>= 2. It is removed when the instance is scaled to a single replica and while a cross-minor upgrade is draining (so the drain-to-zero recreate is never blocked). A single-replica instance never gets one. - Pod anti-affinity — soft (
preferredDuringScheduling, weight 100,kubernetes.io/hostname) so replicas spread across nodes where possible, while a single-node dev cluster still schedules them. - Security context — pod-level
runAsNonRoot: true+seccompProfile: RuntimeDefault; the Keycloak container and every provider initContainer getallowPrivilegeEscalation: falseandcapabilities.drop: [ALL].runAsUseris deliberately left unset — the Keycloak/provider images declare their own non-root user, and pinning a UID breaks provider init containers. Consequence for provider images: becauserunAsUseris unset, every provider image must itself declare a numeric non-rootUSER(e.g.USER 1000:1000); the kubelet rejects a root or unresolvable-username image underrunAsNonRoot, and the pod never starts. - Update strategy — the StatefulSet uses an explicit
RollingUpdatestrategy (one-by-one), protecting Infinispan in-memory session state. - Events — a Kubernetes Event (
reason: Phase<NewPhase>) is emitted each timestatus.phasechanges, alongside the upgrade-orchestration Events (UpgradeRecreateStarted,UpgradeCompleted,DowngradeBlocked, ...).
KeycloakVersionMap¶
Cluster-scoped | Short name: kcvm
Maps Keycloak version strings/aliases to container images. The operator always
looks up the KeycloakVersionMap named default.
Spec fields¶
| Field | Type | Required | Description |
|---|---|---|---|
spec.versions |
map[string]VersionEntry | yes | Maps a Keycloak version string (e.g. "26.6.4") to a VersionEntry. |
spec.versions[*].image |
string | yes | Full container image reference (including tag) for this version. |
spec.aliases |
map[string]string | no | Maps symbolic names (e.g. "latest", "stable", "26") to a key present in spec.versions. Resolution is a single alias hop followed by an exact-key match — there is no prefix/fuzzy matching (unlike bnerd-gitlab-operator's resolver). An alias pointing at a version not present in spec.versions is an error, listing the known versions. |
Print columns¶
Latest (from .spec.aliases.latest), Age
KeycloakProfile¶
Cluster-scoped | Short name: kcprofile
A reusable preset of KeycloakInstance defaults. An instance references a profile
by name via spec.profile; the operator merges the profile's fields beneath
the instance spec (the instance always wins) to produce the effective spec it
renders from. The headline use is org-wide default providers — a company theme or
SPI JAR injected into every instance — plus shared ingress classes, resources and
placement, so hq/clients can stamp minimal instance CRs. Every field is optional.
Spec fields¶
| Field | Type | Description |
|---|---|---|
spec.replicas |
int32 | Default StatefulSet replica count. |
spec.adminExposure |
string | no |
spec.rootRedirect |
string | no |
spec.ingress |
IngressSpec | Default ingress settings (enabled, class, adminClass, clusterIssuer, annotations). Merged field-by-field; annotation maps are unioned. |
spec.resources |
corev1.ResourceRequirements | Default Keycloak container resources. requests and limits default independently. |
spec.placement |
Placement | Default nodeSelector / tolerations. |
spec.postgres.topology |
"" | ha | standalone |
Default managed-Postgres topology. Managed-only: presets apply only when the instance's postgres.managed: true; a BYO backend is left untouched. |
spec.postgres.pgbouncer |
bool | Default PgBouncer toggle (managed-only). |
spec.postgres.nodes |
int | Default managed-Postgres node count (managed-only). |
spec.defaultProviders |
[]ProviderSource | Provider JAR sources prepended to every referencing instance's spec.providers, de-duplicated by name with the instance winning. Same entry shape as KeycloakInstance spec.providers[]. This is the org-wide-theme mechanism. |
spec.imagePullSecrets |
[]LocalObjectReference | Pull secrets unioned (by name) with the instance's. |
spec.env |
[]corev1.EnvVar | Extra env merged before the instance's env; on a name collision the instance wins. |
spec.metrics.serviceMonitor |
bool | Default ServiceMonitor toggle. |
spec.networkPolicy |
NetworkPolicySpec | Default NetworkPolicy settings (enabled, selectors, dbEgressCIDR, egressWhitelist, extraIngress/extraEgress) — same shape as KeycloakInstance spec.networkPolicy. Merged field-by-field. |
spec.cacheMtls |
*bool | Default cache-mTLS toggle. |
There is deliberately no postgres.managed or postgres.credentialsSecret on
a profile: whether a backend is managed vs BYO, and its BYO connection secret, are
per-instance decisions a profile can never override.
Merge semantics¶
- Scalars / pointers / structs (
replicas, ingress scalars, resource requests & limits, placement, metrics,cacheMtls): a non-zero instance value wins; an unset instance value takes the profile's. networkPolicy: merged field-by-field like ingress —enabled, each selector,dbEgressCIDR,egressWhitelist,extraIngress,extraEgressare each taken from the profile only where the instance leaves that specific sub-field unset (egressWhitelistwins wholesale likeextraIngress/extraEgress— it is not unioned with the profile's list).- Ingress annotations: unioned; the instance wins on a key collision.
- Postgres presets: applied only to a managed backend; a BYO instance is never touched (so a profile can never corrupt BYO credentials).
defaultProviders: prepended, then de-duplicated by name — an instance re-declaring a provider name keeps its own value and position.env: profile-only vars first, then the instance's; the instance wins on a name collision.imagePullSecrets: unioned by name.
Editing a profile re-reconciles every instance that references it.
Print columns¶
PG-Topology (from .spec.postgres.topology), Age