Keycloak Docker Created: 07 Apr 2026 Updated: 07 Apr 2026

Keycloak Kubernetes Setup

01Architecture Overview

This setup deploys Keycloak as a StatefulSet backed by a dedicated PostgreSQL instance. External traffic flows through Cloudflare (TLS termination), hits the NGINX Ingress Controller, and is proxied to the Keycloak service on port 8080.

Browser ──HTTPS──▶ Cloudflare ──HTTP──▶ NGINX Ingress ──HTTP──▶ Keycloak :8080 │ ▼ PostgreSQL :5432 │ ▼ PersistentVolume (10Gi)

Keycloak's embedded Infinispan cache uses JGroups for cluster discovery, backed by a headless Service (keycloak-discovery). When you scale to two replicas, sessions replicate automatically between pods.

02Prerequisites

Before you begin, make sure you have the following in place:

  1. A running Kubernetes cluster (k3s, EKS, GKE, AKS, etc.)
  2. NGINX Ingress Controller installed (via Helm or manifest)
  3. A StorageClass that supports dynamic provisioning (e.g. hcloud-volumes, gp3, standard)
  4. kubectl configured and pointing at your cluster
  5. A domain with DNS managed by Cloudflare (or any reverse proxy)

Verify your setup:

kubectl get ingressclass
kubectl get storageclass
kubectl get nodes

03The Complete Manifest

The entire stack lives in a single YAML file. It creates a dedicated namespace, a Secret for credentials, a PostgreSQL Deployment with persistent storage, and the Keycloak StatefulSet with an NGINX Ingress resource.

Namespace & Secrets

apiVersion: v1
kind: Namespace
metadata:
name: keycloak
---
apiVersion: v1
kind: Secret
metadata:
name: keycloak-secrets
namespace: keycloak
type: Opaque
stringData:
# Change these in production!
KC_BOOTSTRAP_ADMIN_USERNAME: "admin"
KC_BOOTSTRAP_ADMIN_PASSWORD: "admin"
POSTGRES_USER: "keycloak"
POSTGRES_PASSWORD: "keycloak"
POSTGRES_DB: "keycloak"

WarningNever use default credentials in production. Generate strong passwords and consider using an external secret manager like Vault, Sealed Secrets, or External Secrets Operator.

PostgreSQL with Persistent Storage

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-data
namespace: keycloak
spec:
accessModes:
- ReadWriteOnce
storageClassName: hcloud-volumes # ← your StorageClass here
resources:
requests:
storage: 10Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
namespace: keycloak
labels:
app: postgres
spec:
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:17
envFrom:
- secretRef:
name: keycloak-secrets
ports:
- name: postgres
containerPort: 5432
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
subPath: pgdata
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: 1000m
memory: 512Mi
volumes:
- name: postgres-data
persistentVolumeClaim:
claimName: postgres-data
---
apiVersion: v1
kind: Service
metadata:
name: postgres
namespace: keycloak
spec:
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432
type: ClusterIP

NoteThe subPath: pgdata is important — PostgreSQL requires the data directory to be empty on first init. Using a subPath prevents the volume root's lost+found directory from causing initialization failures.

Keycloak StatefulSet & Services

# ClusterIP Service — main traffic
apiVersion: v1
kind: Service
metadata:
name: keycloak
namespace: keycloak
spec:
ports:
- port: 8080
targetPort: http
name: http
selector:
app: keycloak
type: ClusterIP
---
# Headless Service — JGroups cluster discovery
apiVersion: v1
kind: Service
metadata:
name: keycloak-discovery
namespace: keycloak
spec:
selector:
app: keycloak
clusterIP: None
ports:
- name: jgroups
port: 7800
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: keycloak
namespace: keycloak
spec:
serviceName: keycloak-discovery
replicas: 1
selector:
matchLabels:
app: keycloak
template:
metadata:
labels:
app: keycloak
spec:
containers:
- name: keycloak
image: quay.io/keycloak/keycloak:26.0.7
args: ["start"]
env:
- name: KC_BOOTSTRAP_ADMIN_USERNAME
valueFrom:
secretKeyRef:
name: keycloak-secrets
key: KC_BOOTSTRAP_ADMIN_USERNAME
- name: KC_BOOTSTRAP_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: keycloak-secrets
key: KC_BOOTSTRAP_ADMIN_PASSWORD
- name: KC_PROXY_HEADERS
value: "xforwarded"
- name: KC_HTTP_ENABLED
value: "true"
- name: KC_HOSTNAME
value: "https://sso.example.com" # ← your domain
- name: KC_HEALTH_ENABLED
value: "true"
- name: KC_CACHE
value: "ispn"
- name: POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: KC_CACHE_EMBEDDED_NETWORK_BIND_ADDRESS
value: "$(POD_IP)"
- name: KC_DB
value: "postgres"
- name: KC_DB_URL_HOST
value: "postgres"
- name: KC_DB_URL_DATABASE
valueFrom:
secretKeyRef:
name: keycloak-secrets
key: POSTGRES_DB
- name: KC_DB_USERNAME
valueFrom:
secretKeyRef:
name: keycloak-secrets
key: POSTGRES_USER
- name: KC_DB_PASSWORD
valueFrom:
secretKeyRef:
name: keycloak-secrets
key: POSTGRES_PASSWORD
ports:
- name: http
containerPort: 8080
- name: jgroups
containerPort: 7800
startupProbe:
httpGet:
path: /health/started
port: 9000
periodSeconds: 2
failureThreshold: 300
readinessProbe:
httpGet:
path: /health/ready
port: 9000
periodSeconds: 10
livenessProbe:
httpGet:
path: /health/live
port: 9000
periodSeconds: 10
resources:
requests:
cpu: 500m
memory: 1024Mi
limits:
cpu: 2000m
memory: 2048Mi

