The Keycloak Upgrade Ledger

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

2026-08-25 · run 2026-08-25-s4-dml-only-db-user

S4: it is not DDL *rights* that the migration needs, it is *ownership*

Upgrade
26.0.0 → 26.7.1
Scale
1,002 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
DB user without DDL rights

Summary

S4 asked whether a DML-only database account fails cleanly. It does. But the run answered a more useful question than the one asked: granting that account full DDL rights does not fix it. The first changeset in this upgrade issues DROP INDEX, and in Postgres DROP INDEX requires ownership of the index — a thing no GRANT confers.

The correct pre-flight check is therefore not "does the Keycloak database user have DDL permissions", which is what everyone asks. It is "does the Keycloak database user own the Keycloak objects".

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 tuningshared_buffers 128MB, maintenance_work_mem 64MB, statement_timeout 0 — stock
Adverse scenarioS4 — the database role Keycloak connects as is the variable
Dataset scale1,002 users, 2 realms
Seeding methodpartialImport (bin/seed-realm.sh --profile typical)
Topologysingle container
HostHetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe
JVM heap-Xms1g -Xmx4g
Lab changedocker-compose.dbidentity.yml added so KC_DB_USERNAME / KC_DB_PASSWORD / KC_DB_SCHEMA are env-driven without editing the base compose file

Procedure

./bin/restore.sh baseline-26.0.0-typical-1k

# a locked-down enterprise account: DML on everything, DDL on nothing
create role kc_dml login password 'kc_dml';
grant connect on database keycloak to kc_dml;
grant usage on schema public to kc_dml;
grant select,insert,update,delete on all tables in schema public to kc_dml;
grant usage,select on all sequences in schema public to kc_dml;
revoke create on schema public from kc_dml, public;

# .env
COMPOSE_FILE=docker-compose.yml:docker-compose.dbidentity.yml
KC_DB_USERNAME=kc_dml
KC_DB_PASSWORD=kc_dml

./bin/upgrade.sh 26.7.1

Then two further runs, each changing exactly one thing: grant create on schema public to kc_dml (run B), and alter table … owner to kc_dml for every table and sequence (run C).

Results

RunRole's rightsOutcomeElapsedSchema afterUsers
ADML only, no CREATEexit 1, refused to startdied at 182smodel 26.0.0, changelog 144 (untouched)1,002
BDML + CREATE on schema (verified: can CREATE TABLE)exit 1, identical failuredied at 151smodel 26.0.0, changelog 144 (untouched)1,002
Cowner of every table and sequenceREADY, clean17smodel 26.7.1, changelog 2111,002

The failure, identical in runs A and B

ERROR [liquibase.changelog.ChangeSet] ChangeSet
  META-INF/jpa-changelog-20.0.0.xml::20.0.0-12964-supported-dbs-edb-migration::keycloak
  encountered an exception.: liquibase.exception.DatabaseException:
  ERROR: must be owner of index idx_group_att_by_name_value
  [Failed SQL: (0) DROP INDEX public.IDX_GROUP_ATT_BY_NAME_VALUE]
ERROR: Failed to update database
ERROR: Failed to start server in (production) mode

Run B is the load-bearing one. kc_dml demonstrably had DDL rights — the probe create table ddl_probe(x int); drop table ddl_probe; succeeded as that role immediately before the run — and the migration failed at exactly the same changeset with exactly the same message. must be owner of is not a privilege error and cannot be granted away. Postgres reserves DROP INDEX, and ALTER TABLE, to the object's owner (or a superuser, or a member of the owning role).

Why it fails on the first changeset

The 26.0.0 baseline carries 144 changelog rows. Reading them back after the successful run C:

idexectypeorderexecuted
20.0.0-12964-supported-dbsEXECUTED109
20.0.0-12964-unsupported-dbsMARK_RAN110
20.0.0-12964-supported-dbs-edb-migrationEXECUTED145

Order 145 is the first row past the 144-row baseline. The very first thing a 26.0.0 → 26.7.1 migration does is drop and rebuild IDX_GROUP_ATT_BY_NAME_VALUE, via a changeset that lives in the 20.0.0 changelog file and exists to fix up EDB deployments. So the ownership requirement bites immediately — which is the good news, because nothing else has been applied when it does.

Outcome

Verification

ClaimPrimary sourceChecked
DML-only account fails the migrationrun A, exit 1, changelog 144
Granting CREATE does not fix itrun B, same changeset, same message, after a successful CREATE TABLE probe as that role
Ownership does fix itrun C, ready in 17s, changelog 211
Failure applies nothingchangelog 144 and 1,002 users after both failed runs
The blocking changeset is the first pending oneorderexecuted = 145 against a 144-row baseline
Holds on other version pairsnot tested — a pair with no DROP INDEX/ALTER TABLE in its delta may not hit this
Holds on MySQL / MariaDB / Oraclenot tested — Postgres only; ownership semantics differ per engine

Publishable extract

Everyone asks whether the Keycloak database user has DDL permissions. That is the wrong question.

We gave Keycloak a locked-down Postgres account — select, insert, update, delete on every table, no create — and upgraded a 1,002-user realm from 26.0.0 to 26.7.1 on Postgres 16.15. It failed, cleanly, having applied nothing:

ERROR: must be owner of index idx_group_att_by_name_value
[Failed SQL: (0) DROP INDEX public.IDX_GROUP_ATT_BY_NAME_VALUE]

Then we granted that same account CREATE on the schema and proved it could create and drop a table. The upgrade failed again, at the same changeset, with the same message. Only making the account the owner of the tables let the migration run — after which it completed in 17 seconds.

The reason is plain Postgres, not Keycloak: DROP INDEX and ALTER TABLE require ownership, and ownership is not a privilege you can GRANT. Any shop where a DBA creates the schema and the application connects as a different role — which is most shops with a change-control process — has a Keycloak upgrade that will stop dead on its first changeset.

The check to run before your maintenance window is one query:

select tablename, tableowner from pg_tables
where schemaname = 'public' and tableowner <> '<your keycloak role>';

If that returns rows, your upgrade has not been tested. Ours hadn't either.

← All runs