Skip to content

Upgrades & Version Management

The operator uses a KeycloakVersionMap CRD to map Keycloak version strings and aliases to container images. Understanding version resolution and how the operator orchestrates same-minor vs. cross-minor changes is essential before performing an upgrade.

How version resolution works

When the operator reconciles a KeycloakInstance, it resolves spec.version through this priority chain:

  1. spec.image (escape hatch) — if set, this exact image is used directly, bypassing KeycloakVersionMap resolution entirely. status.observedVersion becomes the tag portion of this value.
  2. KeycloakVersionMap/default — the operator always looks up the cluster-scoped KeycloakVersionMap named default.
  3. Alias resolution — a value like "latest", "stable", or "26" resolves through spec.aliases to a key present in spec.versions. This is a single alias hop followed by an exact-key match — there is no prefix/fuzzy matching (unlike bnerd-gitlab-operator's resolver).
  4. Exact match"26.6.4" matches the entry directly.

If the version map is not present, or the requested version/alias is not (yet) found in it, resolution is transient: the instance stays Pending with condition reason VersionResolutionPending and requeues after 30 seconds — the map may simply not be applied yet, or may be updated later to add the version.

The default KeycloakVersionMap

examples/versionmap-default.yaml ships the qualified 26.x ladder:

apiVersion: k8s.bnerd.com/v1alpha1
kind: KeycloakVersionMap
metadata:
  name: default
spec:
  versions:
    "26.5.7":
      image: quay.io/keycloak/keycloak:26.5.7
    "26.6.4":
      image: quay.io/keycloak/keycloak:26.6.4
    "26.7.0":
      image: quay.io/keycloak/keycloak:26.7.0
  aliases:
    latest: "26.7.0"
    stable: "26.6.4"
    "26": "26.6.4"
    "26.5": "26.5.7"
    "26.6": "26.6.4"
    "26.7": "26.7.0"

The Helm chart installs this same content automatically (versionMap.installDefault: true, the default) — see Installation § Applying the default KeycloakVersionMap.

Same-minor vs. cross-minor: how an upgrade is applied

The operator inspects the running vs. target Keycloak version and picks one of two upgrade strategies:

Change Strategy Availability
Same minor (e.g. 26.6.326.6.4) Plain one-by-one rolling update on the StatefulSet Stays available throughout
Cross-minor or major (e.g. 26.6.426.7.0) Drain-first recreate: scale to zero, then bring the new version up Full outage for the duration

A cross-minor change never runs mixed versions — the operator always scales the old version to zero before starting the new one, reporting the transition via the UpgradeInProgress condition and a UpgradeCompleted Event once done.

A cross-minor upgrade is a full outage, with no automatic rollback

Because a cross-minor change drains to zero before the new version starts, the instance is unavailable for the duration. If the new version fails to come up, the instance stays down — there is no automatic rollback. Recovery is to fix the target version (correct spec.version, or set the break-glass annotation below and point back at the previous version) and let the operator recreate.

Ready and status.observedVersion only advance once the StatefulSet rollout has fully converged onto the new revision (updatedReplicas >= spec.replicas, updateRevision == currentRevision, observedGeneration caught up) — so readiness is never reported while old-revision pods still serve.

Performing an upgrade

Upgrades are declarative: update spec.version on the KeycloakInstance.

kubectl get kci my-keycloak -n my-keycloak
# NAME          PHASE   HOST                       VERSION   AGE
# my-keycloak   Ready   auth.example.com   26.6.4    30d

kubectl patch kci my-keycloak -n my-keycloak \
  --type merge \
  -p '{"spec":{"version":"26.7.0"}}'

kubectl get kci my-keycloak -n my-keycloak -w
# NAME          PHASE               HOST                       VERSION   AGE
# my-keycloak   UpgradeInProgress   auth.example.com                     30d
# my-keycloak   Ready               auth.example.com   26.7.0    30d

Check status.observedVersion matches spec.version once done:

kubectl get kci my-keycloak -n my-keycloak \
  -o jsonpath='{.status.observedVersion}{"\n"}'

No downgrades (with a break-glass override)

A downgrade is refused by default: the instance is set to phase: Failed, condition reason VersionDowngradeBlocked, and the running workload is left untouched (not scaled down, not recreated).

To force a downgrade in an emergency, set the break-glass annotation:

kubectl annotate kci my-keycloak -n my-keycloak \
  k8s.bnerd.com/allow-downgrade="true" --overwrite

kubectl patch kci my-keycloak -n my-keycloak \
  --type merge \
  -p '{"spec":{"version":"26.6.4"}}'

An allowed cross-minor downgrade is itself performed as a drain-first recreate (same outage profile as a forward cross-minor upgrade) — never a live mixed-version swap.

Upgrading the operator itself

This section is about upgrading the operator (e.g. helm upgrade from 0.1.x to 0.2.0) — a different thing from the Keycloak version upgrades above, which the operator performs for you on a running instance.

0.2.0 pre-upgrade check: default-on NetworkPolicy

0.2.0 renders an owned NetworkPolicy on every instance by default (spec.networkPolicy.enabled defaults to true — see Network isolation). On a cluster whose CNI enforces NetworkPolicy (Cilium, Calico, ...), upgrading the operator immediately starts isolating every already-running instance to a rule set it never had before. Answer these two questions before upgrading, for every existing instance:

  1. Does this instance have spec.hosts.adminHost set, and does its admin ingress controller run in a namespace other than ingress-nginx-internal (the default adminIngressControllerSelector match whenever ingress.adminClass differs from the public class; since v0.3.2 an admin Ingress on the public class reuses the public rule instead)? If yes, set spec.networkPolicy.adminIngressControllerSelector to the correct namespace before upgrading, or the admin console silently stops being reachable.
  2. Does any realm on this instance make its own outbound network calls — the general case, not a fixed list. The two most common instances: external Identity Provider federation (SAML/OIDC to Google, Microsoft, a corporate IdP — outbound HTTPS during login) and an SMTP relay for realm email (see SMTP / outbound email — outbound to whatever host/port the realm's smtpServer config names). Neither, nor anything else a realm is configured to call out to, is covered by any default egress rule (the :443 rule in the isolation table is only for a url: provider JAR fetch, an unrelated one-time init-container concern). If any realm on the instance does this, set spec.networkPolicy.egressWhitelist to that destination's CIDR(s) before upgrading:
    spec:
      networkPolicy:
        egressWhitelist:
          - name: corp-idp     # documentation only, not rendered
            cidr: 203.0.113.5/32
            # port: 443 is the default; set it explicitly for SMTP (587/465/25)
    
    Skipping this means that traffic breaks with no error surfaced by the operator — it's just silently dropped by the CNI. egressWhitelist targets a CIDR only (vanilla NetworkPolicy cannot express hostname/FQDN rules) — resolve the target's IP range up front if you only have a hostname. DNS resolution itself needs no pre-upgrade action. An earlier draft of this fix required setting spec.networkPolicy.dnsEgressCIDR to the cluster's DNS Service ClusterIP; real-cluster testing found that ineffective (a NetworkPolicy egress rule is evaluated against the packet's post-DNAT destination, not the ClusterIP a client dials, so naming that address doesn't help). The DNS egress rule's default peer — a namespaceSelector+podSelector match on CoreDNS's own pods, opening both port 53 and CoreDNS's real container port (commonly 8053) — is verified working reliably on a real Calico cluster and needs no per-instance configuration; see Network isolation for the mechanism and what is and isn't proven about why it works. dnsEgressCIDR remains available as an escape hatch for a non-DNAT'd DNS endpoint the default peer cannot express, but it is not something you need to set for an ordinary upgrade.

Neither question is visible on a kind cluster (kindnet does not enforce NetworkPolicy at all) — a "the e2e suite passed" upgrade check is not sufficient evidence here. If you're not sure whether your cluster's CNI enforces NetworkPolicy, treat both questions as "yes" until you've checked. See also Known Limitations.

An instance that answers "no" to both needs no action — the default rule set already covers it.

Customising the version map

To qualify a new Keycloak version:

kubectl edit keycloakversionmap default

Add the entry under spec.versions:

spec:
  versions:
    "26.7.1":
      image: quay.io/keycloak/keycloak:26.7.1

Update the relevant alias if appropriate:

spec:
  aliases:
    stable: "26.7.1"
    "26.7": "26.7.1"

An alias pointing at a version not present in spec.versions is rejected at apply time (the error message lists the known versions). Editing the map does not automatically trigger a reconcile on running instances — version resolution only re-runs when the KeycloakInstance spec changes.