NGINX Ingress (Cloudflare TLS Termination)

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: keycloak-ingress
namespace: keycloak
annotations:
nginx.ingress.kubernetes.io/proxy-buffer-size: "128k"
nginx.ingress.kubernetes.io/proxy-buffers-number: "4"
spec:
ingressClassName: nginx
rules:
- host: sso.example.com # ← your domain
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: keycloak
port:
number: 8080

Why proxy-buffer-size?Keycloak sends large HTTP headers (especially during OIDC token exchanges). Without increasing the buffer size, NGINX returns 502 Bad Gateway errors. The 128k value is a safe default for Keycloak deployments.

04Deploying to the Cluster

Apply the manifest and watch the pods come up:

kubectl apply -f keycloak.yaml

# Watch pod status
kubectl get pods -n keycloak -w

PostgreSQL should be Running within 30 seconds. Keycloak takes longer (1–3 minutes) because it runs database migrations on first start. The startup probe gives it up to 10 minutes (300 × 2s) before Kubernetes considers it failed.

Once both pods show Running and 1/1 READY, verify the Ingress:

kubectl get ingress -n keycloak

05Cloudflare & DNS Configuration

Two things to configure on the Cloudflare dashboard:

DNS Record

Create an A record pointing your domain to the external IP of the NGINX Ingress Controller's LoadBalancer service. Enable the orange cloud (Proxy) for Cloudflare protection.

# Find the external IP
kubectl get svc -n ingress-nginx ingress-nginx-controller
TypeNameContentProxy
AssoYOUR_LOADBALANCER_IPProxied (orange)

SSL/TLS Mode

Set the SSL/TLS encryption mode to Full (not "Full (Strict)"). Since Keycloak runs on HTTP behind the Ingress with no cluster-side certificate, "Full" tells Cloudflare to encrypt the client-facing connection while accepting HTTP from your origin.

ImportantDo not use "Flexible" mode — it can cause redirect loops with Keycloak's KC_PROXY_HEADERS configuration. "Full" is the correct choice.

06Without NGINX Ingress & Persistent Volumes

Not every cluster has an Ingress Controller or a dynamic StorageClass. Here's how to adapt the manifest for a minimal setup — useful for development, testing, or bare-metal clusters without a cloud volume provisioner.

What Changes

ComponentFull SetupMinimal Setup
IngressNGINX Ingress + CloudflareNodePort Service (direct access)
PostgreSQL StoragePVC with StorageClassemptyDir (ephemeral)
TLSCloudflare terminatesNone (HTTP only)
HostnameKC_HOSTNAME setKC_HOSTNAME_STRICT=false

Modified PostgreSQL (No PVC)

Remove the PersistentVolumeClaim entirely and replace the volume with emptyDir:

# No PVC needed — data lives only while the pod runs
spec:
containers:
- name: postgres
image: postgres:17
envFrom:
- secretRef:
name: keycloak-secrets
ports:
- containerPort: 5432
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
volumes:
- name: postgres-data
emptyDir: {}

WarningWith emptyDir, all data is lost when the PostgreSQL pod restarts. This is acceptable for development and testing only. Never use this in production.

NodePort Instead of Ingress

Remove the Ingress resource and change the Keycloak Service type from ClusterIP to NodePort:

apiVersion: v1
kind: Service
metadata:
name: keycloak
namespace: keycloak
spec:
type: NodePort
ports:
- port: 8080
targetPort: http
nodePort: 30080 # ← pick a port in 30000-32767
name: http
selector:
app: keycloak

Keycloak Environment Changes

Replace the hostname configuration in the Keycloak StatefulSet env section:

# Remove this:
- name: KC_HOSTNAME
value: "https://sso.example.com"

# Add these instead:
- name: KC_HOSTNAME_STRICT
value: "false"
- name: KC_PROXY_HEADERS
value: "xforwarded"

Access Keycloak at http://<NODE_IP>:30080.

07Post-Installation Steps

After Keycloak starts, you'll see a warning banner: "You are logged in as a temporary admin user." This is expected. The bootstrap admin account should be replaced with a permanent one.

Create a Permanent Admin

  1. Log in to the Admin Console at https://sso.example.com/admin
  2. Navigate to the master realm → UsersCreate user
  3. Set a username, email, first/last name
  4. Go to Credentials tab → set a strong password (toggle Temporary to Off)
  5. Go to Role mappings → assign the admin role
  6. Log out, log in with the new account, and delete the bootstrap admin user

Create Your First Realm

  1. Click Create Realm next to "Current realm"
  2. Give it a name (e.g. myapp)
  3. Create users and clients (OIDC/SAML) within that realm

Best PracticeNever use the master realm for application users. Keep it exclusively for Keycloak administration. Create separate realms for each application or tenant.

08Production Hardening Checklist

AreaAction
CredentialsUse Kubernetes Secrets with strong, generated passwords. Consider External Secrets Operator or Sealed Secrets.
DatabaseUse a managed PostgreSQL service or a proper operator (CloudNativePG, Zalando). Never run production databases with emptyDir.
ReplicasScale Keycloak to 2 replicas for high availability. JGroups handles session replication automatically.
ResourcesTune CPU/memory requests and limits based on actual load. Monitor with Prometheus + Grafana.
TLSIf not using Cloudflare, configure cert-manager with Let's Encrypt for automated certificate management.
BackupsSet up regular PostgreSQL backups (pg_dump, WAL archiving, or operator-managed backups).
Network PoliciesRestrict traffic so only the Keycloak pods can reach PostgreSQL, and only the Ingress can reach Keycloak.
MonitoringEnable Keycloak metrics endpoint and scrape with Prometheus. Set up alerts for pod restarts and health check failures.


Share this lesson: