The Keycloak Upgrade Ledger

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

2026-08-25 · run 2026-08-25-s14-disk-exhaustion

S14: the bigger realm is the safer one, and a half-migrated schema resumes

Upgrade
26.0.0 → 26.7.1
Scale
100,002 users, 2 realms; database 223–225 MB
Database
PostgreSQL 16.15, single node, container
Topology
single container
Host
Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe
Condition
Disk exhaustion mid-migration

Summary

Running out of disk during the migration does break it, and it breaks it at one specific place: CREATE INDEX IDX_USER_CREATED_TIMESTAMP, which needs a temporary sort file that will not fit.

Three things make this the most interesting result of the set.

The amount of headroom needed is tiny. A 224 MB database needed somewhere between 4 MB and 8 MB free. At 8 MB the upgrade completed and used 1.58 MB; at 4 MB it died.

It leaves a genuinely half-applied changelogDATABASECHANGELOG stops at 187 of 211 rows, committed, with migration_model still 26.0.0. Freeing the space and restarting completed it in 17 seconds, to 211 and 26.7.1, with the index built and all 100,002 users intact.

And the failure only happens on the smaller realm.

⚠ QUALIFIED 2026-08-26. True only while Keycloak is declining the index build. Force the build at 2,000,003 users — which a stale pg_class.reltuples does on its own — and the floor is 125–150 MB, roughly 20× the figure below. Scaling up removes the trigger, not the failure mode, and multiplies the damage if anything pulls it. See 2026-08-26-s14-revisited-disk-floor-at-2m. The index that runs out of space is the same one 2026-08-25-index-skip-threshold shows Keycloak skipping above 300,000 rows. At 100k it is built, and needs temp space. At 2M it is declined, and needs none. Scaling the realm up removes this failure mode. That is the opposite of what anyone provisioning a maintenance window would assume.

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 scenarioS14 — free space on the Postgres data directory is the variable
Storage2 GB ext4 loopback filesystem mounted at /mnt/tinypg, bind-mounted as pgdata
Dataset scale100,002 users, 2 realms; database 223–225 MB
Seeding methodpartialImport (restored from baseline-26.0.0-typical-100k)
Topologysingle container
HostHetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe
JVM heap-Xms1g -Xmx4g
Lab changedocker-compose.smalldisk.yml added

The box's root filesystem is 226 GB, so exhaustion is not reachable there. Free space at the moment of migration is set exactly, with a ballast file, so each run states a real number rather than "nearly full".

Results

Free spaceOutcomeElapsedSpace usedmigration_modelDATABASECHANGELOGUsers
25 MBready17s~1 MB26.7.1211100,002
8 MBready17s1,580 KB26.7.1211100,002
4 MBexit 114s512 KB26.0.0187100,002
2 MBexit 114s504 KB26.0.0187100,002
1 MBexit 114s344 KB26.0.0187100,002

The floor is between 4 MB and 8 MB free, for a 224 MB database.

Where it dies, and why

ERROR: could not write to file "base/pgsql_tmp/pgsql_tmp705.0.fileset/0.0":
       No space left on device
[Failed SQL: (0) CREATE INDEX IDX_USER_CREATED_TIMESTAMP ON public.USER_ENTITY(REALM_ID, CREATED_TIMESTAMP)]

base/pgsql_tmp is Postgres's temporary-file area. Building an index sorts, and a sort that exceeds maintenance_work_mem (64 MB here, stock) spills to disk. The space that runs out is not the space the finished index occupies — it is the scratch space needed to build it.

That is why the margin is so small and so easy to miss: the finished index is about 2 MB, and the sort needs more room than the result.

Why a larger realm would not hit this

CustomCreateIndexChange skips index creation on tables above 300,000 rows (2026-08-25-index-skip-threshold). This realm has 100,002 users, so the index is built — the run confirms it, with skipped_idx=0 in every leg, and the index present after recovery.

