IDENTITY BYTES
Senior IAM Architect Track / Phase 1: Core IAM Platform

LAB 06: Keycloak
High Availability

Your identity provider is now the single most critical service in the estate, and it is running on one container. This lab removes that single point of failure: external PostgreSQL, a two node cluster with a replicated session cache, and a load balancer. Then you kill a node mid session and watch the user stay logged in.

IB-SIA-06 Advanced Est. 4 to 4.5 hours PostgreSQL / Infinispan / JGroups / Load Balancing / Failover
Section 1

Lab Metadata

AttributeValue
Lab IDIB-SIA-06
TrackIdentity Bytes Senior IAM Architect Track (36 lab curriculum)
DifficultyAdvanced (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 technologiesKeycloak 26.x in production mode, PostgreSQL 16, Infinispan embedded cache, JGroups (JDBC_PING discovery), Nginx as a load balancer, Docker Compose
Protocols and standardsJDBC based cluster discovery, distributed cache replication, HTTP reverse proxy with X-Forwarded headers, health and readiness probes, session affinity
Builds onIB-SIA-02 (the northgate realm is migrated from the embedded database into PostgreSQL and served by the cluster)
Feeds intoIB-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)
Section 2

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.

Section 3

Prerequisites

3.1 Prior labs required

LabWhy it is required
IB-SIA-01OpenLDAP remains the federated user store; the clustered nodes connect to it exactly as the single node did
IB-SIA-02The 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

ResourceMinimumRecommended
Operating systemUbuntu 22.04, macOS 13, or Windows 11 with WSL2Ubuntu 22.04 LTS
RAM8 GB (two Keycloak nodes, PostgreSQL, OpenLDAP, and Nginx run together)12 GB
CPU4 cores6 cores
Disk16 GB free25 GB free
NetworkHost 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

ToolVersionPurpose
Docker Engine and Docker ComposeEngine 24+, Compose v2Runs the multi container cluster; Compose is the right tool once more than two containers coordinate
PostgreSQL imagepostgres:16The external, durable database that holds all realm state
Keycloak imagequay.io/keycloak/keycloak:26.0 or laterSame image as Lab 02, now run in production mode and clustered
Nginx imagenginx:1.27The load balancer in front of the cluster
curl, jqAs installed in earlier labsDriving 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.

VERIFICATIONCompose v2 present, 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.
Section 4

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.

Section 5

Skills Mapped to Production Solutions

Skill LearnedReal World Enterprise Application
Migrating Keycloak from an embedded database to external PostgreSQLThe 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 settingsCorrectly 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 discoveryDesigning 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 mechanismDeploying 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 serviceThe affinity, health check, and forwarded header decisions that make or break an IdP behind a proxy
Using health and readiness probesWiring Keycloak into orchestrators and load balancers so unhealthy nodes are removed automatically, the basis of self healing infrastructure
Testing failover deliberatelyChaos and resilience testing: proving a design survives failure rather than assuming it, which is what distinguishes a claimed HA design from a verified one
Section 6

Architecture Overview

User / applications NGINX LOAD BALANCER one stable address :8081 · spreads load removes unhealthy nodes · forwards X-Forwarded-* KEYCLOAK NODE 1 stateless · production mode Infinispan cache holds live sessions (replica) KEYCLOAK NODE 2 stateless · production mode Infinispan cache holds live sessions (replica) Infinispan replicates sessions between nodes (JGroups cluster, JDBC_PING discovery) POSTGRESQL 16 durable realm state + discovery table OPENLDAP federated users (Lab 01) passwords stay here Kill any one node and the session lives on, because it was replicated the moment it was created.

Component breakdown

