Keycloak Database Migration
This guide covers migrating Keycloak's database from the in-cluster PostgreSQL (Crunchy PGO) to a cloud-managed PostgreSQL instance. Two target options are supported:
- Shared database — Keycloak reuses the existing CodeMie PostgreSQL instance. No infrastructure changes required.
- Dedicated database — Keycloak gets its own PostgreSQL instance provisioned by Terraform.
Keycloak will be unavailable during the migration.
What Changes
| Component | Before (PGO) | After (Cloud-Managed) |
|---|---|---|
| Database host | keycloak-primary.security.svc | Cloud PostgreSQL endpoint |
| Database user | admin | keycloak_admin |
| Kubernetes secret | keycloak-pguser-admin (auto-created by PGO) | keycloak-postgresql (manually created) |
| Secret keys | password, host, port, dbname, user, uri, jdbc-uri | password only |
| Helm values section | pgo.enabled: true | sharedDatabaseInstance.enabled: true/false |
| Operator | Postgres Operator in postgres-operator namespace | Not required |
Prerequisites
kubectlaccess to the cluster (securityandpostgres-operatornamespaces)- Updated
codemie-helm-chartsrepository with the new Keycloak configuration deployment_outputs.envwith target database connection details
- Update the Terraform repository and set
TF_VAR_keycloak_db_config='{"enabled":true}'indeployment.conf. - Apply Terraform to provision a dedicated PostgreSQL instance.
- Copy
deployment_outputs.envtocodemie-helm-charts.
Step 1: Stop Keycloak
kubectl scale statefulset keycloakx -n security --replicas=0
kubectl get pods -n security -l app.kubernetes.io/name=keycloakx
Step 2: Dump the PGO Database
Launch a temporary pod and dump the database. The pod will be reused in Step 5 for restoring.
KC_PGO_PASSWORD=$(kubectl get secret keycloak-pguser-admin -n security \
-o jsonpath='{.data.password}' | base64 -d)
kubectl run pg-tmp \
--image=postgres:17-alpine \
--restart=Never \
-n security \
-- sleep 3600
kubectl wait pod/pg-tmp -n security \
--for=condition=Ready --timeout=60s
kubectl exec pg-tmp -n security -- sh -c \
"PGPASSWORD='${KC_PGO_PASSWORD}' pg_dump \
--host=keycloak-primary.security.svc \
--port=5432 \
--username=admin \
--dbname=keycloak \
--no-owner \
--no-privileges \
--format=plain \
--file=/tmp/keycloak-dump.sql"
# Save a local backup copy
kubectl cp security/pg-tmp:/tmp/keycloak-dump.sql ./keycloak-dump.sql
Step 3: Create Kubernetes Secrets
- Dedicated Database
- Shared Database
source deployment_outputs.env
# Password for the keycloak_admin database user
kubectl create secret generic keycloak-postgresql \
--from-literal=password="${KEYCLOAK_POSTGRES_DATABASE_PASSWORD}" \
--namespace security
source deployment_outputs.env
KEYCLOAK_DB_PASSWORD=$(openssl rand -base64 16 | tr -d '=+/' | head -c 16)
# Password for the keycloak_admin database user
kubectl create secret generic keycloak-postgresql \
--from-literal=password="${KEYCLOAK_DB_PASSWORD}" \
--namespace security
# CodeMie RDS admin credentials (used by the Helm hook Job to create the keycloak DB and user)
kubectl create secret generic codemie-postgresql \
--from-literal=PG_USER="${CODEMIE_POSTGRES_DATABASE_USER}" \
--from-literal=PG_PASS="${CODEMIE_POSTGRES_DATABASE_PASSWORD}" \
--namespace security
Step 4: Prepare the Target Database
- Dedicated Database
- Shared Database
Edit keycloak-helm/values-<cloud_name>.yaml:
database:
hostname: "<KEYCLOAK_POSTGRES_DATABASE_HOST value>"
# ...
sharedDatabaseInstance:
enabled: false
initImage: alpine/psql:18.3
Edit keycloak-helm/values-<cloud_name>.yaml with the CodeMie database hostname:
database:
hostname: "<CODEMIE_POSTGRES_DATABASE_HOST value>"
# ...
sharedDatabaseInstance:
enabled: true
initImage: alpine/psql:18.3
Deploy the Keycloak chart with replicas=0 to trigger the init Job that creates the keycloak database and keycloak_admin user:
helm upgrade --install keycloak keycloak-helm/. \
-n security \
--values keycloak-helm/values-<cloud_name>.yaml \
--set keycloakx.replicas=0 \
--wait \
--timeout 900s \
--dependency-update
The init Job is automatically deleted after successful completion.
Step 5: Restore the Dump
Restore from the dump file that is already inside pg-tmp (created in Step 2).
- Dedicated Database
- Shared Database
kubectl exec pg-tmp -n security -- sh -c \
"PGPASSWORD='${KEYCLOAK_POSTGRES_DATABASE_PASSWORD}' psql \
--host='${KEYCLOAK_POSTGRES_DATABASE_HOST}' \
--port=5432 \
--username=keycloak_admin \
--dbname=keycloak \
--file=/tmp/keycloak-dump.sql"
KEYCLOAK_DB_PASSWORD=$(kubectl get secret keycloak-postgresql -n security \
-o jsonpath='{.data.password}' | base64 -d)
kubectl exec pg-tmp -n security -- sh -c \
"PGPASSWORD='${KEYCLOAK_DB_PASSWORD}' psql \
--host='${CODEMIE_POSTGRES_DATABASE_HOST}' \
--port=5432 \
--username=keycloak_admin \
--dbname=keycloak \
--file=/tmp/keycloak-dump.sql"
Clean up the temporary pod:
kubectl delete pod pg-tmp -n security
Step 6: Deploy Updated Keycloak
helm upgrade --install keycloak keycloak-helm/. \
-n security \
--values keycloak-helm/values-<cloud_name>.yaml \
--wait \
--timeout 900s \
--dependency-update
Step 7: Verify
kubectl get pods -n security -l app.kubernetes.io/name=keycloakx
kubectl logs -n security -l app.kubernetes.io/name=keycloakx --tail=20
Then verify in the browser:
- Open the Keycloak admin console
- Check that realms, clients, and users are present
- Test an SSO login flow
Step 8: Clean Up PGO Resources
The PGO PostgreSQL pods and secrets in the security namespace are removed automatically by helm upgrade (the postgres-cluster.yaml template no longer exists in the chart). Only the Postgres Operator itself remains in a separate namespace:
helm uninstall postgres-operator -n postgres-operator
kubectl delete namespace postgres-operator