The Keycloak Upgrade Ledger

Every upgrade we have rehearsed, with the environment stated and the clock running.

2026-08-25 · run 2026-08-25-s13-non-default-schema

S13: a non-default schema works, and it broke our rollback tool

Upgrade
26.0.0 → 26.7.1
Scale
202 users, 2 realms
Database
PostgreSQL 16.15, single node, container
Topology
single container
Host
Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe
Condition
Non-default KC_DB_SCHEMA — KC_DB_SCHEMA=kc

Summary

Keycloak handles a non-default schema correctly: 87 tables created in kc, zero in public, migration clean in 16 seconds, nothing leaked.

Two things around it are less clean.

Keycloak will not create the schema. If KC_DB_SCHEMA names a schema that does not exist, startup fails — and the message it fails with is Cannot invoke "CustomLockService.waitForLock(...)" because "this.lockService" is null, the exact error S8 produced from an entirely different cause.

Our own restore.sh was broken by it, in the specific way that matters: it read migration_model unqualified, got relation "migration_model" does not exist, and would have reported an empty schema version rather than a failed rollback.

Environment

FieldValue
Keycloak from → to26.0.0 → 26.7.1
Distribution / start modequay.io/keycloak/keycloak official image, start w/ external DB
DatabasePostgreSQL 16.15, single node, container
Postgres tuningstock — shared_buffers 128MB, maintenance_work_mem 64MB, statement_timeout 0
Adverse scenarioS13 — KC_DB_SCHEMA=kc
Dataset scale202 users, 2 realms
Seeding methodpartialImport (bin/seed-realm.sh --profile typical --users 200)
Topologysingle container
HostHetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe
JVM heap-Xms1g -Xmx4g

Started from an empty pgdata volume so no public schema objects existed to confuse attribution.

Results

StepOutcome
Start with KC_DB_SCHEMA=kc, schema absentexit 1ERROR: schema "kc" does not exist
create schema kc, start againready in 16s
Tables in kc87
Tables in public0
Fresh install statemodel 26.0.0, changelog 144
Seed 200 users202 users in kc.user_entity
Upgrade to 26.7.1READY in 16s — model 26.7.1, changelog 211, 202 users
Tables leaked into public after the upgrade0
Admin login after the upgrade200

The misleading error, and what it means for S8

Starting against a missing schema produces this, in this order:

Caused by: org.postgresql.util.PSQLException: ERROR: schema "kc" does not exist
        at ...QuarkusJpaConnectionProviderFactory.createOrUpdateSchema(...:247)
ERROR: Failed to start server in (production) mode
ERROR: Cannot invoke "org.keycloak.connections.jpa.updater.liquibase.lock.CustomLockService
       .waitForLock(org.keycloak.models.dblock.DBLockProvider$Namespace)"
       because "this.lockService" is null

The last line is the same one 2026-08-25-s8-pgbouncer-transaction-mode recorded, where the cause was a transaction-mode connection pooler and no schema was missing at all.

That reclassifies it. lockService is null is not a diagnosis — it is what Keycloak prints whenever schema initialisation failed for any reason, because the error path dereferences a lock service that was never constructed. The real cause is always further up the log, in a Caused by: that the summary lines at the bottom do not repeat.

This matters operationally because the summary block is what an operator sees first, and it is the least informative part of the failure.

Outcome

Verification

ClaimPrimary sourceChecked
Keycloak does not create a missing schemarun with kc absent: exit 1, schema "kc" does not exist
It works once the schema exists87 tables in kc, 0 in public, ready 16s
The upgrade is clean on a non-default schemamodel 26.7.1, changelog 211, 202 users, login 200
Nothing leaks into publicinformation_schema.tables count = 0 for public, before and after
lockService is null also arises from a missing schemathis run's log, with schema "kc" does not exist as the Caused by
snapshot.sh copesran clean, 300K dump
restore.sh copedno — it failed, fixed here, re-verified
Behaviour with a non-default schema and a search_path set on the rolenot tested — a plausible customer configuration that could mask this
Multiple Keycloak instances in separate schemas of one databasenot tested

Publishable extract

Keycloak in a non-default Postgres schema upgrades cleanly. The interesting part is what it says when the schema is missing.

With KC_DB_SCHEMA=kc and the schema created in advance, Keycloak put all 87 of its tables in kc, none in public, and the 26.0.0 → 26.7.1 migration ran in sixteen seconds with 202 users intact and nothing leaking into the default schema.

Keycloak will not create the schema for you. Point it at one that does not exist and it exits — with this at the bottom of the log:

ERROR: Cannot invoke "CustomLockService.waitForLock(DBLockProvider$Namespace)"
       because "this.lockService" is null

We had seen that exact line earlier the same day, from a completely different cause: PgBouncer in transaction pooling mode, with every schema present and correct. lockService is null is not a diagnosis. It is what Keycloak prints whenever it failed to initialise its database connection, because the error path then dereferences a lock service that was never built. The actual reason — ERROR: schema "kc" does not exist — is fifteen lines further up, in a Caused by: that the summary at the bottom never repeats.

If you are staring at that null, scroll up. The summary block at the end of a Keycloak startup failure is the least informative part of it.

← All runs