ComponentPurposeTechnologyContainerPortsKey configuration
PostgreSQLThe single durable home of all realm state: realms, clients, roles, groups, and locally stored credentials. Also hosts the cluster discovery table.postgres:16ib-postgres5432Dedicated keycloak database and user; a named volume so data survives container recreation
Keycloak node 1 and node 2Stateless compute serving the OIDC and SAML endpoints; either node can serve any requestkeycloak:26 in production modeib-keycloak-1, ib-keycloak-28080 internal, 9000 managementExternal DB env vars, proxy headers, hostname, clustering, health and metrics enabled
Infinispan distributed cacheReplicates live authentication and user sessions across nodes so failover does not log users outEmbedded Infinispan via JGroupsInside each node7800 (JGroups)Distributed session caches with at least one owner replica per node; discovery via JDBC_PING
Nginx load balancerPresents one stable address, spreads requests across healthy nodes, forwards the headers Keycloak needs, and routes away from failed nodesnginx:1.27ib-lb8081 (host)Upstream of both nodes, passive health checks, X-Forwarded-For and X-Forwarded-Proto, a node identifying response header for the demo
OpenLDAPThe federated user directory, unchanged; both nodes bind to it for authenticationosixia/openldap (Lab 01)ib-openldap389Unchanged; reached over the shared network

Data flow (a login through the highly available cluster)

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

ControlIn this lab
Availability as a security propertyThe 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 availabilityRealm state lives in PostgreSQL on a named volume, so a destroyed node never destroys the realm
Correct proxy trustKeycloak is told to trust forwarded headers only from the balancer, and the balancer sets them explicitly, avoiding host header and redirect manipulation
Self healing routingHealth and readiness probes let the balancer route away from unhealthy nodes automatically rather than sending users into errors
Blast radius of the discovery channelCluster discovery and replication traffic stay on the internal network; the JGroups port is never exposed to the host
Section 7

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

Purpose

Capture the entire realm, including users and locally stored credentials, so it can be re-imported into the database backed cluster.

Context

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
Expected outcome

A northgate-realm.json on the host reporting the realm name, a user count, and a client count that includes hr-portal.

VERIFICATIONOpen the file and confirm the LDAP federation provider survived the export: 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.
SECURITY WARNINGThis export contains client secrets and credential material. Treat it as sensitive, keep it out of version control, and delete it after the migration. Section 12 covers producing a separate, sanitised copy for the portfolio.

Step 1.2: Stop the single node so port 8081 and the realm name are free

Purpose

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")
VERIFICATIONThe curl returns a connection failure or "port free". The old node is preserved under a new name for rollback but is no longer serving. OpenLDAP keeps running for the new cluster to federate against.

Phase 2: Stand Up PostgreSQL and a Two Node Cluster

Step 2.1: Write the cluster cache configuration

Purpose

Configure Infinispan to discover cluster members through the shared database rather than network multicast, which does not work reliably across containers.

Context

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.

INFOThe 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

Purpose

Define PostgreSQL and two Keycloak nodes in production mode, sharing the database, clustering through the cache config, and importing the realm on first start.

Context

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.

SECURITY WARNINGSecrets come from a .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

Purpose

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"
Expected outcome

PostgreSQL becomes healthy, node 1 imports the realm, and both nodes finish starting. The realm now lives in PostgreSQL, not in either node.

VERIFICATIONConfirm the two nodes see each other as one cluster of size 2. The clearest signal is in the logs: 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.
What just happened?You separated durability from compute. The realm now lives in a database on its own volume, and the two Keycloak nodes are interchangeable shells in front of it. Losing a node no longer loses data, and the two nodes have found each other through the database and agreed they are one cluster, which is the precondition for sharing sessions in the next phase.

Phase 3: Put a Load Balancer in Front

Step 3.1: Configure Nginx as the single entry point

Purpose

Give clients one stable address, spread traffic across both nodes, forward the headers Keycloak needs, and route away from a failed node.

Context

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.

Expected outcome

The realm answers on the original address, http://localhost:8081, now served by the cluster through Nginx.

VERIFICATIONConfirm the endpoint works and that requests spread across both nodes: 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.
PRODUCTION CONSIDERATIONThis lab uses round robin so distribution is visible, but Keycloak performs best with session affinity, sending a given user back to the same node while it is healthy, because local cache hits are faster than fetching a session replica. Production balancers enable sticky sessions on the 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

Purpose

Demonstrate the core promise: a user with a live session stays logged in when the node that served them dies.

Context

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.

Actions
  1. 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.
  2. 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.
  3. Kill that node. If it was node 1: docker stop ib-keycloak-1.
  4. 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.
Expected outcome

The browser session survives the node kill with no re-login, and a token refresh routed to the surviving node succeeds.

VERIFICATIONReload the account console repeatedly after the kill; check the response header now shows only the surviving node: 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.
What just happened?You killed the node that logged the user in, and the user never noticed. The session outlived its origin node because Infinispan had already placed a replica on the other node, and the load balancer, seeing the first node refuse connections, quietly routed every following request to the survivor. Availability stopped being a slogan and became a demonstrated property.

Step 4.2: Bring the node back and watch it rejoin

Purpose

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
VERIFICATIONThe returning node logs a new cluster view listing both members, and the upstream header again shows both nodes. The cluster self healed with no manual intervention beyond starting the container, which in production is what an orchestrator does for you.
Section 8

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

SymptomLikely causeSolution
Each node logs a cluster view of only itselfDiscovery 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 killedNodes are not truly clustered, so sessions were never replicatedFix discovery first (N1); replication only works once the view shows both members
Redirect loops or wrong URLs at loginProxy headers not trusted, or hostname misconfiguredConfirm KC_PROXY_HEADERS=xforwarded and KC_HOSTNAME, and that Nginx sets X-Forwarded-Proto and Host
Node exits at start complaining about the databasePostgreSQL not ready, wrong URL, or wrong credentialsConfirm the healthcheck passes before nodes start; verify KC_DB_URL host is ib-postgres and the password matches .env
start --optimized fails at bootImage was not pre-built with these optionsUse start without --optimized for the lab, or add an explicit kc.sh build stage
Health endpoint returns 404 on port 8080Health moved to the management portQuery :9000/health/ready, not :8080; ensure KC_HEALTH_ENABLED=true
Load balancer keeps hitting a dead nodePassive health check thresholds too lax, or no proxy_next_upstreamConfirm max_fails, fail_timeout, and proxy_next_upstream are set as shown
Section 9

Security Analysis

What makes this implementation resilient

What is intentionally simplified for the lab

Production hardening recommendations

RecommendationWhyCovered 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 foundationBeyond this track; noted here as the next dependency
Terminate TLS at the balancer and enforce HTTPS end to endSessions, tokens, and admin traffic must never traverse plaintext in productionIB-SIA-17 to 19 (PKI phase)
Spread nodes across hosts and availability zones; consider multi site with external InfinispanCo-located nodes share a failure domain; true resilience needs independent domainsMulti site design, informed by IB-SIA-12
Enable session affinity on the AUTH_SESSION_ID cookieLocal cache hits outperform remote replica fetches; affinity for speed, replication for survivalIB-SIA-12 (session management)
Wire health and readiness probes into the orchestrator, not only the balancerSelf healing at the platform layer restarts and reschedules failed nodes automaticallyIB-SIA-35 (identity observability)
Adopt rolling upgrades and test themStateless nodes make zero downtime patching possible, but only if proven with a real rolling restartThis lab, extended operationally
Back up PostgreSQL and rehearse restore to a defined RPO and RTOReplication is not backup; a corrupt write replicates too, so point in time recovery mattersIB-SIA-33 (identity incident response)
Section 10

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
SECURITY WARNINGThe realm export and the .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.
VERIFICATIONAfter Option A or B, 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.
Section 11

Recommended Learning Links

Section 12 (Bonus)

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.

SECURITY WARNINGThe realm export holds client secrets and credentials; it never enters the repository. Publish configuration and evidence, not state. Rotate the hr-portal secret after migrating.

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.