Skip to content

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 KeycloakInstanceexcept 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: auth.example.com          # required
    adminHost: auth-admin.example.com   # optional

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: false renders Services only — no Ingress objects at all. Use this for dev/kind clusters without an ingress controller; reach Keycloak via kubectl port-forward.
  • clusterIssuer is the only switch for TLS. Leaving it empty produces plain-HTTP Ingresses (no cert-manager annotation, no TLS secret). Set it to a ClusterIssuer name already provisioned on the cluster to get a cert-manager-issued certificate on both Ingresses.
  • annotations are merged onto both Ingresses, but the cluster-issuer annotation (if clusterIssuer is set) always takes precedence — you cannot override it via annotations. The operator itself never emits an nginx.ingress.kubernetes.io/configuration-snippet annotation (see "Admin-console isolation" below); this field is passed through unvalidated, so if you hand-set a configuration-snippet (or other controller-specific) annotation yourself, a cluster that rejects it at admission — e.g. hardened ingress-nginx with allow-snippet-annotations=false — will reject the whole Ingress.
  • The admin Ingress is only created when spec.hosts.adminHost is set.
  • class, adminClass and clusterIssuer are 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:

spec:
  hosts:
    rootRedirect: /realms/master/account/

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)

spec:
  postgres:
    managed: false
    credentialsSecret: my-keycloak-pg-credentials
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.

spec:
  adminSecret: my-keycloak-admin-credentials   # optional
  • 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 (username admin, a random 32-character password) once and never rotates it on subsequent reconciles. It is tracked in status.secrets for 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:

kubectl logs job/<name>-recovery-admin

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 against sha256 before placing it — a checksum mismatch fails the initContainer, so an unverified JAR never reaches Keycloak. sha256 is mandatory here.
  • configMapRef / secretRef + key: the object's key (put the JAR in binaryData) 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

spec:
  realmImport:
    secretName: my-keycloak-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

spec:
  metrics:
    serviceMonitor: true

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 / monitoringSelector override the default kubernetes.io/metadata.name: ingress-nginx / monitoring namespace match.
  • adminIngressControllerSelector overrides the default match for the admin-ingress rule above (kubernetes.io/metadata.name: ingress-nginx-internal only when the resolved adminClass differs from the public class — otherwise the public rule's namespace). Check this before upgrading an existing instance that has adminHost set — see Upgrading the operator itself.
  • dbEgressCIDR only 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 an extraEgress rule.
  • dnsEgressCIDR is 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.
  • egressWhitelist is 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 :443 rule in the table above is only for a url: provider JAR fetch, an unrelated one-time init-container concern). One entry per destination — name is documentation only (not rendered); cidr is required; port defaults to 443. CIDR targets only — vanilla NetworkPolicy cannot express hostname/FQDN rules (a CNI with FQDN-aware policy support, e.g. Cilium's toFQDNs, 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 / extraEgress append verbatim networkingv1 rules 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: false to 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)

spec:
  cacheMtls: true

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. runAsUser is left unset — the image declares its own non-root user, and pinning a UID breaks provider init containers.
  • Explicit RollingUpdate StatefulSet strategy (one-by-one) and terminationGracePeriodSeconds: 60.
  • Events on every status.phase transition (reason: Phase<NewPhase>).

Reconcile phases

status.phase moves through PendingProvisioningDeployingReady, 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.