At 2M users Keycloak declines to build it, logs a WARN, marks the changeset EXECUTED, and moves on. No sort, no temp file, no exhaustion. The dangerous size band for this failure is below the skip threshold, and a customer who manually created the indexes Keycloak declined has re-entered it at any size.

Recovery: the half-migrated schema resumes

From the failed state — DATABASECHANGELOG 187, migration_model 26.0.0 — removing the ballast and restarting:

Restartready in 17s
DATABASECHANGELOG187 → 211
migration_model26.0.0 → 26.7.1
idx_user_created_timestamppresent
Users100,002
Admin login200

Liquibase resumed from the last committed changeset. No manual intervention, no lock clearing, no restore.

This is the third route this lab has found to a stuck upgrade (after S8's pooler and S12's database floor) and the second to a genuinely inconsistent database — and in both cases the recovery was "fix the environmental cause and start it again". 2026-08-25-s1-s2-lock-failure-modes reached that conclusion without ever producing a half-migrated schema. It has now been produced twice, and the conclusion held both times.

Verification

ClaimPrimary sourceChecked
8 MB free is sufficient for a 224 MB databaserun: ready 17s, changelog 211, 1,580 KB consumed
4 MB free is notrun: exit 1, changelog 187
The failing statement is the index buildFailed SQL: CREATE INDEX IDX_USER_CREATED_TIMESTAMP in three runs
The space that runs out is temp sort spacePostgres names base/pgsql_tmp/...
The changelog is genuinely half applied187 of 211, read from Postgres, across three runs
Recovery is a restart once space existschangelog 187 → 211, model → 26.7.1, index present, login 200
The index is skipped above 300k rows2026-08-25-index-skip-threshold; skipped_idx=0 at 100k here
That a 2M realm therefore avoids thisnot directly tested — inferred from the two records; worth one run to close
Behaviour with a larger maintenance_work_memnot tested — a bigger setting keeps the sort in RAM and may remove the failure entirely
Behaviour when WAL, not the data directory, fillsnot tested — separate filesystem in most real deployments
Whether a full disk (0 bytes) corrupts rather than errorsnot tested — the tightest run still had 676 KB

Publishable extract

Your Keycloak upgrade can run out of disk with 4 MB free and succeed with 8. And the bigger your realm, the less likely it is to happen.

We put a 100,002-user Keycloak database — 224 MB — on a 2 GB filesystem and upgraded 26.0.0 → 26.7.1 with the free space set to an exact figure each time. With 8 MB free it completed in 17 seconds and consumed 1.58 MB. With 4 MB it died:

ERROR: could not write to file "base/pgsql_tmp/...": No space left on device
[Failed SQL: (0) CREATE INDEX IDX_USER_CREATED_TIMESTAMP ON public.USER_ENTITY(REALM_ID, CREATED_TIMESTAMP)]

base/pgsql_tmp is the giveaway. The space that ran out is not the space the index occupies — it is the temporary sort file Postgres needs to build it, which is larger than the result. Free space equal to your index size is not enough.

Now the counter-intuitive part. That index is the one Keycloak refuses to build on tables over 300,000 rows — it logs a warning, marks the changeset executed, and moves on. So a 2-million-user realm never runs that sort, never writes that temp file, and never hits this. The vulnerable realms are the small ones, and anyone who has manually created the indexes Keycloak declined has put themselves back in range at any size.

Finally, the reassuring half. The failure left a genuinely half-applied schema — 187 of 211 changesets committed, migration_model still on the old version. We freed the space and started Keycloak again. Seventeen seconds later: changelog 211, model 26.7.1, index built, all 100,002 users present, admin login returning 200. Liquibase resumed from the last committed changeset.

If you find a Keycloak stopped partway through a migration, the first thing to try is starting it again — after you have fixed whatever stopped it. That advice has now survived every way we have found to break one.

← All runs