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 changelog — DATABASECHANGELOG 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.reltuplesdoes 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. See2026-08-26-s14-revisited-disk-floor-at-2m. The index that runs out of space is the same one2026-08-25-index-skip-thresholdshows 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
| 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 | S14 — free space on the Postgres data directory is the variable |
| Storage | 2 GB ext4 loopback filesystem mounted at /mnt/tinypg, bind-mounted as pgdata |
| Dataset scale | 100,002 users, 2 realms; database 223–225 MB |
| Seeding method | partialImport (restored from baseline-26.0.0-typical-100k) |
| Topology | single container |
| Host | Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe |
| JVM heap | -Xms1g -Xmx4g |
| Lab change | docker-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 space | Outcome | Elapsed | Space used | migration_model | DATABASECHANGELOG | Users |
|---|---|---|---|---|---|---|
| 25 MB | ready | 17s | ~1 MB | 26.7.1 | 211 | 100,002 |
| 8 MB | ready | 17s | 1,580 KB | 26.7.1 | 211 | 100,002 |
| 4 MB | exit 1 | 14s | 512 KB | 26.0.0 | 187 | 100,002 |
| 2 MB | exit 1 | 14s | 504 KB | 26.0.0 | 187 | 100,002 |
| 1 MB | exit 1 | 14s | 344 KB | 26.0.0 | 187 | 100,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:
| Restart | ready in 17s |
DATABASECHANGELOG | 187 → 211 |
migration_model | 26.0.0 → 26.7.1 |
idx_user_created_timestamp | present |
| Users | 100,002 |
| Admin login | 200 |
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
| Claim | Primary source | Checked |
|---|---|---|
| 8 MB free is sufficient for a 224 MB database | run: ready 17s, changelog 211, 1,580 KB consumed | ☑ |
| 4 MB free is not | run: exit 1, changelog 187 | ☑ |
| The failing statement is the index build | Failed SQL: CREATE INDEX IDX_USER_CREATED_TIMESTAMP in three runs | ☑ |
| The space that runs out is temp sort space | Postgres names base/pgsql_tmp/... | ☑ |
| The changelog is genuinely half applied | 187 of 211, read from Postgres, across three runs | ☑ |
| Recovery is a restart once space exists | changelog 187 → 211, model → 26.7.1, index present, login 200 | ☑ |
| The index is skipped above 300k rows | 2026-08-25-index-skip-threshold; skipped_idx=0 at 100k here | ☑ |
| That a 2M realm therefore avoids this | not directly tested — inferred from the two records; worth one run to close | ☐ |
Behaviour with a larger maintenance_work_mem | not tested — a bigger setting keeps the sort in RAM and may remove the failure entirely | ☐ |
| Behaviour when WAL, not the data directory, fills | not tested — separate filesystem in most real deployments | ☐ |
| Whether a full disk (0 bytes) corrupts rather than errors | not 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_tmpis 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_modelstill 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.