This lesson deploys Keycloak on Kubernetes with the official Operator. You install the Operator and its CRDs (the object types it adds to Kubernetes), provision the PostgreSQL database it does not ship, declare an instance with the Keycloak resource, import a realm (the isolated space that groups users, clients and settings) declaratively, and diagnose startup failures. It is aimed at intermediate to advanced readers who already know kubectl. All commands and outputs come from a real cluster running Keycloak 26.7.0. The latest stable release is 26.8.1 (13 October 2026): the lesson has not been replayed on it yet.
What you will learn
- Install the Keycloak Operator and its four CRDs without breaking permissions.
- Deploy an instance with PostgreSQL and check its status conditions.
- Import a realm declaratively, and know the limit that trips everyone up.
- Diagnose a stuck pod: hostname, health check port, memory.
- Choose between the Operator and a Helm chart with full knowledge of the trade-offs.
Prerequisites
- a working Kubernetes cluster and a configured
kubectl(a local cluster such as k3d is enough); - about 2 GB of free RAM for the Keycloak pod, which requests 1700 MiB by default;
- some Keycloak background: if the tool is new to you, start with the Docker installation (in French), which is much simpler.
How the Operator works
An Operator is a controller that extends the Kubernetes API with your own object types, declared through CRDs (Custom Resource Definitions). Instead of writing a StatefulSet, a Service and a dozen environment variables yourself, you declare a Keycloak resource, and the Operator builds and watches everything else. If the concept is new to you, the page on Kubernetes operators (in French) lays the groundwork.
The Keycloak 26 Operator installs four CRDs in the k8s.keycloak.org group:
| CRD | Role |
|---|---|
Keycloak | The instance itself (replicas, database, hostname, TLS) |
KeycloakRealmImport | Create a realm from a manifest |
KeycloakOIDCClient | Declare an OIDC client (new in 26) |
KeycloakSAMLClient | Declare a SAML client (new in 26) |
Remember that number, four: it explains the first failure in this lesson.
Install the Operator
The installation uses Kustomize, with the version pinned. It is the official method, and pinning is not a matter of comfort.
kubectl create namespace keycloakkubectl apply -k 'github.com/keycloak/keycloak-k8s-resources/kubernetes?ref=26.7.0'Check that the four CRDs are registered, then that the Operator is running:
kubectl get crd | grep keycloak# keycloakoidcclients.k8s.keycloak.org# keycloakrealmimports.k8s.keycloak.org# keycloaks.k8s.keycloak.org# keycloaksamlclients.k8s.keycloak.org
kubectl get pods -n keycloak# NAME READY STATUS RESTARTS AGE# keycloak-operator-849dc9ddcd-xttb2 1/1 Running 0 11sProvision PostgreSQL
The Operator manages no database at all. It is written plainly in the documentation, and it is the first surprise: under the Operator, Keycloak runs in production mode, so it needs an external database. Providing one is up to you.
For a lab, an ephemeral PostgreSQL is enough. In production, use a managed database or a dedicated operator.
apiVersion: v1kind: Secretmetadata: name: keycloak-db-secret namespace: keycloaktype: OpaquestringData: username: keycloak password: keycloak-lab-password---apiVersion: apps/v1kind: StatefulSetmetadata: name: postgres namespace: keycloakspec: serviceName: postgres replicas: 1 selector: matchLabels: app: postgres template: metadata: labels: app: postgres spec: containers: - name: postgres image: postgres:17-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193 ports: - containerPort: 5432 env: - name: POSTGRES_DB value: keycloak - name: POSTGRES_USER valueFrom: secretKeyRef: name: keycloak-db-secret key: username - name: POSTGRES_PASSWORD valueFrom: secretKeyRef: name: keycloak-db-secret key: password - name: PGDATA value: /var/lib/postgresql/data/pgdata volumeMounts: - name: data mountPath: /var/lib/postgresql/data volumes: - name: data emptyDir: {}---apiVersion: v1kind: Servicemetadata: name: postgres namespace: keycloakspec: selector: app: postgres ports: - port: 5432kubectl apply -f postgres.yamlkubectl wait --for=condition=ready pod -l app=postgres -n keycloak --timeout=180s# pod/postgres-0 condition metDeclare the Keycloak instance
Here is the minimal manifest that works for a lab. Every field matters.
apiVersion: k8s.keycloak.org/v2beta1kind: Keycloakmetadata: name: keycloak-lab namespace: keycloakspec: instances: 1 db: vendor: postgres host: postgres database: keycloak usernameSecret: name: keycloak-db-secret key: username passwordSecret: name: keycloak-db-secret key: password http: httpEnabled: true hostname: strict: false ingress: enabled: falseLet us go through the choices:
apiVersion: k8s.keycloak.org/v2beta1. Be careful: the official documentation still usesv2alpha1in several examples. Since Keycloak 26, the storage version isv2beta1: that is the one to write.db.host: postgrespoints to the PostgreSQL Service. The credentials are read from the Secret, never in clear text.http.httpEnabled: trueenables the plain HTTP listener. Without it, Keycloak serves HTTPS with a self-signed certificate, which needlessly complicates a lab.hostname.strict: falseturns off strict hostname validation. Keycloak 26 is uncompromising on this point: in production mode it requires either an explicit hostname or this setting turned off, otherwise it refuses to start.ingress.enabled: falsestops the Operator from creating an Ingress that we do not need here.
Apply it, then watch the resource conditions:
kubectl apply -f keycloak.yaml
kubectl get keycloaks/keycloak-lab -n keycloak \ -o go-template='{{range .status.conditions}}{{.type}}={{.status}}{{"\n"}}{{end}}'# Ready=True# HasErrors=False# RollingUpdate=FalseAllow about 70 seconds before Ready=True: Keycloak is slow to start. The Operator has created a StatefulSet and two Services. The output below is an excerpt that keeps only the useful columns:
kubectl get statefulset,svc -n keycloak# statefulset.apps/keycloak-lab 1/1# service/keycloak-lab-service ClusterIP 8080/TCP,9000/TCP# service/keycloak-lab-discovery ClusterIP None 7800/TCPNote the -discovery Service, headless (without a virtual IP address), declared on port 7800. The Operator also creates it as the StatefulSet's serviceName, which gives each pod a stable DNS name. The pods find each other to form the Infinispan cache cluster (the distributed cache Keycloak uses for its sessions) when you raise instances above 1; on the version taught here, this discovery goes through the database (see below).
Log in to the console
The Operator generates a temporary administrator account in a Secret named after your resource:
kubectl get secret keycloak-lab-initial-admin -n keycloak \ -o jsonpath='{.data.username}' | base64 -d# temp-admin
kubectl get secret keycloak-lab-initial-admin -n keycloak \ -o jsonpath='{.data.password}' | base64 -dThen open a tunnel to the Service:
kubectl port-forward -n keycloak service/keycloak-lab-service 8080:8080The console is at http://localhost:8080. If port 8080 is already taken on your machine (by another Keycloak instance in Docker, for example), change the local port: kubectl port-forward ... 8081:8080.
Import a realm declaratively
The KeycloakRealmImport resource creates a realm from a manifest. The realm field accepts a complete RealmRepresentation, that is, the same JSON object a realm export produces.
apiVersion: k8s.keycloak.org/v2beta1kind: KeycloakRealmImportmetadata: name: intranet-realm namespace: keycloakspec: keycloakCRName: keycloak-lab realm: id: intranet realm: intranet displayName: Intranet enabled: trueThe Operator launches a Job that pushes the realm into the instance:
kubectl apply -f realm.yaml
kubectl get jobs -n keycloak# NAME STATUS COMPLETIONS DURATION# intranet-realm Complete 1/1 19s
kubectl get keycloakrealmimport intranet-realm -n keycloak \ -o go-template='{{range .status.conditions}}{{.type}}={{.status}} {{end}}'# Done=True Started=False HasErrors=FalseTroubleshooting
Keycloak on Kubernetes fails most often for one of these five reasons. The table gives the symptom as you will see it, not the theoretical error message.
| Symptom | Cause | Fix |
|---|---|---|
Operator in CrashLoopBackOff, unreadable Java stack trace | Manifests applied with apply -f: missing CRDs, broken RoleBindings | Reinstall with apply -k |
| Keycloak pod refuses to start, log mentions the hostname | Keycloak 26 requires an explicit hostname in production mode | hostname.strict: false (lab) or hostname.hostname (prod) |
Pod never Ready, probe in connection refused | Health checks have moved to port 9000 | Probe :9000, open this port in your NetworkPolicies |
Pod killed at startup, OOMKilled | Memory limit trimmed "for a lab" | Keep the 1700 MiB request, or raise the limit |
Redirect loop, Invalid parameter: redirect_uri | Keycloak does not trust the reverse proxy headers | proxy.headers: xforwarded |
Port 9000, the failure that follows every upgrade
Since Keycloak 25, the health endpoints have left the application port for a dedicated management port, 9000. You can check it with one command, run from a pod inside the cluster (the Service name only resolves there):
# On port 9000: health answerscurl -s http://keycloak-lab-service:9000/health/ready# {"status": "UP", "checks": [ ...
# On port 8080: nothingcurl -s -o /dev/null -w "%{http_code}\n" http://keycloak-lab-service:8080/health/ready# 404Any probe, Ingress or NetworkPolicy that still queries 8080/health/ready will mark the pod as failing even though it works perfectly. It is a frequent cause of pods that are "never Ready" after a version upgrade.
The myth of the probe that kills Keycloak
You often read that the liveness probe would kill Keycloak before it has finished starting. With the Operator, this is false: the default startupProbe allows ten minutes of grace. If your pod dies at startup, look at memory (OOMKilled) or the hostname instead. Cutting the memory limit to 512 MiB "because it is a lab" is the best way to create this problem for yourself.
The reverse proxy trap
Behind an Ingress that terminates TLS, Keycloak believes it is on plain HTTP and builds redirect URLs starting with http://. The result is a redirect loop or Invalid parameter: redirect_uri. The fix takes one line in the resource:
spec: proxy: headers: xforwardedBe wary of tutorials still online that recommend proxy: edge or KC_PROXY=edge: this option no longer exists since Keycloak 24. Its presence in a guide shows that the guide is out of date.
Moving to production
This lab runs on a single instance. Three points change the picture as soon as you want several.
Sessions are shared between pods. Since Keycloak 26.0, user sessions are persisted in the database by default, then loaded on demand into distributed Infinispan caches: a user routed to another pod, or after a restart, finds their session again. Authentication sessions, those of a login in progress, stay in the distributed authenticationSessions cache.
For the pods to form a single cluster, Keycloak uses the jdbc-ping stack: each pod registers itself in the database, which lets the others discover it. It is the stack the Operator configures by default since Keycloak 26.3 (the kubernetes stack is deprecated). The caches then exchange data from pod to pod on port 7800 (data transfer, encrypted by TLS with automatically generated keys), and failure detection uses port 57800. A NetworkPolicy must therefore let these two ports through between the Keycloak pods. If the pods cannot see each other, a user in the middle of logging in may fail when their next request reaches another pod.
Sticky sessions no longer bring much. Since Keycloak 26.6, the documentation states that they no longer offer a significant benefit and recommends spreading the load evenly across nodes. The spi-sticky-session-encoder options that appended the node route to the AUTH_SESSION_ID cookie have been deprecated since 26.8. If your proxy needs affinity, use an affinity cookie owned by the proxy, rather than relying on a cookie managed by Keycloak.
An upgrade interrupts the service. The Operator does a rolling update when the configuration changes, but switches to a recreate with downtime as soon as the image changes, because schema migrations require a single instance to touch the database. Three replicas therefore do not guarantee a version upgrade without interruption.
On hardening (TLS, headers, audit logs), the principles apply to any installation: see the page on operational security (in French).
Key takeaways
- No official Helm chart exists and none will: the Operator is the supported route.
- Install with
kubectl apply -k ...?ref=26.7.0. Applying the manifests by hand breaks the CRDs and the permissions. - The Operator does not provide a database: PostgreSQL has to be provisioned separately.
- The current
apiVersionisk8s.keycloak.org/v2beta1, despite what some official examples still write. - No manifest field is required: an invalid CR is accepted, then fails. Watch the
HasErrorscondition. - The health checks are on port 9000, no longer on 8080. It is the failure that follows version upgrades.
KeycloakRealmImportnever updates an existing realm, and does so without saying. For GitOps, use keycloak-config-cli or Terraform.- With several instances, the pods discover each other through the database (jdbc-ping) and exchange on ports 7800 and 57800, which you must open in your NetworkPolicies; user sessions have been persisted in the database since Keycloak 26.0, authentication sessions travel in the distributed cache, and an image change is still a service interruption.