Lab Metadata
| Attribute | Value |
|---|---|
| Lab ID | IB-SIA-06 |
| Track | Identity Bytes Senior IAM Architect Track (36 lab curriculum) |
| Difficulty | Advanced (requires IB-SIA-01 and IB-SIA-02; IB-SIA-04 and IB-SIA-05 recommended so there are real sessions and credentials to preserve) |
| Core technologies | Keycloak 26.x in production mode, PostgreSQL 16, Infinispan embedded cache, JGroups (JDBC_PING discovery), Nginx as a load balancer, Docker Compose |
| Protocols and standards | JDBC based cluster discovery, distributed cache replication, HTTP reverse proxy with X-Forwarded headers, health and readiness probes, session affinity |
| Builds on | IB-SIA-02 (the northgate realm is migrated from the embedded database into PostgreSQL and served by the cluster) |
| Feeds into | IB-SIA-07 (SCIM lifecycle against a resilient IdP), IB-SIA-12 (session management on the cache you build here), IB-SIA-35 (identity observability, extending the health and metrics endpoints) |
Lab Title and Description
No Single Point of Failure: Clustering Keycloak with an External Database and a Replicated Cache
Across the first five labs you built something powerful and, quietly, something dangerous. Every application at Northgate now delegates its login to one Keycloak container backed by an embedded database. That container is convenient, and it is a single point of failure sitting under the entire estate. When it stops, every application's login stops at the same moment, and because the database lives inside the same container, a lost disk is a lost realm. Production identity cannot work this way. The identity provider is usually the first service that must come up in a disaster and the last that is allowed to go down.
This lab re-architects Keycloak into a genuinely available service, and it does so in the order a real migration follows. First you externalise state: the realm, users, clients, and credentials move out of the throwaway embedded database into a dedicated PostgreSQL instance, so a Keycloak node becomes stateless and disposable. Then you run two nodes instead of one and teach them to find each other and share live session state through an Infinispan distributed cache, using JGroups discovery over that same database so there is no fragile static node list. Then you place an Nginx load balancer in front, so clients reach the cluster through one stable address while traffic spreads across the nodes.
The proof is the part that matters. You will log in as a real user through the load balancer, identify which node served you, and then kill that node while the session is live. The user stays logged in, served seamlessly by the surviving node, because the session was replicated the moment it was created. You will then bring the dead node back and watch it rejoin the cluster and re-balance. By the end you will have a reference highly available IdP and, more importantly, the ability to reason about where identity state lives, how it survives failure, and what a load balancer must and must not do in front of an authentication server. Estimated completion time is 4 to 4.5 hours.
Prerequisites
3.1 Prior labs required
| Lab | Why it is required |
|---|---|
IB-SIA-01 | OpenLDAP remains the federated user store; the clustered nodes connect to it exactly as the single node did |
IB-SIA-02 | The northgate realm is the workload being made highly available; you migrate it from the embedded database into PostgreSQL |
IB-SIA-04, IB-SIA-05 (recommended) | The authorization code flow and the MFA credentials give you real sessions and stored authenticators to preserve through the migration and to exercise during the failover test |
Confirm the single node environment is present before you dismantle it
# The existing single node and directory must be running so we can # export the realm from it before rebuilding. docker start ib-openldap ib-keycloak ib-phpldapadmin curl -s http://localhost:8081/realms/northgate/.well-known/openid-configuration | jq -r '.issuer' # Expected: http://localhost:8081/realms/northgate
If the realm does not answer, rebuild Lab 02 first. This lab migrates that realm; it does not recreate it.
3.2 System requirements
| Resource | Minimum | Recommended |
|---|---|---|
| Operating system | Ubuntu 22.04, macOS 13, or Windows 11 with WSL2 | Ubuntu 22.04 LTS |
| RAM | 8 GB (two Keycloak nodes, PostgreSQL, OpenLDAP, and Nginx run together) | 12 GB |
| CPU | 4 cores | 6 cores |
| Disk | 16 GB free | 25 GB free |
| Network | Host ports 8081 (load balancer), 8443 optional, and 5432 (PostgreSQL, optional to expose) must be free. Port 8081 is reused so every earlier lab URL keeps working, now against the cluster. | |
3.3 Required tools and versions
| Tool | Version | Purpose |
|---|---|---|
| Docker Engine and Docker Compose | Engine 24+, Compose v2 | Runs the multi container cluster; Compose is the right tool once more than two containers coordinate |
| PostgreSQL image | postgres:16 | The external, durable database that holds all realm state |
| Keycloak image | quay.io/keycloak/keycloak:26.0 or later | Same image as Lab 02, now run in production mode and clustered |
| Nginx image | nginx:1.27 | The load balancer in front of the cluster |
| curl, jq | As installed in earlier labs | Driving requests and reading health and discovery endpoints |
3.4 Verification of tools
Verify Docker Compose v2
docker compose version
# Expected: Docker Compose version v2.x.x
docker network inspect ib-lab-net >/dev/null 2>&1 && echo "ib-lab-net present" || \
echo "ib-lab-net missing (it was created in Lab 01)"
The cluster attaches to the existing ib-lab-net network so the new nodes can reach ib-openldap for federation.
ib-lab-net exists, and the single node realm answers on 8081. Keep your Keycloak admin credentials and the hr-portal client secret to hand. You are about to move the realm, so a working export is the safety net for the whole lab.Real World Problem Statement
This lab solves the problem that the previous five created by succeeding: concentration of dependency. Centralising authentication was the right architecture, but it means the identity provider is now on the critical path of every application at once. A single node IdP turns any routine event, a host reboot, a failed disk, an out of memory kill, a bad deployment, into a total authentication outage. Nobody can log in to anything. Worse, with the database embedded in the node, the availability problem and the durability problem are the same problem: lose the node's storage and you lose the realm, the clients, and every locally stored credential.
Highly available identity is not optional at enterprise scale, and it has a specific shape. State must live outside any single compute node, in a database that is itself backed up and, in production, replicated. Compute nodes must be stateless and horizontally scalable, so any node can serve any request and losing one costs capacity rather than availability. Live session state, which by nature is not in the database on every request, must be replicated across nodes so that a user whose node dies is not silently logged out. And a load balancer must present one address, spread load, and route away from unhealthy nodes, without breaking the stateful parts of the login journey. Getting this wrong produces an IdP that appears redundant but logs everyone out the moment a node restarts, which is the failure a senior architect is expected to prevent.
Why it matters, across four dimensions
Risk
Removing the single point of failure means a node loss degrades capacity instead of causing a full outage. Externalising the database separates durability from availability, so a lost node never means a lost realm.
Compliance
Availability targets and recovery objectives are auditable controls. Financial regulators and operational resilience regimes expect critical services, and the IdP is one, to survive component failure with defined RTO and RPO.
Productivity
Stateless, load balanced nodes make maintenance routine: patch or restart one node at a time with no user impact. Rolling upgrades replace the maintenance window with a non event.
Security Posture
Availability is a security property, the A in the classic triad. An IdP that fails open, fails closed, or drops sessions under load is a security incident, not merely an operational one.
Concrete scenario
Northgate Financial has a bad Tuesday. The virtual machine hosting the sole Keycloak node is rebooted during routine patching, and for fifteen minutes nobody in the company can log in to anything: the HR portal, the acquired Harborview systems, the internal tools, all delegate to an IdP that is not there. The incident review is blunt. The identity provider is classified as a critical service with no redundancy and a single point of failure that also co-locates its own database. The board sets a requirement: the IdP must survive the loss of any single node with no user visible outage and no forced re-authentication, and its data must be durable independently of any compute node. Your task in this lab is the reference build that meets that requirement: migrate the realm to an external PostgreSQL database, run a clustered pair of stateless Keycloak nodes that replicate session state, front them with a load balancer, and prove by killing a node mid session that a live user stays logged in.
Skills Mapped to Production Solutions
| Skill Learned | Real World Enterprise Application |
|---|---|
| Migrating Keycloak from an embedded database to external PostgreSQL | The first and non negotiable step of any production Keycloak deployment; the embedded database is explicitly development only |
| Running Keycloak in production mode with proxy and hostname settings | Correctly deploying behind a load balancer or ingress, the source of a large share of real Keycloak deployment tickets (redirect loops, wrong URLs, cookie problems) |
| Forming an Infinispan cluster with JGroups discovery | Designing session replication so node loss does not log users out, the core of highly available IdP and session tier design |
| Choosing and configuring a cluster discovery mechanism | Deploying across Docker, Kubernetes, or virtual machines where multicast is unavailable and database or DNS based discovery is required |
| Configuring a load balancer for a stateful authentication service | The affinity, health check, and forwarded header decisions that make or break an IdP behind a proxy |
| Using health and readiness probes | Wiring Keycloak into orchestrators and load balancers so unhealthy nodes are removed automatically, the basis of self healing infrastructure |
| Testing failover deliberately | Chaos and resilience testing: proving a design survives failure rather than assuming it, which is what distinguishes a claimed HA design from a verified one |
Architecture Overview
Component breakdown
| Component | Purpose | Technology | Container | Ports | Key configuration |
|---|---|---|---|---|---|
| PostgreSQL | The single durable home of all realm state: realms, clients, roles, groups, and locally stored credentials. Also hosts the cluster discovery table. | postgres:16 | ib-postgres | 5432 | Dedicated keycloak database and user; a named volume so data survives container recreation |
| Keycloak node 1 and node 2 | Stateless compute serving the OIDC and SAML endpoints; either node can serve any request | keycloak:26 in production mode | ib-keycloak-1, ib-keycloak-2 | 8080 internal, 9000 management | External DB env vars, proxy headers, hostname, clustering, health and metrics enabled |
| Infinispan distributed cache | Replicates live authentication and user sessions across nodes so failover does not log users out | Embedded Infinispan via JGroups | Inside each node | 7800 (JGroups) | Distributed session caches with at least one owner replica per node; discovery via JDBC_PING |
| Nginx load balancer | Presents one stable address, spreads requests across healthy nodes, forwards the headers Keycloak needs, and routes away from failed nodes | nginx:1.27 | ib-lb | 8081 (host) | Upstream of both nodes, passive health checks, X-Forwarded-For and X-Forwarded-Proto, a node identifying response header for the demo |
| OpenLDAP | The federated user directory, unchanged; both nodes bind to it for authentication | osixia/openldap (Lab 01) | ib-openldap | 389 | Unchanged; reached over the shared network |
Data flow (a login through the highly available cluster)
- The client reaches one address: the browser or application connects to the load balancer on the single published address, never to a node directly. Why: nodes can be added, removed, or restarted without any client ever changing its configuration.
- The balancer selects a healthy node: Nginx forwards the request to node 1 or node 2, skipping any node its health check has marked down, and adds the X-Forwarded headers. Why: Keycloak must know the original scheme and host to build correct redirect and token URLs, and the balancer must never send traffic to a dead node.
- The node authenticates against shared state: the chosen node reads realm and client configuration from PostgreSQL and validates the password against OpenLDAP. Why: because state is external, any node has the full picture; there is no primary node that owns the truth.
- The session is written to the replicated cache: when the login succeeds, the session is created in the Infinispan distributed cache, and a replica is placed on the other node. Why: this is the step that makes failover invisible; the session does not belong to the node that created it.
- A later request can land on either node: a subsequent request routed to the other node finds the session already present in its cache and serves it without a re-login. Why: with replicated sessions the cluster behaves as one logical IdP, so losing a node loses capacity, not sessions.
Security considerations
| Control | In this lab |
|---|---|
| Availability as a security property | The cluster survives the loss of any single node with no forced re-authentication, proven in Section 8 by killing a node mid session |
| Durability separated from availability | Realm state lives in PostgreSQL on a named volume, so a destroyed node never destroys the realm |
| Correct proxy trust | Keycloak is told to trust forwarded headers only from the balancer, and the balancer sets them explicitly, avoiding host header and redirect manipulation |
| Self healing routing | Health and readiness probes let the balancer route away from unhealthy nodes automatically rather than sending users into errors |
| Blast radius of the discovery channel | Cluster discovery and replication traffic stay on the internal network; the JGroups port is never exposed to the host |
Step by Step Implementation
Phase 1: Externalise State by Migrating the Realm to PostgreSQL
Step 1.1: Export the northgate realm from the single node
Capture the entire realm, including users and locally stored credentials, so it can be re-imported into the database backed cluster.
The embedded database used since Lab 02 cannot be pointed at from a second node and is not durable, so the realm has to move. Exporting to a portable file is the clean migration path and doubles as a backup. Exporting with users included preserves the MFA and passkey credentials you enrolled in Lab 05; the LDAP federated passwords stay in OpenLDAP and reconnect automatically.
Export the realm to a file on the host
# Export inside the running single node, writing to a directory, # then copy that file out to the host. docker exec ib-keycloak /opt/keycloak/bin/kc.sh export \ --dir /tmp/realm-export --realm northgate mkdir -p ~/ib-sia-06/import docker cp ib-keycloak:/tmp/realm-export/northgate-realm.json \ ~/ib-sia-06/import/northgate-realm.json # Confirm the export is real and names the realm. jq -r '.realm, (.users | length | tostring) + " users", (.clients | length | tostring) + " clients"' \ ~/ib-sia-06/import/northgate-realm.json
A northgate-realm.json on the host reporting the realm name, a user count, and a client count that includes hr-portal.
jq '.components["org.keycloak.storage.UserStorageProvider"][0].name' ~/ib-sia-06/import/northgate-realm.json. Expected: the LDAP provider name you set in Lab 02. If it is null, the export still holds the realm and clients; the LDAP provider can be re-added after import, but normally it travels in the file.Step 1.2: Stop the single node so port 8081 and the realm name are free
Retire the old container cleanly so the load balancer can take over its address.
Stop and rename the old node
# Keep OpenLDAP running; it is shared. Stop only the old Keycloak and its # admin helper. We rename rather than delete, so rollback stays possible. docker stop ib-keycloak ib-phpldapadmin docker rename ib-keycloak ib-keycloak-singlenode-old # Confirm nothing now holds host port 8081. (curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8081/ || echo "port free")
Phase 2: Stand Up PostgreSQL and a Two Node Cluster
Step 2.1: Write the cluster cache configuration
Configure Infinispan to discover cluster members through the shared database rather than network multicast, which does not work reliably across containers.
Keycloak clusters using JGroups. Its historic default discovery is UDP multicast, which a single Docker host and most cloud networks do not pass, so nodes never find each other and each runs as a lonely cluster of one. The robust, production standard answer where multicast is unavailable is discovery through a shared resource. Since the cluster already shares PostgreSQL, JDBC_PING is the natural choice: each node registers itself in a small table, and members read that table to find one another. Recent Keycloak releases make JDBC_PING the default when an external database is configured; you set it explicitly here so the lab behaves the same across versions and so the mechanism is visible rather than magic.
Create a minimal Infinispan config that selects JDBC_PING discovery
mkdir -p ~/ib-sia-06/conf
cat > ~/ib-sia-06/conf/cache-ispn-jdbc.xml << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<infinispan
xmlns="urn:infinispan:config:15.0">
<jgroups>
<!-- A TCP based stack that discovers members via the shared database.
extends="tcp" keeps all the default protocols and swaps only
the discovery protocol for JDBC_PING2. -->
<stack name="jdbc-ping-tcp" extends="tcp">
<JDBC_PING2
connection_driver="org.postgresql.Driver"
connection_url="jdbc:postgresql://ib-postgres:5432/keycloak"
connection_username="keycloak"
connection_password="${env.KC_DB_PASSWORD}"
initialize_sql="CREATE TABLE IF NOT EXISTS JGROUPSPING (own_addr varchar(200) NOT NULL, cluster_name varchar(200) NOT NULL, bind_addr varchar(200) NOT NULL, updated timestamp default current_timestamp, ping_data BYTEA, constraint PK_JGROUPSPING PRIMARY KEY (own_addr, cluster_name))"
stack.combine="REPLACE"
stack.position="MPING" />
</stack>
</jgroups>
<cache-container name="keycloak">
<transport stack="jdbc-ping-tcp" node-name="${env.KC_NODE_NAME:}"/>
</cache-container>
</infinispan>
EOF
echo "wrote cache-ispn-jdbc.xml"
This file changes only the discovery protocol and the transport stack. Keycloak merges it with its own shipped cache definitions, so the distributed session caches keep their production defaults while gaining database based discovery.
extends="tcp" and stack.combine="REPLACE" attributes are the mechanism that lets you change one protocol without re-declaring the whole stack. If your Keycloak version already defaults to JDBC_PING with an external database, this file is belt and braces: harmless, explicit, and portable. Should a version reject the schema namespace, align the urn:infinispan:config version to the one your image ships, which you can read with docker run --rm quay.io/keycloak/keycloak:26.0 find / -name cache-ispn.xml.Step 2.2: Write the Docker Compose file for the database and both nodes
Define PostgreSQL and two Keycloak nodes in production mode, sharing the database, clustering through the cache config, and importing the realm on first start.
Once more than two containers must start in the right order with shared configuration, individual docker run commands become error prone, so Compose becomes the right tool. The two nodes are identical except for their name; that sameness is the point of stateless design. Production mode differs from the development mode of Lab 02 in three ways that matter: it demands an external database, it expects to sit behind a proxy so it must be told to trust forwarded headers, and it wants to know its public hostname so redirect and token URLs are correct.
Create docker-compose.yml
cat > ~/ib-sia-06/docker-compose.yml << 'EOF'
services:
ib-postgres:
image: postgres:16
container_name: ib-postgres
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: ${KC_DB_PASSWORD}
volumes:
- ib-pgdata:/var/lib/postgresql/data
networks: [ib-lab-net]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U keycloak -d keycloak"]
interval: 5s
timeout: 5s
retries: 10
ib-keycloak-1: &kc
image: quay.io/keycloak/keycloak:26.0
container_name: ib-keycloak-1
command: start --optimized --import-realm
depends_on:
ib-postgres:
condition: service_healthy
environment: &kcenv
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD}
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://ib-postgres:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: ${KC_DB_PASSWORD}
KC_HOSTNAME: http://localhost:8081
KC_HOSTNAME_STRICT: "false"
KC_HTTP_ENABLED: "true"
KC_PROXY_HEADERS: xforwarded
KC_HEALTH_ENABLED: "true"
KC_METRICS_ENABLED: "true"
KC_CACHE: ispn
KC_CACHE_CONFIG_FILE: cache-ispn-jdbc.xml
KC_NODE_NAME: node1
volumes:
- ./conf/cache-ispn-jdbc.xml:/opt/keycloak/conf/cache-ispn-jdbc.xml:ro
- ./import:/opt/keycloak/data/import:ro
networks: [ib-lab-net]
ib-keycloak-2:
<<: *kc
container_name: ib-keycloak-2
environment:
<<: *kcenv
KC_NODE_NAME: node2
depends_on:
ib-postgres:
condition: service_healthy
ib-keycloak-1:
condition: service_started
volumes:
ib-pgdata:
networks:
ib-lab-net:
external: true
EOF
echo "wrote docker-compose.yml"
The &kc and *kc anchors define node 1 once and reuse it for node 2, so the two are provably identical bar the node name. Only node 1 runs --import-realm effectively, because the realm exists after the first import and Keycloak skips it thereafter.
.env file, never hard coded in the compose file. The start --optimized command assumes a build; if your image has not been built with these options, use start without --optimized for the lab so Keycloak builds automatically on first boot. Production pipelines run an explicit kc.sh build step and then start --optimized.Step 2.3: Provide secrets and bring up the cluster
Supply the database and admin passwords, start the stack, and confirm the two nodes form one cluster.
Create the .env file and start the stack
cd ~/ib-sia-06 # Secrets live here and this file must never be committed. cat > .env << 'EOF' KC_DB_PASSWORD=ChangeMe_DbStrong#2026 KC_ADMIN_PASSWORD=ChangeMe_AdminStrong#2026 EOF chmod 600 .env # If your image is not pre-built, swap 'start --optimized' for 'start' # in docker-compose.yml before this step. docker compose up -d # Watch node 1 come up and import the realm (Ctrl C to stop watching). docker compose logs -f ib-keycloak-1 | grep -m1 "Imported realm northgate"
PostgreSQL becomes healthy, node 1 imports the realm, and both nodes finish starting. The realm now lives in PostgreSQL, not in either node.
docker compose logs ib-keycloak-1 | grep -iE "ISPN000094|view.*node1.*node2|members". Expected: a JGroups view line listing both node1 and node2. You can also confirm the discovery table exists in the database: docker exec ib-postgres psql -U keycloak -d keycloak -c '\dt' | grep -i jgroupsping. If the view shows only one member, discovery failed; see the failure table in Section 8.Phase 3: Put a Load Balancer in Front
Step 3.1: Configure Nginx as the single entry point
Give clients one stable address, spread traffic across both nodes, forward the headers Keycloak needs, and route away from a failed node.
A load balancer in front of an authentication server has to do more than round robin. Keycloak builds absolute URLs for redirects and tokens, so it must be told the original scheme and host through X-Forwarded headers, which is why production mode was set to trust them. The balancer must also detect and avoid a dead node, or it will keep sending some users into connection errors. For the demonstration you add one non standard touch: a response header naming which node served the request, so the failover in Section 8 is visible rather than inferred.
Write the Nginx configuration
cat > ~/ib-sia-06/conf/nginx.conf << 'EOF'
events {}
http {
upstream keycloak_cluster {
# Round robin across both nodes for this lab so distribution is visible.
# max_fails + fail_timeout give passive health checking: after 2 failed
# attempts a node is treated as down for 10s and traffic routes elsewhere.
server ib-keycloak-1:8080 max_fails=2 fail_timeout=10s;
server ib-keycloak-2:8080 max_fails=2 fail_timeout=10s;
}
server {
listen 8081;
location / {
proxy_pass http://keycloak_cluster;
# Tell Keycloak the original request details so it builds correct URLs.
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 8081;
# Retry the other node if one refuses or errors mid request.
proxy_next_upstream error timeout http_502 http_503 http_504;
# DEMO ONLY: expose which backend served this response.
add_header X-Upstream-Node $upstream_addr always;
}
}
}
EOF
echo "wrote nginx.conf"
Add the load balancer to the stack and reload
# Append the nginx service to docker-compose.yml.
cat >> ~/ib-sia-06/docker-compose.yml << 'EOF'
ib-lb:
image: nginx:1.27
container_name: ib-lb
depends_on: [ib-keycloak-1, ib-keycloak-2]
ports:
- "8081:8081"
volumes:
- ./conf/nginx.conf:/etc/nginx/nginx.conf:ro
networks: [ib-lab-net]
EOF
cd ~/ib-sia-06 && docker compose up -d ib-lb
Appending the service keeps the volumes and networks blocks at the end of the file valid, because Compose reads the whole document; if your editor is to hand, placing ib-lb among the other services reads more naturally.
The realm answers on the original address, http://localhost:8081, now served by the cluster through Nginx.
for i in 1 2 3 4; do curl -s -o /dev/null -D - http://localhost:8081/realms/northgate/ | grep -i x-upstream-node; done. Expected: the header appears, and across the requests you see both node addresses. Every earlier lab URL now works unchanged against the cluster, which is the whole promise of a stable front address.AUTH_SESSION_ID cookie, and the distributed cache remains the safety net for when the sticky node dies. Affinity for speed, replication for survival: the two work together, they are not alternatives.Phase 4: Prove Failover by Killing a Node Mid Session
Step 4.1: Establish a live session, then kill its node
Demonstrate the core promise: a user with a live session stays logged in when the node that served them dies.
This is the test that separates a real HA design from a hopeful one. Many clusters that look redundant still pin a session to the node that created it, so killing that node logs the user out. Because you configured a distributed cache, the session was replicated to the other node at creation, so the survivor already holds it. You will see this end to end in a browser, then confirm it at the protocol level with a refresh token that keeps working across the failure.
- In a browser, open http://localhost:8081/realms/northgate/account and sign in as asmith (completing MFA if Lab 05 is in force). You now hold a live session.
- Find which node served you:
curl -s -D - http://localhost:8081/realms/northgate/ -o /dev/null | grep -i x-upstream-node. Note the node address shown. - Kill that node. If it was node 1:
docker stop ib-keycloak-1. - Back in the browser, reload the account console. You remain logged in, with no new password or MFA prompt.
Confirm the same at the protocol level with a refresh token
cd ~/ib-sia-06 # Get tokens through the load balancer (direct grant is fine for this test; # if you disabled it in Lab 04, use the account console session instead). # Here we read a refresh token from a code flow you run, or reuse tokens.json. # Suppose $RT holds a valid refresh token obtained via the cluster. # Kill one node (done above), then refresh through the balancer: curl -s -X POST http://localhost:8081/realms/northgate/protocol/openid-connect/token \ -d grant_type=refresh_token -d client_id=hr-portal \ -d client_secret="<CLIENT_SECRET>" -d refresh_token="$RT" \ | jq -r '.access_token[0:16] + "... (refresh succeeded on surviving node)"' # Expected: a new access token, served by the node that is still up.
The browser session survives the node kill with no re-login, and a token refresh routed to the surviving node succeeds.
curl -s -D - http://localhost:8081/realms/northgate/ -o /dev/null | grep -i x-upstream-node. The session persists, and all traffic flows to the node that is up. That is failover with session continuity, the exact requirement the board set.Step 4.2: Bring the node back and watch it rejoin
Show that recovery is automatic: a returning node rejoins the cluster and the balancer resumes using it.
Restart the node and confirm it rejoins
docker start ib-keycloak-1 # Watch it rediscover the cluster through the database and rejoin. docker compose logs -f ib-keycloak-1 | grep -m1 -iE "ISPN000094|view" # After a few seconds the balancer's fail_timeout lapses and traffic # spreads across both nodes again. for i in 1 2 3 4; do curl -s -D - http://localhost:8081/realms/northgate/ -o /dev/null | grep -i x-upstream-node done
Testing and Validation
End to end scenario: the board requirement, verified
This confirms the three properties the incident review demanded: durable state, load spread across nodes, and session survival on node loss.
Run the end to end validation
# STAGE 1: state is durable and external. Recreate a node from scratch and # confirm the realm is still there (it lives in PostgreSQL, not the node). docker compose rm -sf ib-keycloak-2 && docker compose up -d ib-keycloak-2 curl -s http://localhost:8081/realms/northgate/.well-known/openid-configuration \ | jq -r '.issuer' # Expected: the realm answers; destroying and recreating a node lost nothing. # STAGE 2: load spreads across nodes. for i in $(seq 1 6); do curl -s -D - http://localhost:8081/realms/northgate/ -o /dev/null | grep -i x-upstream-node done | sort | uniq -c # Expected: both node addresses appear. # STAGE 3: session survives node loss (Phase 4). Log in, kill the serving # node, confirm the session persists with no re-authentication.
Negative and resilience tests
Run the tests
# TEST N1: a lonely cluster of one is the classic misconfiguration. # Confirm the cluster view is size 2, not two clusters of size 1. docker compose logs ib-keycloak-1 | grep -iE "ISPN000094" | tail -1 docker compose logs ib-keycloak-2 | grep -iE "ISPN000094" | tail -1 # Expected: BOTH logs show a view containing node1 AND node2. If each shows # only itself, discovery failed and sessions will NOT replicate. # TEST N2: readiness gates traffic correctly. A node that is up but not # ready should report so on its management port. docker exec ib-keycloak-1 curl -s http://localhost:9000/health/ready | jq -r '.status' # Expected: UP. (Health and metrics live on the 9000 management port since # Keycloak 25, not on 8080.) # TEST N3: the database is the single source of truth. Stop BOTH nodes, # keep PostgreSQL, start the nodes again. The realm returns intact. docker compose stop ib-keycloak-1 ib-keycloak-2 docker compose start ib-keycloak-1 ib-keycloak-2 sleep 15 && curl -s http://localhost:8081/realms/northgate/ -o /dev/null -w "%{http_code}\n" # Expected: 200 after the nodes finish starting. State outlived all compute. # TEST N4: killing PostgreSQL is the real single point of failure now. # Stop it and observe that the nodes cannot serve new logins. This shows # WHY production replicates the database itself (out of scope here). docker compose stop ib-postgres curl -s http://localhost:8081/realms/northgate/ -o /dev/null -w "%{http_code}\n" # Expected: an error once caches expire. Restart it: docker compose start ib-postgres
Common failure modes and solutions
| Symptom | Likely cause | Solution |
|---|---|---|
| Each node logs a cluster view of only itself | Discovery failed; nodes are not clustering (multicast blocked, cache file not loaded) | Confirm KC_CACHE_CONFIG_FILE is set and the file is mounted; check the JGROUPSPING table exists; align the Infinispan schema version to the image |
| Session is lost when a node is killed | Nodes are not truly clustered, so sessions were never replicated | Fix discovery first (N1); replication only works once the view shows both members |
| Redirect loops or wrong URLs at login | Proxy headers not trusted, or hostname misconfigured | Confirm KC_PROXY_HEADERS=xforwarded and KC_HOSTNAME, and that Nginx sets X-Forwarded-Proto and Host |
| Node exits at start complaining about the database | PostgreSQL not ready, wrong URL, or wrong credentials | Confirm the healthcheck passes before nodes start; verify KC_DB_URL host is ib-postgres and the password matches .env |
start --optimized fails at boot | Image was not pre-built with these options | Use start without --optimized for the lab, or add an explicit kc.sh build stage |
| Health endpoint returns 404 on port 8080 | Health moved to the management port | Query :9000/health/ready, not :8080; ensure KC_HEALTH_ENABLED=true |
| Load balancer keeps hitting a dead node | Passive health check thresholds too lax, or no proxy_next_upstream | Confirm max_fails, fail_timeout, and proxy_next_upstream are set as shown |
Security Analysis
What makes this implementation resilient
- State is external and durable: the realm lives in PostgreSQL on a named volume, so destroying and recreating a node loses nothing, as Stage 1 and Test N3 proved.
- Compute is stateless and horizontally scalable: the two nodes are identical, so any node serves any request and losing one costs capacity, not availability.
- Sessions are replicated: the distributed cache places a replica on another node at creation, so a node kill mid session does not log the user out, demonstrated directly in Phase 4.
- Discovery is robust: JDBC_PING over the shared database works where multicast does not, avoiding the lonely cluster of one that silently defeats replication.
- Routing self heals: passive health checks and upstream retry route traffic away from a failed node and back when it recovers, with no manual step.
- Proxy trust is explicit: Keycloak trusts forwarded headers, and only the balancer sets them, so host and redirect manipulation through spoofed headers is contained.
What is intentionally simplified for the lab
- PostgreSQL is a single instance and is now the remaining single point of failure, deliberately highlighted in Test N4; production replicates the database with streaming replication or a managed HA service.
- All traffic is HTTP on localhost; production terminates TLS at the balancer and often re-encrypts to the nodes, with the balancer holding the certificate.
- Two nodes on one host share that host's fate; real HA spreads nodes across hosts, availability zones, or regions, which is the multi site topic beyond this lab.
- The load balancer itself is a single Nginx; production runs the balancer in pairs or uses a managed load balancer that is itself redundant.
- The cache uses defaults for replica count and timeouts rather than being tuned to a session volume and recovery target.
Production hardening recommendations
| Recommendation | Why | Covered in |
|---|---|---|
| Make PostgreSQL highly available (streaming replication, Patroni, or a managed HA database) | It is the single point of failure once compute is clustered; durability and availability of state are the foundation | Beyond this track; noted here as the next dependency |
| Terminate TLS at the balancer and enforce HTTPS end to end | Sessions, tokens, and admin traffic must never traverse plaintext in production | IB-SIA-17 to 19 (PKI phase) |
| Spread nodes across hosts and availability zones; consider multi site with external Infinispan | Co-located nodes share a failure domain; true resilience needs independent domains | Multi site design, informed by IB-SIA-12 |
| Enable session affinity on the AUTH_SESSION_ID cookie | Local cache hits outperform remote replica fetches; affinity for speed, replication for survival | IB-SIA-12 (session management) |
| Wire health and readiness probes into the orchestrator, not only the balancer | Self healing at the platform layer restarts and reschedules failed nodes automatically | IB-SIA-35 (identity observability) |
| Adopt rolling upgrades and test them | Stateless nodes make zero downtime patching possible, but only if proven with a real rolling restart | This lab, extended operationally |
| Back up PostgreSQL and rehearse restore to a defined RPO and RTO | Replication is not backup; a corrupt write replicates too, so point in time recovery matters | IB-SIA-33 (identity incident response) |
Cleanup Instructions
Option A: pause the lab, keep everything (recommended)
Stop the stack without destroying data
cd ~/ib-sia-06 docker compose stop # Resume later with: docker compose start # OpenLDAP is managed separately: docker start ib-openldap
Option B: tear down the cluster, keep the database volume
Remove containers but preserve realm data
cd ~/ib-sia-06 # Removes containers and the network attachment but keeps the named # volume ib-pgdata, so the realm survives for a later rebuild. docker compose down docker volume ls | grep ib-pgdata # confirm the data volume remains
Option C: full teardown, including data
Destroy everything this lab created
cd ~/ib-sia-06 docker compose down -v # -v also deletes the ib-pgdata volume # Roll back to the single node if you want the pre-lab state: docker rename ib-keycloak-singlenode-old ib-keycloak docker start ib-keycloak ib-phpldapadmin # Remove the sensitive export and secrets. rm -f ~/ib-sia-06/import/northgate-realm.json ~/ib-sia-06/.env
.env file both hold secrets. Delete them once the lab is done, or move them somewhere access controlled. Never commit either. Rotate the hr-portal client secret after the migration as routine hygiene.http://localhost:8081/realms/northgate/ answers when the stack is started, served by the cluster. After Option C with rollback, it answers from the restored single node. Choose based on whether later labs will run against the HA cluster (recommended) or the single node.Recommended Learning Links
- Keycloak: Configuring Keycloak for production (database, hostname, proxy, TLS)
- Keycloak: Configuring distributed caches (Infinispan, cache config file)
- Keycloak: Using a reverse proxy (proxy headers and hostname behind a balancer)
- Keycloak: High availability guide (single site and multi site topologies)
- Keycloak: Health and readiness probes (the management port endpoints)
- Infinispan documentation (distributed caches and replication)
- JGroups manual: JDBC_PING2 discovery
- Nginx upstream module (health checks, retries, affinity)
Portfolio Publishing Guide: Evidence of a Verified HA Design
Lab 06 is a portfolio centrepiece because it moves you from configuring a product to architecting a service. The evidence is strong and visual: an architecture diagram, a Compose file, a cluster view log showing two members, and a failover demonstration. The most persuasive artefact is proof you tested the failure, not merely designed for it.
12.1 Collect and sanitise the artefacts
Assemble a sanitised, publishable set
cd ~/identity-bytes-architect-labs
mkdir -p lab-06-keycloak-ha/{docs,config,evidence,screenshots}
# Copy configs, replacing real secrets with placeholders.
sed 's/ChangeMe_[A-Za-z0-9#_]*/<set-in-your-env>/g' \
~/ib-sia-06/docker-compose.yml > lab-06-keycloak-ha/config/docker-compose.yml
cp ~/ib-sia-06/conf/nginx.conf lab-06-keycloak-ha/config/nginx.conf
cp ~/ib-sia-06/conf/cache-ispn-jdbc.xml lab-06-keycloak-ha/config/cache-ispn-jdbc.xml
# Provide a .env.example, never the real .env.
cat > lab-06-keycloak-ha/config/.env.example << 'EOF'
KC_DB_PASSWORD=replace-with-a-strong-secret
KC_ADMIN_PASSWORD=replace-with-a-strong-secret
EOF
# Capture the cluster-view log line as evidence (redact host UUIDs).
docker compose -f ~/ib-sia-06/docker-compose.yml logs ib-keycloak-1 \
| grep -iE "ISPN000094|view" | tail -1 \
| sed -E 's/[0-9a-f]{8}-[0-9a-f-]{27}/<uuid>/g' \
> lab-06-keycloak-ha/evidence/cluster-view.txt
Do not publish the realm export or the real .env. The Compose file, Nginx and cache configs, the cluster view line, and screenshots of the failover are the safe, high value evidence.
12.2 Add Lab 06 to the repository and push
Create the README, commit, and push
cat > lab-06-keycloak-ha/README.md << 'EOF'
# Lab 06: Keycloak High Availability
Part of my Identity Bytes Senior IAM Architect lab series (IB-SIA-06).
Re-architects the single node IdP from earlier labs into a highly
available cluster and proves it survives node loss.
## Problem this solves
Once every application delegates login to one IdP, that IdP is the most
critical service in the estate. A single node with an embedded database
is both a single point of failure and a single point of data loss.
## What I built
- Migrated the realm from the embedded database to external PostgreSQL,
making nodes stateless and disposable
- Ran a two node Keycloak cluster in production mode
- Configured Infinispan session replication with JGroups JDBC_PING
discovery over the shared database (works where multicast does not)
- Fronted the cluster with an Nginx load balancer: forwarded headers,
passive health checks, and upstream retry
## What I proved
- Destroyed and recreated a node: realm intact (state is external)
- Killed the node serving a live session: user stayed logged in, no
re-authentication (sessions were replicated at creation)
- Restarted the node: it rejoined the cluster and traffic re-balanced
- Stopped both nodes, kept the database, restarted: realm returned intact
## Design lessons documented
Durability separated from availability; the lonely cluster of one as the
classic replication failure; affinity for speed vs replication for
survival; PostgreSQL as the remaining single point of failure to address
next.
## Skills demonstrated
Production Keycloak, external database migration, Infinispan/JGroups
clustering, cluster discovery selection, load balancing a stateful auth
service, health probes, and deliberate failover testing.
Full guide: docs/IB-SIA-06-keycloak-high-availability.html
Config: config/ Evidence: evidence/cluster-view.txt
EOF
cp <PATH_TO>/IB-SIA-06-keycloak-high-availability.html lab-06-keycloak-ha/docs/
git add .
git commit -m "Lab 06: HA Keycloak: PostgreSQL, Infinispan clustering, Nginx LB, verified failover"
git push
# Track index: | 06 | Keycloak HA: PostgreSQL, clustering, LB, failover | Complete |
12.3 Share it on LinkedIn
Attach the architecture diagram, or a two frame screenshot: the account console before the node kill and after it, still logged in. GitHub link in the first comment. A draft in the Identity Bytes style:
Draft post:
I killed my identity provider this weekend while a user was logged in. She never noticed, and that was the entire point.
Here is the problem I set out to fix. Over the last few labs I had centralised every application's login onto one Keycloak server, which is the correct architecture and also a quiet trap. One server means that a routine reboot, a failed disk, or a bad patch takes down authentication for everything at once, and if the database lives inside that server, a lost disk is a lost realm. The most important service in the estate was resting on a single point of failure.
So I rebuilt it the way production demands. First I moved all the state, the realm, the users, the credentials, out of the throwaway embedded database into a dedicated PostgreSQL, which turns each server into a stateless, disposable shell. Then I ran two of those shells and taught them to share live session state through a replicated cache, so a session does not belong to the server that created it. Then I put a load balancer in front, so clients reach one stable address while traffic spreads across both.
The test is what I care about. I logged in, identified which node was serving me, and then stopped that node while my session was live. The load balancer noticed the node was gone and quietly sent me to the survivor, which already held my session because it had been replicated the moment it was created. No re-login, no error, no interruption. Then I restarted the dead node and watched it rejoin and take its share of traffic again.
The lesson I keep relearning: a design is not highly available because the diagram has two boxes. It is highly available when you have deliberately killed one of them and watched the service carry on. Redundancy you have not tested is a hope, not a control.
Lab six of thirty six in the senior IAM architecture series I am publishing. The full guide, the Compose and config files, and the cluster evidence are on my GitHub, link in the comments.
For those running an IdP today: have you actually killed a node in anger, or are you trusting a topology diagram you have never stress tested?
Same cadence: Tuesday to Thursday morning UK time, replies within two hours, hashtags at the end (#IAM #Keycloak #HighAvailability #DevOps #CyberSecurity), and a comment linking the earlier labs so the series compounds.
12.4 Interview leverage from this lab
This lab evidences the production readiness, resilience, and platform ownership lines of the target role, and it answers the senior level design question directly: how do you make an identity provider highly available. You can walk through externalising state, stateless horizontally scalable compute, session replication, discovery choice, and load balancer behaviour, and then say you proved it by killing a node mid session. When the interviewer probes the trade offs, affinity versus replication, the remaining database single point of failure, single site versus multi site, you are discussing a system you built and broke on purpose, which is the difference between reciting a pattern and owning one.