Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français
Lire en français

Deploy Keycloak on Kubernetes with the Operator

17 min read
Improve this page

Logo Keycloak

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:

CRDRole
KeycloakThe instance itself (replicas, database, hostname, TLS)
KeycloakRealmImportCreate a realm from a manifest
KeycloakOIDCClientDeclare an OIDC client (new in 26)
KeycloakSAMLClientDeclare 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.

Terminal window
kubectl create namespace keycloak
kubectl 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:

Terminal window
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 11s

Provision 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.

postgres.yaml
apiVersion: v1
kind: Secret
metadata:
name: keycloak-db-secret
namespace: keycloak
type: Opaque
stringData:
username: keycloak
password: keycloak-lab-password
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
namespace: keycloak
spec:
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: v1
kind: Service
metadata:
name: postgres
namespace: keycloak
spec:
selector:
app: postgres
ports:
- port: 5432
Terminal window
kubectl apply -f postgres.yaml
kubectl wait --for=condition=ready pod -l app=postgres -n keycloak --timeout=180s
# pod/postgres-0 condition met

Declare the Keycloak instance

Here is the minimal manifest that works for a lab. Every field matters.

keycloak.yaml
apiVersion: k8s.keycloak.org/v2beta1
kind: Keycloak
metadata:
name: keycloak-lab
namespace: keycloak
spec:
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: false

Let us go through the choices:

  • apiVersion: k8s.keycloak.org/v2beta1. Be careful: the official documentation still uses v2alpha1 in several examples. Since Keycloak 26, the storage version is v2beta1: that is the one to write.
  • db.host: postgres points to the PostgreSQL Service. The credentials are read from the Secret, never in clear text.
  • http.httpEnabled: true enables the plain HTTP listener. Without it, Keycloak serves HTTPS with a self-signed certificate, which needlessly complicates a lab.
  • hostname.strict: false turns 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: false stops the Operator from creating an Ingress that we do not need here.

Apply it, then watch the resource conditions:

Terminal window
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=False

Allow 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:

Terminal window
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/TCP

Note 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:

Terminal window
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 -d

Then open a tunnel to the Service:

Terminal window
kubectl port-forward -n keycloak service/keycloak-lab-service 8080:8080

The 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.

realm.yaml
apiVersion: k8s.keycloak.org/v2beta1
kind: KeycloakRealmImport
metadata:
name: intranet-realm
namespace: keycloak
spec:
keycloakCRName: keycloak-lab
realm:
id: intranet
realm: intranet
displayName: Intranet
enabled: true

The Operator launches a Job that pushes the realm into the instance:

Terminal window
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=False

Troubleshooting

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.

SymptomCauseFix
Operator in CrashLoopBackOff, unreadable Java stack traceManifests applied with apply -f: missing CRDs, broken RoleBindingsReinstall with apply -k
Keycloak pod refuses to start, log mentions the hostnameKeycloak 26 requires an explicit hostname in production modehostname.strict: false (lab) or hostname.hostname (prod)
Pod never Ready, probe in connection refusedHealth checks have moved to port 9000Probe :9000, open this port in your NetworkPolicies
Pod killed at startup, OOMKilledMemory limit trimmed "for a lab"Keep the 1700 MiB request, or raise the limit
Redirect loop, Invalid parameter: redirect_uriKeycloak does not trust the reverse proxy headersproxy.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):

Terminal window
# On port 9000: health answers
curl -s http://keycloak-lab-service:9000/health/ready
# {"status": "UP", "checks": [ ...
# On port 8080: nothing
curl -s -o /dev/null -w "%{http_code}\n" http://keycloak-lab-service:8080/health/ready
# 404

Any 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: xforwarded

Be 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 apiVersion is k8s.keycloak.org/v2beta1, despite what some official examples still write.
  • No manifest field is required: an invalid CR is accepted, then fails. Watch the HasErrors condition.
  • The health checks are on port 9000, no longer on 8080. It is the failure that follows version upgrades.
  • KeycloakRealmImport never 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.

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn