KeycloakInstance Guide¶
KeycloakInstance is the primary workload resource — one CR describes one
Keycloak deployment. This guide covers each part of the spec that needs more
than a one-line field reference; for the full field-by-field list see the
CRD Reference.
Reconcile picture¶
flowchart LR
U([kubectl apply]) -->|KeycloakInstance CR| K8s[(Kubernetes API)]
K8s --> Op[Operator reconcile loop]
Op -->|1| Val[Validate spec]
Op -->|1b| Prof[Resolve profile\nmerge defaults]
Op -->|2| Ver[Resolve version\nvia KeycloakVersionMap]
Op -->|3| PG[Provision/check Postgres\nmanaged or BYO]
Op -->|4| Admin[Ensure admin bootstrap Secret]
Op -->|5| Render[Render + SSA child resources]
Op -->|6| Watch[Watch StatefulSet readiness]
Every child object carries a controller owner reference — Kubernetes garbage
collection deletes them with the KeycloakInstance — except the managed
PerconaPGCluster, which is deliberately retained. See
Managed backend retention.
Profiles (shared defaults)¶
Set spec.profile: <name> to inherit defaults from a cluster-scoped
KeycloakProfile. The operator
resolves the profile right after validation and merges its fields beneath the
instance spec — the instance always wins field-by-field — so you can keep an
instance CR minimal while a shared profile carries the org-wide ingress classes,
resources, placement and, above all, default provider JARs (a company theme
injected into every instance). Provider lists are prepended and de-duplicated by
name; env is merged with the instance winning; managed-Postgres presets apply
only to a managed backend (a BYO instance is never touched). A referenced profile
that does not exist yet is transient — the instance waits in Pending
(ProfilePending), it does not fail. See
examples/keycloakprofile-org-defaults.yaml
for the full pattern and the CRD Reference for the exact merge rules.
Hosts¶
spec.hosts.host is structurally required, even when
spec.ingress.enabled: false — it always drives KC_HOSTNAME. Omitting
adminHost means no admin Ingress is created and KC_HOSTNAME_ADMIN stays
unset — but the admin console still isn't reachable on the public host: see
Admin-console isolation below, which is
enforced independently of this field.
Ingress & TLS¶
Two serving modes, selected by spec.ingress.mode: Ingress (the default,
documented here) and GatewayAPI (Gateway API HTTPRoutes attached to a
platform-owned Gateway). See Serving: Ingress vs Gateway API
for the GatewayAPI mode, the migration recipe and the mode comparison table.
Leaving mode unset keeps the Ingress behaviour below unchanged.
spec:
ingress:
enabled: true # default; false = Services only (dev/kind)
mode: Ingress # default; the other value is GatewayAPI
class: nginx # default; public Ingress class
adminClass: nginx-internal # admin Ingress class; DEFAULTS TO class
clusterIssuer: letsencrypt-prod # empty = no TLS annotation, plain HTTP
annotations:
custom.example.com/foo: bar
enabled: falserenders Services only — no Ingress objects at all. Use this for dev/kind clusters without an ingress controller; reach Keycloak viakubectl port-forward.clusterIssueris the only switch for TLS. Leaving it empty produces plain-HTTP Ingresses (no cert-manager annotation, no TLS secret). Set it to aClusterIssuername already provisioned on the cluster to get a cert-manager-issued certificate on both Ingresses.annotationsare merged onto both Ingresses, but the cluster-issuer annotation (ifclusterIssueris set) always takes precedence — you cannot override it viaannotations. The operator itself never emits annginx.ingress.kubernetes.io/configuration-snippetannotation (see "Admin-console isolation" below); this field is passed through unvalidated, so if you hand-set aconfiguration-snippet(or other controller-specific) annotation yourself, a cluster that rejects it at admission — e.g. hardenedingress-nginxwithallow-snippet-annotations=false— will reject the whole Ingress.- The admin Ingress is only created when
spec.hosts.adminHostis set. class,adminClassandclusterIssuerare Ingress-mode only. In GatewayAPI mode they are rejected at apply time rather than silently ignored — there is no Ingress to carry a class, and TLS terminates at the Gateway listener.
The bare /¶
/ on the public host is part of the admin-console block below and answers
503. In GatewayAPI mode you can turn that into a redirect instead:
Ingress mode keeps the 503 whatever this field says — networking.k8s.io/v1
has no controller-agnostic redirect, and the operator will not emit a
controller-specific annotation to fake one (a hardened ingress-nginx rejects
the whole Ingress when a snippet annotation is present). See
Serving.
Admin-console isolation¶
The admin console and Admin REST API (everything under /admin, plus the
bare /) are never reachable on the public host — regardless of whether
adminHost is set. This is not implemented via KC_HOSTNAME_ADMIN: per
Keycloak's own hostname (v2) guide,
that option only changes which URL Keycloak advertises — "Using the
hostname-admin option does not prevent accessing the Administration REST
API endpoints via the frontend URL... If you want to restrict access to the
Administration REST API, you need to do it on the reverse proxy level." So
the operator does exactly that, without an nginx snippet: the public
Ingress routes /admin and the bare / to a Service that intentionally
selects no Pods, so it always has zero endpoints — any spec-conformant
Ingress controller answers such a backend with 503 rather than ever
proxying the request through to Keycloak. This works on any Ingress class,
snippets-disabled or not.
Where the admin host itself is served is spec.hosts.adminExposure:
Public (the default) lets it share the public Gateway or ingress class and is
unrestricted; Internal makes the operator require a separate Gateway or
ingress class. Neither changes the block described here. See
Admin exposure — and note that before v0.3.2 the
Internal behaviour was unconditional.
Setting spec.hosts.adminHost doesn't loosen that block — it adds a
separate, deliberately-routed admin Ingress that routes unblocked straight
to Keycloak, which is the only way to actually reach the admin console through
this operator. Where that admin Ingress is served is your call: since
v0.3.2 adminClass defaults to the public class, and nginx-internal (or
whatever your internal controller is called) has to be named — either
explicitly, or by setting hosts.adminExposure: Internal, which makes the
operator require it. adminHost must differ from host —
the operator rejects the spec otherwise, since an Ingress controller merging
rules for the same host from two Ingress objects could resolve the conflict
in a way that bypasses the block.
Normalizing obfuscated request paths (//admin, /%2fadmin, /./admin)
before matching them against the Ingress rules is the Ingress controller's
job, not the operator's: any spec-conformant controller decodes and
collapses the path before selecting a backend, so those variants resolve to
the same /admin deny route.
Postgres: BYO vs. Managed¶
BYO (managed: false)¶
apiVersion: v1
kind: Secret
metadata:
name: my-keycloak-pg-credentials
stringData:
host: pg.example.com # required
dbname: keycloak # required
user: keycloak # required
password: <password> # required
port: "5432" # optional; Keycloak defaults to 5432 when absent
These four/five keys are wired directly to KC_DB_URL_HOST /
KC_DB_URL_DATABASE / KC_DB_USERNAME / KC_DB_PASSWORD (and
KC_DB_URL_PORT when port is present) via secretKeyRef. A Secret missing
a required key fails the instance permanently (CredentialsSecretInvalid)
rather than starting Keycloak against a broken DB config — if the Secret
does not exist yet, the instance instead stays Pending and retries.
Managed (managed: true)¶
spec:
postgres:
managed: true
topology: ha # "" (default, 1 replica) or "ha" (3 replicas)
pgbouncer: true # enables the PgBouncer proxy sidecar (2 replicas)
nodes: 0 # 0 = topology default; >0 overrides it
Requires the pgv2.percona.com CRD — see
Installation § Percona PostgreSQL Operator.
The operator applies a PerconaPGCluster named <name>-pg and waits for
Percona to write its generated credentials Secret,
<name>-pg-db-credentials — that Secret is wired in place of a BYO
Secret automatically; you never create or reference a Secret yourself in
this path.
spec.postgres.nodes is managed-only and must be >= 0; setting it with
managed: false is rejected at validation.
Admin bootstrap credentials¶
Keycloak's bootstrap admin username/password always come from a Secret via
secretKeyRef (KC_BOOTSTRAP_ADMIN_USERNAME / KC_BOOTSTRAP_ADMIN_PASSWORD)
— never inline values.
- Set: the operator uses that pre-existing Secret verbatim (keys
username,password); it is not created or garbage-collected by the operator. - Empty (the default): the operator generates
<name>-admin-credentials(usernameadmin, a random 32-character password) once and never rotates it on subsequent reconciles. It is tracked instatus.secretsfor finalizer garbage collection.
Adopting an existing database¶
An adopted instance runs against a database imported from somewhere else — a migration from a self-hosted Keycloak, most commonly — which already contains a master realm, its users, and its admin accounts.
spec:
adopt: true # mutually exclusive with spec.adminSecret
postgres:
credentialsSecret: imported-keycloak-db
With adopt: true the operator provisions no admin credential and omits the
KC_BOOTSTRAP_ADMIN_* variables entirely. The existing admins in the imported
database are the admins; the operator has no account and cannot log in.
That omission is honesty rather than an optimisation, and the distinction
matters if you are tempted to set the variables yourself. Keycloak consumes
KC_BOOTSTRAP_ADMIN_* only when it CREATES the master realm. Against a
database that already has one they are inert — no account is created, no
existing account is changed, and nothing anywhere reports that the values were
ignored. Setting them would advertise a credential that does not work, and you
would find out at the login screen during whatever incident sent you looking.
The trade is deliberate: the operator holds no standing credential to your identity store. When the customer's own admin credential is lost, use the recovery path below.
Recovering admin access¶
spec.recoveryAdmin creates an additional admin account on an instance
whose credential has been lost. It adds an account — it never modifies,
resets, or removes an existing one.
First create a Secret holding the account you want. You generate both values;
the operator generates neither, and it never writes to this Secret or takes
an ownerReference on it, so deleting the instance cannot garbage-collect the
credential you are relying on:
kubectl create secret generic kc-recovery \
--from-literal=username="recovery-$(openssl rand -hex 4)" \
--from-literal=password="$(openssl rand -base64 24)"
⛔ Generate the username; do not choose one. kc.sh bootstrap-admin exits 0
when the username already exists while creating nothing. A hand-picked name
like admin or recovery can silently collide with an account already in the
imported database, and the failure mode is a success message and a credential
that does not work.
Then request it:
spec:
adopt: true
recoveryAdmin:
requestID: incident-4711 # any value; changing it is what re-runs
credentialsSecret: kc-recovery
The operator runs a one-shot Job that creates the account and then
authenticates as it before reporting success. That second step is the point
of the design, not a belt-and-braces extra: because bootstrap-admin exits 0
on a collision, its exit code is not evidence that anything was created. The
Job requests a real token as the new account, so a success means the account
exists, is enabled, and accepts your password.
Watch the condition, never the Job's exit code:
kubectl get keycloakinstance <name> \
-o jsonpath='{.status.conditions[?(@.type=="RecoveryAdminReady")]}'
| State | Meaning |
|---|---|
Unknown / RecoveryAdminInProgress |
The Job is running. |
True / RecoveryAdminCreated |
Created and verified by login. Use it. |
False / RecoveryAdminFailed |
Failed, and it will not be retried. |
A failure is terminal by design. The operator does not retry on its own: a
failed bootstrap-admin means something ambiguous happened against a live
identity database, and repeating a privileged write is worse than stopping.
Inspect the Job's pod logs, then issue a new requestID — re-submitting the
same one is a deliberate no-op, so nothing will happen if you just reconcile
again.
⏱ Collect those logs within 24 hours. The Job sets
ttlSecondsAfterFinished: 86400, so it and its Pod delete themselves a day
after finishing — deliberately, so one-shot Jobs do not accumulate in your
namespace. After that the diagnostic is gone:
Losing the Job does not lose the operator's record of the request: a completed
request is never re-run, and a failed one stays failed, because both are tracked
in status.recoveryAdmin rather than inferred from whether the Job still
exists. That holds whether the Job went away by TTL or because you deleted it.
After you have regained access, treat the recovery account like any other break-glass credential — remove it, or rotate its password through the admin console, once the incident is closed. The operator does not manage its lifecycle beyond creating it.
Providers: theme + SPI JAR injection¶
Most b'nerd Keycloak instances need custom JARs — a login theme, an SPI
authenticator/event-listener, or both. spec.providers is a first-class
list; each entry becomes its own initContainer that copies *.jar from
path inside the provider image into the shared
/opt/keycloak/providers volume mounted into the Keycloak container:
spec:
providers:
- name: theme # initContainer name: provider-theme
image: registry.bnerd.com/my-org/keycloak-theme:1.4.0
path: /theme # dir inside the image holding *.jar; default "/providers"
- name: audit-spi # initContainer name: provider-audit-spi
image: registry.bnerd.com/my-org/keycloak-audit-spi:2.1.0
# path omitted -> defaults to /providers
imagePullSecrets:
- name: registry-bnerd-com # pod-level; covers the Keycloak image + all providers
Provider names must be unique, non-empty, and DNS-label-safe — they become
the provider-<name> initContainer name.
Provider images must run as a numeric non-root user
The pod runs under runAsNonRoot: true, so the kubelet rejects any
provider (or Keycloak) image whose configured user is root — the pod
never starts. Build provider images with an explicit numeric USER (e.g.
USER 1000:1000); a username the kubelet cannot resolve to a non-zero UID
is also rejected.
Each provider initContainer copies *.jar from path into the shared
providers volume, which it mounts internally at /bnerd-providers (a
distinct path from the in-image path, so the empty volume never shadows
the image's JARs); the Keycloak container sees the result at
/opt/keycloak/providers.
Injecting JARs without building an image¶
An image is not the only source. Each spec.providers[] entry is a union —
set exactly one of image, url, configMapRef or secretRef — so you
can inject a JAR without building and pushing a carrier image:
spec:
providers:
- name: org-theme # placed as org-theme.jar in the providers volume
url: https://cdn.my-org.eu/keycloak-theme-1.4.0.jar
sha256: 3b1f… # 64 lowercase hex chars; MANDATORY with url
- name: audit-spi # from a ConfigMap/Secret binaryData key
configMapRef: audit-spi-jar
key: provider.jar # required with configMapRef/secretRef
url+sha256: a fetcher initContainer downloads the JAR over HTTPS (https://only) and verifies it againstsha256before placing it — a checksum mismatch fails the initContainer, so an unverified JAR never reaches Keycloak.sha256is mandatory here.configMapRef/secretRef+key: the object'skey(put the JAR inbinaryData) is projected in and copied into the providers volume. For small JARs only — the ConfigMap/Secret must fit the ~1 MiB etcd value limit.
The fetcher/copy initContainers use an operator-level image (default
curlimages/curl:8.10.1); air-gapped installs override it via the chart value
providerFetcher.image (env PROVIDER_FETCHER_IMAGE). Unlike image providers,
these sources do not need a numeric-non-root USER — the fetcher container is
pinned to run as uid/gid 65532. The url fetcher also pins the transport to
HTTPS on both the request and any redirect (--proto '=https'
--proto-redir '=https'), so a redirect cannot downgrade the download before the
checksum is verified.
JAR filenames share one volume — last writer wins
Every provider — whichever source type — copies its JAR(s) into the one
shared providers volume, in spec.providers order. A url/configMapRef/
secretRef source places its file as <name>.jar, so two providers that
resolve to the same filename (or a non-image provider whose <name>.jar
matches a JAR baked into an image provider you also list) silently
overwrite each other and the later list entry wins. Give every provider a
unique name, and pick names that do not collide with JARs shipped inside
any image provider in the same instance. (Provider names are also capped
at 50 characters so the derived pod volume name stays within the 63-character
Kubernetes limit.)
See examples/keycloakinstance-full.yaml for a full theme + SPI example.
Truststore¶
spec.truststore configures the CA certificates Keycloak uses for
outbound TLS connections — when it dials your services, most
commonly your own AD/LDAPS server under a private/internal PKI.
Not your serving certificate
This is the single most likely thing to get backwards on this page. Truststore is about Keycloak trusting connections it makes out. The certificate presented to a browser or API client connecting in to Keycloak is a separate, independent surface — see Ingress & TLS / the BYO serving certificate guide for that. Changing one never touches the other.
spec:
truststore:
secretRefs:
- name: customer-root-ca
key: ca.crt
- name: customer-issuing-ca
key: ca.crt
| Field | Required | Description |
|---|---|---|
spec.truststore.secretRefs[] |
no | Empty (default): no truststore volume, no mount, no environment variable — zero footprint on an instance that doesn't set it. |
spec.truststore.secretRefs[].name |
yes, per entry | Name of the Secret (in the instance namespace) holding the CA certificate. |
spec.truststore.secretRefs[].key |
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 naming the key lets each CA get its own filename instead of one silently shadowing the other, or the mount failing outright. |
Every entry is projected into one read-only directory, and the
operator points Keycloak's own KC_TRUSTSTORE_PATHS option at that single
path — deliberately one directory, not a list of paths: the design
depends on neither Keycloak's list-parsing nor its directory recursion,
just the one documented case. There's no concatenation step, no generated
bundle file, and no keystore password to manage on either side — if
you're looking for a knob to configure a bundle format, there isn't one,
because the operator doesn't build one.
The append behaviour is upstream Keycloak's own, not something this operator implements. The custom CA(s) are appended to the system trust store, never a replacement — every public CA Keycloak already relies on keeps working. This was measured, not assumed: a Keycloak instance given a truststore containing only a private CA still verified a public-CA endpoint over outbound HTTPS. The acceptance test for this feature is exactly that property — a public-CA endpoint must still verify correctly after a private CA is added — not merely that the private CA arrived, since a future Keycloak release could change this native behaviour and only the append-property check would catch it.
Not on KeycloakProfile
Truststore is deliberately instance-only. KeycloakProfile doesn't
carry spec.truststore — it's a curated subset of what an instance
can express (it has no spec.providers either), and this is a
recorded omission, not something waiting to be added.
Realm Import¶
The named Secret's contents are mounted read-only at
/opt/keycloak/data/import; its presence adds --import-realm to the start
command. Omit realmImport entirely to skip import (the default).
SMTP / outbound email¶
SMTP is realm-scoped in Keycloak — there are no server-level SMTP settings
and no KC_SMTP_* environment variables, so the operator exposes no spec.smtp
field. Configure outbound mail where Keycloak actually reads it: in the realm's
smtpServer map, either through the Admin Console (Realm Settings → Email) or
by including it in the realm JSON you import. For example, the imported realm
Secret can carry:
{
"realm": "my-realm",
"smtpServer": {
"host": "smtp.example.eu",
"port": "587",
"from": "no-reply@example.eu",
"fromDisplayName": "Example Auth",
"ssl": "false",
"starttls": "true",
"auth": "true",
"user": "smtp-user",
"password": "s3cr3t"
}
}
Because realmImport is one-shot (applied at first boot), change SMTP settings
afterwards through the Admin Console or the admin API rather than by editing the
import Secret.
Network isolation: the SMTP relay needs its own egress rule
If Network isolation is enabled (the default), the
running Keycloak pod cannot reach an SMTP relay outside the default rule
set — no default egress rule covers it. Add the relay's CIDR (and its
port — 587/465/25, not the :443 default) to
spec.networkPolicy.egressWhitelist, or realm emails silently stop
sending with no error surfaced by the operator. This is the same
mechanism, and the same pre-upgrade check, as external Identity Provider
federation — see the
operator-upgrade pre-check
if you're enabling this or upgrading an instance that already uses SMTP.
Resources & placement¶
spec:
resources:
requests:
cpu: 500m
memory: 1700Mi
limits:
cpu: 2000m
memory: 2000Mi
placement:
nodeSelector:
dedicated: my-keycloak
tolerations:
- key: dedicated
operator: Equal
value: my-keycloak
effect: NoSchedule
env:
- name: KC_LOG_LEVEL
value: DEBUG
spec.resources defaults to the reference envelope shown above if omitted.
spec.env is a raw escape hatch merged last onto the container env — it can
override any operator-set environment variable by name; use it sparingly.
Optional metrics¶
Creates 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.
Network isolation¶
Every instance gets an owned <name>-workload NetworkPolicy (spec.networkPolicy,
enabled defaults to true) that selects the instance's pods and sets both
Ingress and Egress policy types — so those pods are default-deny in both
directions except the rules below:
| Direction | Peer | Ports |
|---|---|---|
| Ingress | the ingress controller's namespace | :8080 |
| Ingress | the admin ingress controller's namespace, only when spec.hosts.adminHost is set |
:8080 |
| Ingress | this instance's own pods | :7800 (JGroups), :8080, :9000 |
| Ingress | the monitoring namespace | :9000 (Prometheus scrape) |
| Egress | CoreDNS's pods (by label) + kube-system + node-local-dns + dnsEgressCIDR (unset by default) |
DNS (:53 and CoreDNS's real container port, UDP/TCP) |
| Egress | this instance's own pods | :7800 (JGroups) |
| Egress | managed: the Percona cluster's pods; BYO: dbEgressCIDR (default 0.0.0.0/0) |
:5432 |
| Egress | anywhere | :443, only when a url: provider source is present |
The admin-ingress rule exists because the admin Ingress may be served by a
separate controller from the public one — a different namespace on a real
cluster, which the public ingressControllerSelector rule would not cover.
Since v0.3.2 the operator follows the class it actually resolved: an
adminClass that differs from the public class keeps the
ingress-nginx-internal default, while an admin Ingress on the public class
(the new default, see Admin exposure) reuses the
public rule's namespace and the two rules collapse into one.
The two ingress-controller defaults depend on spec.ingress.mode, because
what actually opens the connection to Keycloak differs per mode:
spec.ingress.mode |
public rule default | admin rule default |
|---|---|---|
Ingress (default) |
kubernetes.io/metadata.name=ingress-nginx |
…=ingress-nginx-internal when adminClass differs from the public class; otherwise the same as the public rule (rendered once) |
GatewayAPI |
…=envoy-gateway-system |
…=envoy-gateway-system (same rule, rendered once) |
The nginx values are legacy defaults — ingress-nginx is retired in the
b'nerd estate, so set ingressControllerSelector explicitly for Traefik or
anything else. In GatewayAPI mode the default is envoy-gateway-system — a
named default for Envoy Gateway's controller namespace, where it runs every
proxy regardless of where the Gateway itself lives, and deliberately not derived
from ingress.gateway. Both the public and admin rules resolve there, so the
:8080 rule is rendered once. An explicit selector always wins, and a wrong
value fails closed (requests hang), never open.
This closes the exposure where, without a NetworkPolicy, any pod in the
cluster could reach the instance's :8080 and spoof X-Forwarded-* headers
(KC_PROXY_HEADERS=xforwarded trusts them from any source by default).
How the DNS egress rule reaches CoreDNS, and why
A plain namespaceSelector: kube-system peer opening only port 53 is the
NetworkPolicy example most people reach for, but on at least one
real-cluster CNI (Calico) it does not reliably admit traffic to the
DNS Service's ClusterIP — only to CoreDNS's Pod IPs directly —
because egress NetworkPolicy is evaluated against the packet's
post-DNAT destination (the real CoreDNS Pod and its real container
port), not the pre-DNAT ClusterIP and Service port every pod's
/etc/resolv.conf actually dials. An earlier version of this fix tried
an ipBlock peer naming the ClusterIP directly, and that failed for the
identical reason — an address is still the wrong kind of match once
DNAT has already rewritten it. Both failures are consistent with this
explanation, but it has not been isolated from an alternative: the
working rule also changes the peer's identity (namespace-only to
namespace+pod) at the same time it opens the real container port, and no
single-variable test separating the two has been run yet. This note
states what is proven, not a guess at which factor is decisive.
What is proven, on the default path (dnsEgressCIDR unset) against a
real Calico shoot, across the full test matrix: the rule rendered by
default here — a namespaceSelector + podSelector peer matching
CoreDNS's own pods (kubernetes.io/metadata.name: kube-system +
k8s-app: kube-dns), admitting both port 53 (the Service port every
client dials) and CoreDNS's real container port (8053 on the clusters
tested so far, since that varies by distribution) — resolves DNS
reliably. The plain kube-system namespaceSelector peer is also still
rendered, for a CNI that evaluates egress pre-DNAT (where it would be the
one that matters).
If you're diagnosing DNS on your own CoreDNS egress rule on another CNI: check whether it opens only port 53 and omits CoreDNS's real container port — that's the one change here proven to matter regardless of which explanation is right.
spec.networkPolicy.dnsEgressCIDR is not for the DNS Service's
ClusterIP — see the field reference below for what it's actually for.
You should not need to set it for ordinary DNS resolution to work; the
default peer above handles that.
spec:
networkPolicy:
ingressControllerSelector: # override the default ingress-nginx match
matchLabels:
kubernetes.io/metadata.name: my-ingress-namespace
dbEgressCIDR: 10.20.0.0/16 # BYO Postgres outside the default 0.0.0.0/0
dnsEgressCIDR: 10.20.0.53/32 # a non-DNAT'd DNS endpoint the podSelector peer can't reach — NOT the kube-dns ClusterIP
egressWhitelist:
- name: corp-idp # documentation only, not rendered
cidr: 203.0.113.5/32
# port: 443 is the default
extraEgress:
- to:
- ipBlock: {cidr: 203.0.113.0/24} # e.g. an external SMTP relay
ports:
- protocol: TCP
port: 587
ingressControllerSelector/monitoringSelectoroverride the defaultkubernetes.io/metadata.name: ingress-nginx/monitoringnamespace match.adminIngressControllerSelectoroverrides the default match for the admin-ingress rule above (kubernetes.io/metadata.name: ingress-nginx-internalonly when the resolvedadminClassdiffers from the public class — otherwise the public rule's namespace). Check this before upgrading an existing instance that hasadminHostset — see Upgrading the operator itself.dbEgressCIDRonly applies to a BYO backend on the default port 5432; a managed backend is selected by the Percona pod label instead. A BYO Postgres on a non-5432 port needs anextraEgressrule.dnsEgressCIDRis not the DNS-reachability fix — see the note above for the peer that actually is. This field is an additive escape hatch for a DNS endpoint the default podSelector peer cannot express because it isn't CoreDNS's own pods: an external resolver, or an unusual DNS topology. It is unset by default and you should not need to set it for ordinary DNS resolution against the cluster's own CoreDNS.egressWhitelistis for external Identity Provider federation (SAML/OIDC login to Google, Microsoft, a corporate IdP, ...): the running Keycloak process calls the IdP's endpoints over HTTPS during login, and no default rule covers that (the:443rule in the table above is only for aurl:provider JAR fetch, an unrelated one-time init-container concern). One entry per destination —nameis documentation only (not rendered);cidris required;portdefaults to443. CIDR targets only — vanilla NetworkPolicy cannot express hostname/FQDN rules (a CNI with FQDN-aware policy support, e.g. Cilium'stoFQDNs, could, but that's a CNI-specific CRD this operator does not render). Unset/empty adds nothing. Check this before upgrading an existing instance whose realms federate externally — see Upgrading the operator itself.extraIngress/extraEgressappend verbatimnetworkingv1rules for anything else not covered by a purpose-named field above — a sidecar, an extra scraper, a non-IdP external service.- Set
spec.networkPolicy.enabled: falseto opt an instance out entirely.
Enforcement depends on the CNI. The NetworkPolicy object is always
rendered (unless disabled), but it is only enforced by a policy-aware CNI
(Cilium, Calico, Antrea, ...). kindnet — the CNI on a plain kind cluster —
ignores NetworkPolicy objects, so the isolation is present but inert there.
Cache transport encryption (opt-in)¶
Enables mutual TLS on the embedded cache (JGroups/Infinispan) —
KC_CACHE_EMBEDDED_MTLS_ENABLED=true. This is genuinely zero-config: Keycloak
26 generates and rotates the certificates itself (stored in the database); no
keystore/truststore to provision. Defaults to false.
Child resource names¶
For a KeycloakInstance named <name>:
| Object | Name |
|---|---|
| StatefulSet | <name> |
Client Service (:8080) |
<name> |
| Headless discovery Service (JGroups DNS_PING) | <name>-discovery |
| Public Ingress | <name> |
Admin Ingress (only when spec.hosts.adminHost is set) |
<name>-admin |
PodDisruptionBudget (only when desired replicas >= 2, not while draining) |
<name> |
NetworkPolicy (unless spec.networkPolicy.enabled: false) |
<name>-workload |
ServiceMonitor (only when spec.metrics.serviceMonitor: true + CRD present) |
<name> |
Generated admin bootstrap Secret (only when spec.adminSecret is empty) |
<name>-admin-credentials |
Managed Postgres cluster (only when spec.postgres.managed: true) |
<name>-pg |
| Managed Postgres credentials Secret | <name>-pg-db-credentials |
Managed backend retention¶
The managed PerconaPGCluster is retained on KeycloakInstance
deletion — it carries no owner reference to the KeycloakInstance. Only
operator-owned Secrets (tracked in status.secrets, e.g. the generated
admin credentials) are garbage-collected by the finalizer. This prevents
accidental data loss; remove the PerconaPGCluster manually once you've
verified the data is no longer needed.
HA / production hardening¶
Applied automatically — there is no spec surface to disable these:
- PodDisruptionBudget
<name>(maxUnavailable: 1) when desired replicas>= 2. Removed on scale-to-1 and while a cross-minor upgrade drains, so the drain-to-zero recreate is never blocked. - Soft pod anti-affinity spreads replicas across nodes while still scheduling on a single-node cluster.
- Security context: pod-level
runAsNonRoot+seccompProfile: RuntimeDefault;allowPrivilegeEscalation: false+capabilities.drop: [ALL]on the Keycloak container and every provider initContainer.runAsUseris left unset — the image declares its own non-root user, and pinning a UID breaks provider init containers. - Explicit
RollingUpdateStatefulSet strategy (one-by-one) andterminationGracePeriodSeconds: 60. - Events on every
status.phasetransition (reason: Phase<NewPhase>).
Reconcile phases¶
status.phase moves through Pending → Provisioning → Deploying →
Ready, or Failed on a permanent error (invalid spec, missing Percona CRD,
an invalid BYO credentials Secret). A missing or not-yet-installed
KeycloakVersionMap, or a version not (yet) present in it, is treated as
transient — the instance stays Pending with condition reason
VersionResolutionPending and requeues after 30 seconds rather than failing
permanently.