This document describes how to safely upgrade the components of the Keycloak OCM bundle.
- Always upgrade one component at a time and verify health before proceeding.
- Take a database backup before any upgrade that touches Keycloak or PostgreSQL.
- All version changes must go through the
component-constructor.yamland a new OCM component release.
Keycloak handles database schema migrations automatically on startup. Minor and patch upgrades are rolling and safe.
Steps:
- Take a CloudNativePG backup (see section 3).
- Update the image tag in
manifests/keycloak/keycloak-deployment.yamlandcomponent-constructor.yaml. - Commit, push, and let the CI pipeline build and publish the new OCM component version.
- Apply the updated manifests to the target cluster:
kubectl apply -f manifests/keycloak/keycloak-deployment.yaml
- Verify the rolling update completes:
kubectl rollout status deployment/keycloak
- Confirm health:
kubectl exec -it deploy/keycloak -- curl -s http://localhost:9000/health/ready
Major Keycloak upgrades may include breaking database schema changes. Keycloak applies migrations automatically on startup, but a concurrent write from the old version during migration causes undefined behaviour. The procedure below serialises the upgrade by scaling to zero before switching the image.
Pre-upgrade checklist:
- Read the upstream Keycloak migration guide for the target version and note any fields that have been removed or renamed in the Admin REST API — these may require updates to operator CRD reconcilers.
- Confirm at least one backup is available and recent (see section 8).
- Ensure
PodDisruptionBudgetis in place (it is, by default). If you have auto-scaling policies that might start new pods, disable them temporarily.
Steps:
- Take a database backup before proceeding:
kubectl apply -f examples/backup-example.yaml
kubectl wait backups.postgresql.cnpg.io/
--for=jsonpath='{.status.phase}'=completed
--timeout=300s -n
2. Scale Keycloak to 0 to prevent any writes to the database during migration:
```sh
kubectl scale deployment keycloak --replicas=0 -n <namespace>
kubectl rollout status deployment/keycloak -n <namespace>
- Update the image tag in
manifests/keycloak/keycloak-deployment.yamlandcomponent-constructor.yaml. - Apply the updated manifest:
kubectl apply -f manifests/keycloak/keycloak-deployment.yaml -n <namespace>
- Scale back to 1 replica so Keycloak performs the DB migration with a single writer:
kubectl scale deployment keycloak --replicas=1 -n <namespace>
- Monitor startup logs — Keycloak logs each migration step and exits non-zero if migration fails:
Wait until the log line
kubectl logs -f deploy/keycloak -n <namespace>
Keycloak X.Y.Z on JVM startedappears before scaling further. - Verify health:
kubectl exec -n <namespace> deploy/keycloak -- \ curl -s http://localhost:9000/health/ready
- Perform a smoke test: open the admin console, issue a token, confirm login works.
- Scale to the production replica count:
kubectl scale deployment keycloak --replicas=<desired> -n <namespace>
Rollback:
If step 6 shows migration errors:
- Scale Keycloak back to 0:
kubectl scale deployment keycloak --replicas=0 -n <namespace>
- Restore the database from the pre-upgrade backup (see section 8.4).
- Revert the image tag in
manifests/keycloak/keycloak-deployment.yamland re-apply. - Scale back up to 1, confirm health, then scale to the desired replica count.
CloudNativePG handles minor PostgreSQL upgrades as in-place rolling restarts.
Steps:
- Update the
imageNameinmanifests/postgres/cluster.yamlto the new minor version tag. - Apply:
kubectl apply -f manifests/postgres/cluster.yaml
- CloudNativePG will perform a rolling restart of the cluster instances.
- Verify the cluster is healthy:
kubectl get cluster keycloak-db kubectl describe cluster keycloak-db
Major PostgreSQL upgrades (e.g., 17 → 18) require a cluster clone + switchover procedure via CloudNativePG's pg_upgrade support.
Steps:
- Ensure a recent backup exists:
kubectl get backups.postgresql.cnpg.io -l cnpg.io/cluster=keycloak-db
2. Create a new cluster manifest (`manifests/postgres/cluster-new.yaml`) targeting the new major version with `bootstrap.pg_upgrade` pointing to the existing cluster.
3. Apply the new cluster — CloudNativePG will handle `pg_upgrade` in-place:
```sh
kubectl apply -f manifests/postgres/cluster-new.yaml
- Monitor the upgrade job:
kubectl logs -l cnpg.io/cluster=keycloak-db-new -f
- Once complete and healthy, update Keycloak's
KC_DB_URL_HOSTto point to the new cluster's service and restart Keycloak. - Decommission the old cluster only after verifying full functionality.
Reference: CloudNativePG Major Upgrades
CloudNativePG supports on-demand backups. To trigger one:
kubectl apply -f - <<EOF
apiVersion: postgresql.cnpg.io/v1
kind: Backup
metadata:
name: keycloak-db-manual-$(date +%Y%m%d%H%M)
spec:
cluster:
name: keycloak-db
method: plugin
pluginConfiguration:
name: barman-cloud.cloudnative-pg.io
EOFNote: Requires the Barman Cloud Plugin with an
ObjectStoreandspec.pluginsenabled on the CNPGCluster. For pre-upgrade safety without an object store, use a volume snapshot if your storage class supports it.
- Update the operator image tag in
component-constructor.yaml. - Re-apply the Helm chart or operator manifests per the CloudNativePG upgrade guide.
- The operator upgrade does not restart database clusters unless a manifest change triggers it.
Reference: CloudNativePG Operator Upgrade
After any upgrade, confirm the full observability stack is operational:
# Check Keycloak metrics endpoint
kubectl exec -it deploy/keycloak -- curl -s http://localhost:9000/metrics | head -20
# Check ServiceMonitor is being scraped (requires Prometheus access)
kubectl get servicemonitor keycloak
# Check PodMonitor for database
kubectl get podmonitor keycloak-db-metrics
# Check PrometheusRules loaded
kubectl get prometheusrule keycloakIf OTEL tracing is enabled (KC_TRACING_ENABLED=true), verify trace data reaches the collector:
kubectl logs -l app=opentelemetry-collector -n observability | grep keycloakBackup and restore are executed with CloudNativePG native resources. This keeps the bundle lightweight and avoids a custom backup/restore orchestration layer.
S3 credentials must be pre-created as a Kubernetes Secret:
kubectl create secret generic keycloak-backup-s3 -n <namespace> \
--from-literal=ACCESS_KEY_ID=<id> \
--from-literal=SECRET_ACCESS_KEY=<secret>Define an ObjectStore and enable plugin WAL archiving on the source cluster:
apiVersion: barmancloud.cnpg.io/v1
kind: ObjectStore
metadata:
name: keycloak-backup-store
namespace: <namespace>
spec:
configuration:
destinationPath: s3://<bucket>/<prefix>
endpointURL: https://<s3-host>
s3Credentials:
accessKeyId:
name: keycloak-backup-s3
key: ACCESS_KEY_ID
secretAccessKey:
name: keycloak-backup-s3
key: SECRET_ACCESS_KEY
wal:
compression: gzip
data:
compression: gzip
---
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: keycloak-db
namespace: <namespace>
spec:
plugins:
- name: barman-cloud.cloudnative-pg.io
isWALArchiver: true
parameters:
barmanObjectName: keycloak-backup-storeApply a CNPG Backup to trigger an immediate backup:
apiVersion: postgresql.cnpg.io/v1
kind: Backup
metadata:
name: keycloak-db-manual
namespace: <namespace>
spec:
cluster:
name: keycloak-db
method: plugin
pluginConfiguration:
name: barman-cloud.cloudnative-pg.iokubectl apply -f <above>.yaml
kubectl wait backups.postgresql.cnpg.io/keycloak-db-manual \
--for=jsonpath='{.status.phase}'=completed \
--timeout=300s -n <namespace>
kubectl get backups.postgresql.cnpg.io keycloak-db-manual -n <namespace>Apply a CNPG ScheduledBackup with cron syntax:
apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
name: keycloak-db-nightly
namespace: <namespace>
spec:
cluster:
name: keycloak-db
schedule: "0 0 2 * * *" # daily at 02:00 UTC; CNPG schedules include seconds
method: plugin
pluginConfiguration:
name: barman-cloud.cloudnative-pg.io# List all CNPG backup resources and phase
kubectl get backups.postgresql.cnpg.io -n <namespace>
# Describe a specific backup
kubectl describe backups.postgresql.cnpg.io <name> -n <namespace>Expected output when complete (example):
NAME PHASE READY AGE
keycloak-db-manual completed true 4m
Restore is performed by creating a new CNPG recovery cluster using bootstrap.recovery
and then cutting Keycloak over to the restored cluster.
Procedure:
- Apply a recovery Cluster manifest pointing at the backup location:
apiVersion: postgresql.cnpg.io/v1 kind: Cluster metadata: name: keycloak-db-restore namespace: <namespace> spec: instances: 1 bootstrap: recovery: source: keycloak-backup externalClusters: - name: keycloak-backup plugin: name: barman-cloud.cloudnative-pg.io parameters: barmanObjectName: keycloak-backup-store serverName: keycloak-db storage: size: 5Gi
kubectl apply -f examples/restore-cluster-example.yaml
2. Wait for the recovery cluster to become ready:
```sh
kubectl wait pod -n <namespace> \
-l cnpg.io/cluster=keycloak-db-restore,cnpg.io/instanceRole=primary \
--for=condition=Ready --timeout=600s
- Cut Keycloak over to the restored cluster and restart:
kubectl set env deployment/keycloak -n KC_DB_URL_HOST=keycloak-db-restore-rw kubectl rollout restart deployment/keycloak -n kubectl rollout status deployment/keycloak -n
4. Verify data integrity: log in to the Keycloak admin console and confirm realms,
clients, and users that existed before the backup are present.
Quick validation command:
```sh
./scripts/tests/test-backup-restore.sh --live \
--namespace <namespace> \
--cluster-name keycloak-db \
--restore-cluster-name keycloak-db-restore \
--destination-path s3://<bucket>/<prefix> \
--credentials-secret keycloak-backup-s3 \
--endpoint-url https://<s3-host>
The following fields serve as Keycloak-side identifiers. Changing them after the resource is
created can create duplicates, orphan Keycloak-side objects, or cause keycloak-config-cli
to interpret the change as a delete/recreate operation. The API server does not enforce
immutability via x-kubernetes-validations, and the current reconcilers do not reject these
changes explicitly. Treat these fields as operationally immutable until validation is added.
| Resource | Immutable field(s) | Reason |
|---|---|---|
Realm |
spec.realmName |
Realm ID in Keycloak; renaming requires full delete/recreate |
Client |
spec.clientId, spec.realmRef |
Client ID is the Keycloak identifier; realmRef change would orphan the object |
ClientScope |
spec.name, spec.realmRef |
Scope name is the Keycloak identifier |
Group |
spec.name, spec.realmRef |
Group name is the Keycloak identifier |
User |
spec.username, spec.realmRef |
Username is the Keycloak identifier |
IdentityProvider |
spec.alias, spec.type, spec.realmRef |
Alias is the IdP identifier; type determines the protocol and cannot be switched in place |
AuthFlow |
spec.alias, spec.realmRef |
Flow alias is the Keycloak identifier |
To change an immutable field: delete the CR and recreate it with the new value. For
non-Realm child resources, the finalizer path triggers a Realm sync so the old object is
removed from the desired export before the new object is created. Realm CR deletion
intentionally preserves the Keycloak realm. Take note of any dependent resources (e.g.
clients that reference a realm by realmRef) - they will also require updating and
post-change verification.
New optional spec fields are introduced without a CRD version bump provided they carry a default value or are omitted from the Keycloak API call when absent. Existing CRs continue to reconcile without modification. No migration steps are required for consumers when an optional field is added.
A change is considered breaking if it:
- removes or renames an existing required field, or
- changes the type or validation constraints of an existing field in a way that would cause currently-valid CRs to fail schema validation.
Breaking changes require a new CRD version (e.g. v1alpha2). Both the old and new versions
shall be served simultaneously with a conversion webhook or documented manual migration path.
The old version shall remain storage: true until all instances have been migrated.
The current version across all CRDs is v1alpha1. No breaking changes have been made since
initial delivery.