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
| Field | Value |
|---|---|
| Keycloak from → to | 26.0.0 → 26.7.1 |
| Distribution / start mode | quay.io/keycloak/keycloak official image, start w/ external DB |
| Database | PostgreSQL 16.15, single node, container |
| Postgres tuning | stock — shared_buffers 128MB, maintenance_work_mem 64MB, statement_timeout 0 |
| Adverse scenario | S13 — KC_DB_SCHEMA=kc |
| Dataset scale | 202 users, 2 realms |
| Seeding method | partialImport (bin/seed-realm.sh --profile typical --users 200) |
| Topology | single container |
| Host | Hetzner 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
| Step | Outcome |
|---|---|
Start with KC_DB_SCHEMA=kc, schema absent | exit 1 — ERROR: schema "kc" does not exist |
create schema kc, start again | ready in 16s |
Tables in kc | 87 |
Tables in public | 0 |
| Fresh install state | model 26.0.0, changelog 144 |
| Seed 200 users | 202 users in kc.user_entity |
| Upgrade to 26.7.1 | READY in 16s — model 26.7.1, changelog 211, 202 users |
Tables leaked into public after the upgrade | 0 |
| Admin login after the upgrade | 200 |
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
- Reached ready
- Realm config intact
- Login flow works
-
No object leakage into
public
Verification
| Claim | Primary source | Checked |
|---|---|---|
| Keycloak does not create a missing schema | run with kc absent: exit 1, schema "kc" does not exist | ☑ |
| It works once the schema exists | 87 tables in kc, 0 in public, ready 16s | ☑ |
| The upgrade is clean on a non-default schema | model 26.7.1, changelog 211, 202 users, login 200 | ☑ |
Nothing leaks into public | information_schema.tables count = 0 for public, before and after | ☑ |
lockService is null also arises from a missing schema | this run's log, with schema "kc" does not exist as the Caused by | ☑ |
snapshot.sh copes | ran clean, 300K dump | ☑ |
restore.sh coped | no — it failed, fixed here, re-verified | ☑ |
Behaviour with a non-default schema and a search_path set on the role | not tested — a plausible customer configuration that could mask this | ☐ |
| Multiple Keycloak instances in separate schemas of one database | not 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=kcand the schema created in advance, Keycloak put all 87 of its tables inkc, none inpublic, 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 nullWe 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 nullis 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 aCaused 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.