Upgrading
An upgrade is five steps. Skip either of the two database steps and you get a deployment that looks fine and is subtly broken.
-
Read the changelog. The changelog is kept per release; every entry that needs an operator action says so. Releases are also on GitHub.
-
Move to the new version. Change the pinned image tag, or pull the new git tag and rebuild:
Terminal window git fetch --tagsgit checkout v<version>bun installbun run buildThe published tags are listed on the GitHub releases page and on the container package page. Images are signed and carry an SBOM, so this is the step at which to verify the version you are adopting — you are reading the changelog anyway.
-
Re-run the provisioning step against the same database:
Terminal window bun run db:setup # MongoDBbun run db:setup:pg # PostgreSQL -
Apply any schema migrations the release declares:
Terminal window bun run db:migrateOne command for either datastore. It reports
currentand exits when there is nothing to do, which is how most upgrades go.--planlists what would run without running it and exits non-zero when work is outstanding, so a pipeline can gate on it before the deploy instead of during it.A migration that cannot be undone is called out by name before it runs. When you see one, take a backup first.
-
Restart the server. It refuses to start against a database whose schema is not the one the release expects: behind, ahead, or holding a record for a migration whose declaration has since changed. A missed step 3 or 4 therefore stops the deploy instead of corrupting data quietly.
Why step 3 is not optional
Section titled “Why step 3 is not optional”db:setup is the only thing that applies a release’s structural changes. It is also idempotent: it
upserts, so running it against an already-current database changes nothing. What a release can add:
- New collections (tables, on PostgreSQL), for a capability that did not exist before.
- New indexes, TTL indexes included. A collection that MongoDB auto-creates on first write has no TTL index, so documents that were meant to expire simply do not, and nothing complains. The script also drops stale expiry indexes for a field a release has stopped writing. On PostgreSQL there are no TTL indexes to create; the running server sweeps expired rows itself.
- Seed changes. When a release changes the admin seed,
db:setupis what applies it: a new reserved client, a new field on the administrators’ project, a team the ownership model now expects. The reservedadmin-mcpagent client arrived exactly this way: an existing install has it only oncedb:setuphas run again.
db:setup exits non-zero when a constraint it declares is not in force, so make it a gate instead
of a fire-and-forget. The shipped fly.toml runs it as release_command and Fly aborts the deploy
on failure; the Compose files run it as a setup service the server depends_on with
service_completed_successfully; on Kubernetes it is a Job you wait for.
Why step 4 is a separate step
Section titled “Why step 4 is a separate step”db:setup only ever adds things: a collection, an index, a seeded document. That is the only kind
of change safe to repeat against a database in any state. Reshaping data that already exists is a
different job. It has to run once, in a declared order, and be recorded so the next deploy knows it
happened. That is what db:migrate does, on either datastore, and it is why the two are not one
command.
Three things follow from the record it keeps, and each of them stops a deploy instead of letting it proceed on a guess:
- A database behind this release has outstanding migrations. Run them.
- A database ahead of it holds a record for a migration this release does not declare, which means the binary is older than the database — a rollback past a migration, usually.
- A database is diverged when a migration it already applied no longer matches the one declared under that name. A released migration edited after the fact is the cause; the fix is a new migration, and the released one stays as it was.
Only one process migrates at a time. A run takes a short renewable lease, so two replicas rolling at once do not both apply the same step, and a crashed run leaves a lease that expires rather than a database nobody may touch.
What a restart carries, and what it does not
Section titled “What a restart carries, and what it does not”- Server settings are already in force. A change saved in the console takes effect in the instance that saved it, so an upgrade neither applies nor re-applies it: the stored settings are simply read again at startup. An upgrade is still a good moment to re-read the settings reference for anything the release added. A setting that genuinely cannot be applied to a running server says so on its card and in the console, and there are none at present.
- Signing keys persist in the database — every issuer’s, the instance’s included, as records in
bucketKeys. An upgrade neither rotates nor invalidates them: tokens issued before it still verify, and every/jwksserves the same key set it did. Keys are not environment configuration and there is nothing to carry across by hand. The one exception is the release that gave projects keys of their own, below; 0.8.0’s migration moved the instance’s keys intobucketKeyswithout changing them. - SMTP settings are read live from their own store on every send, so they are unaffected by a restart in either direction.
- Tokens, sessions and grants survive, since they live in the database. Users are not signed out by an upgrade, with one release-specific exception below.
One upgrade signs your end users in again
Section titled “One upgrade signs your end users in again”Upgrading to the release that made projects addressable, and only that one, ends every end-user sign-in once.
A sign-in is held in a browser cookie, and that cookie is now named after the project it belongs to —
_session_default, _session_acme — so that somebody can be signed in to two projects at once
without either sign-in disturbing the other. Cookies written before the upgrade carry the old name,
which names no project. They are not read, and the server expires them on the first request that presents
one, so a browser stops sending a cookie that no longer means anything.
What your end users see: the sign-in screen, once, the next time they visit. Nothing is lost — accounts, grants, consent and refresh tokens are all untouched, and a refresh token keeps working without anybody signing in at all. There is nothing to do on your side and no order to do it in; it is listed here so the support question is expected rather than alarming.
The console is unaffected in one respect worth naming: it stays at /admin. An earlier plan moved
it to free that word for a project address, and that turned out not to be necessary — the
administrators’ project is served at the root and never puts its name in a URL.
One upgrade gives each project keys of its own
Section titled “One upgrade gives each project keys of its own”Upgrading to the release that gave addressable projects their own signing keys and resource
namespaces changes three things an integration can see. The instance’s own issuer, its metadata and
its /jwks are unchanged; everything below concerns projects with a path or a hostname of their own.
- A project signs with its own key from its first use. Before, every project signed with the instance
keys and its discovery pointed
jwks_uriat the instance’s/jwks. Now itsjwks_uriis beneath its own issuer —https://auth.example.com/acme/jwks, or its hostname’s/jwks— and holds only its own keys. A resource server that re-reads the project’s metadata picks this up by itself; one configured with the instance’s/jwksby hand has to be pointed at the project’s. - Tokens a project issued before the upgrade stop verifying at its resource servers. They were signed with the instance keys, which the project no longer publishes. The switch is immediate, with no overlap, because an overlap would keep one tenant’s tokens verifiable at another’s. Access and ID tokens are short-lived and refresh tokens are unaffected, so clients recover on their next refresh; plan the upgrade for a quiet moment if long-lived access tokens are in use.
- The release carries its first schema migration, and it cannot be undone. It re-keys declared
protected resources so that an identifier is unique per issuer rather than across the instance.
db:migrate --plannames it; take a backup beforedb:migrate, which step 4 above runs. From then on, declaring a resource in a project of one’s own needs nothing from the resource, and at the instance’s root issuer only a super administrator declares.
One upgrade turns roles into groups and moves the instance’s keys
Section titled “One upgrade turns roles into groups and moves the instance’s keys”Upgrading to 0.8.0 carries two schema migrations, and neither can be undone. Do the steps in this order:
-
Take a backup. There is no way back from either migration except a restore.
-
Run
db:setup(ordb:setup:pg). The release adds storage areas the migrations and the new features write into — groups and their memberships, provisioning connections, the deprovisioning tally — anddb:migrateexpects them to exist. -
Run
db:migrate --plan, thendb:migrate. The plan names both migrations below. The server refuses to start against a database that has not run them, so a skipped step stops the deploy rather than serving from a half-converted database.
2026-09-30-root-keys-lifecycle moves the instance’s signing keys into the store a project’s keys
already use, so that the instance’s keys rotate without a restart too. For each algorithm, the key that
signs afterwards is the key the old server was signing with; on PostgreSQL, which keeps no insertion
order, that is the key with the lowest kid. The migration prints which key it chose for each algorithm,
so check that output against what your relying parties expect. Every other key becomes published only:
it is still served at /jwks and still verifies, but it does not sign. A migrated key can be promoted
once the 60-second publication window has passed. See Rotate signing keys.
2026-10-08-roles-to-groups removes roles. Every role a project declares, or an end user holds, becomes
a group with exactly the same holders as members. Role names that differ only in letter case are
merged into one group, blank names are skipped, and a name some user held but the project never declared
is kept as a group. The migration reports each of these as a count. On the administrator side, every
holder of super_admin, active or deactivated, becomes a member of the Super administrators team.
project_admin disappears without replacement, because it never granted anything.
What an integration sees after 0.8.0:
rolesis gone from the admin API and from MCP. A script or agent that setroleson an administrator, a project or an end user is now refused, because the field is unknown. Super administrator standing is granted and withdrawn by operations of its own,admin_super_grantandadmin_super_withdraw, and end users are grouped with thebucket_group_*tools. Relying parties that read roles from a claim read thegroupsclaim instead.- Refresh tokens are served only while
offline_accessis a supported scope. The token endpoint now accepts exactly the grant types discovery advertises, so withoffline_accessabsent from thescopessetting, a refresh, including one using a token issued before the upgrade, is refused withunsupported_grant_type.refreshToken.enabled, which changed nothing anyone could observe, is removed. - ID tokens from a password sign-in now carry
"amr": ["pwd"]. Every ID token from a sign-in states how the user authenticated:["pwd"], or["pwd", "otp", "mfa"]with a one-time code; a federated sign-in carries none. A test or relying party that treated the mere presence ofamras “used a second factor” now gets that wrong. Look formfaorotpin the array instead. jwks_deleteis gone, and so is deleting a key. Keys are retired instead, and a retire, of an instance key or a project key, is refused unless its body names the key:{ "confirm": "<kid>" }. The console makes the operator type thekid.
Then 0.9.0
Section titled “Then 0.9.0”0.9.0 declares no migration. It adds one storage area, UpstreamSession, which
back-channel logout from upstream providers needs, so run
db:setup (or db:setup:pg) and restart.
One fix in it concerns the upgrade you have just read about. The Compose files shipped before 0.9.0 ran
only db:setup in their setup service, so pulling 0.7.0 or 0.8.0 over an existing database left a
server that refused to start. The shipped docker-compose.yml and docker-compose.postgres.yml now run
db:migrate after it. If you keep your own copy of an older file, add the step to its setup command.
A Compose deployment that has refused to start since it moved to 0.7.0 or 0.8.0 is this: take the
backup those releases asked for, then run db:migrate once.
Then 0.10.0
Section titled “Then 0.10.0”0.10.0 carries one schema migration, and it cannot be undone. A personal team used to be able to gain
members; from this release it has exactly one, its owner. The migration
2026-10-09-personal-groups-single-member removes everyone else from each personal team and writes one
audit entry per team. Before you upgrade, list the personal teams with more than one member
(group_list with kind: personal). Move what they hold into a regular team, or note who loses access.
Then take a backup and run db:setup (or db:setup:pg). It adds the activityMarks and
activityFigures areas that count active users, and a new index on the audit trail. db:migrate --plan
names the migration, and db:migrate runs it.
What else an operator or an integration sees after 0.10.0:
- The administrators’ settings are project settings now.
GETandPATCH /admin/api/admins/settingsand the MCP toolsadmin_settings_readandadmin_settings_updateare removed. Read and change the administrators’ project withbucket_getandbucket_update, as any other project. - A setting stored with the wrong type stops startup. The settings API never writes one, so this
only concerns a value edited into the database by hand, such as a flag stored as the string
"false". The error names the key. A malformedrichAuthorizationRequests.typesstops startup too, even while RAR is off, because the consent screen reads it.
Then 0.11.0
Section titled “Then 0.11.0”0.11.0 carries one schema migration, and it cannot be undone. A SCIM static token used to live until it
was replaced or revoked; from this release it expires (RFC 7644 §7.4). The migration
2026-10-12-static-token-expiry gives every static token that has none an expiry a year after its issue,
and never sooner than 90 days after the upgrade, so no directory stops syncing that day. Note the dates
it sets and plan the replacements: a directory that still holds an expired token is refused.
Then take a backup and run db:setup (or db:setup:pg). It adds the provisioningRefusals area, which
records the removals a held connection refused, and new indexes: prefix search over users’ emails and
usernames, the audit trail’s project filters, and a connection’s previous static token. db:migrate --plan
names the migration, and db:migrate runs it.
What else an operator or an integration sees after 0.11.0:
- Administrators should enrol their authenticator again. Before this release the administrator list and the answers to creating and updating an administrator carried every enrolled administrator’s TOTP secret, readable by any super administrator or an agent acting for one.
- The users list is paged by the server.
GET /admin/api/buckets/:id/usersandbucket_user_listanswer{ users, totalResults }in email order, a page at a time, instead of an array of everyone. - More changes ask to be confirmed. Setting or clearing the bucket of one of the old projects (removed
since), tightening a project’s sign-in (passwords off, verification or an authenticator required, sign-up
opened on a reserved project) and handing a group to a directory answer 409 with what they do. They
apply only when the request is repeated with
confirm: trueand the sameexpect. A script that did any of these in one request must now send the second. - Deleting a project schedules it.
DELETE /admin/api/buckets/:idswitches the project off and destroys it 30 days later;POST /admin/api/buckets/:id/purgedestroys a scheduled project at once, andbucket_restorebrings it back. Its address stays taken until it is destroyed. - New projects are closed to sign-up unless the request asks for it, and requiring a verified address is refused on every project until mail delivery is configured.
- A user’s profile wins over a stored claim of the same name. An application that read a
given_namefrom a user’s claims document while the profile held another now receives the profile’s.
Then 0.12.0
Section titled “Then 0.12.0”0.12.0 carries no schema migration. Take a backup and run db:setup (or db:setup:pg): it adds the
ClientLogo area, which holds the logo an administrator uploads for an application.
What else an operator or an integration sees after 0.12.0:
- The consent form is bound to the account it was shown for. It carries a hidden
accountId, and a post for any other account is refused. A script that fills in the consent form itself must send it. - The consent page is reworded and rebuilt. It is headed by the project’s new
displayName(or its address), names the account by email, and groups what an application asks for into what it will see, what it will do and what nobody described. A test or a script that reads the page’s text must be updated. - A refused permission can be granted again. A permission someone left out is offered again when the
application sends
prompt=consent; before, a refusal recorded on a grant was final. - End-user pages speak three languages. They are shown in English, Russian or Simplified Chinese, from the
person’s own choice, the application’s
ui_locales, the account’s stored locale and the browser, and they answer withContent-LanguageandVary: Accept-Language, Cookie. Protocol answers do not change. Both discovery documents publishui_locales_supported. - Every project has a connected-apps page at
/account/appson its own address, where people remove the access they gave. A removal is audited asself-service.
Then 1.0.0
Section titled “Then 1.0.0”1.0.0 is the first stable release: from it on, an incompatible change to the HTTP surface, the admin API
or the MCP tools comes only in a major release. It renames what an administrator or an agent reads and types. A bucket is now a project,
an administrator group a team (a personal group a personal team), and a bucket group a group of
the project’s end users. The sections above already use the new words; the paths and tool names they quote
are those of their release. Storage, the protocol and issued tokens do not change: issuer addresses,
discovery, /jwks, tokens, SCIM and end-user pages are what they were.
It also removes the old projects, the containers that listed a bucket’s applications, before it
gives their name to buckets; the changelog describes that change and its migrations. It
carries one migration of its own for the rename, and it cannot be undone:
2026-10-17-projects-reserved-hostnames moves the stored value of the setting buckets.reservedHostnames
to its new key, projects.reservedHostnames, and changes nothing else. Take a backup, then
db:migrate --plan names the migrations and db:migrate runs them.
What an integration sees, with no aliases kept for the old names:
- Admin API paths.
/admin/api/buckets…is/admin/api/projects…, a project’s groups are at/admin/api/projects/:id/groups…, and/admin/api/groups…is/admin/api/teams…. Every sub-path keeps its suffix, and an old path answers 404. A script written for the old/admin/api/projectsroutes does not work against the new ones. - Fields.
ownerGroupIdisownerTeamId, an application’s or API’sbucketIdisprojectId, andgroupIdisteamIdwhen creating or moving a project, switching scope (PUT /admin/api/scope) and accepting an invitation./admin/api/meanswersactiveTeamId, and the audit filterbucketisproject. Error messages say project, team and group. - MCP tools.
bucket_*,bucket_user_*,bucket_group_*andbucket_key_*areproject_*,project_user_*,project_group_*andproject_key_*, andgroup_*isteam_*. The count, each tool’s consequence class and its confirmation are unchanged. An agent that kept the old list gets “unknown tool” and has to list the tools again. - Console addresses. The console lives at
/admin/projects/…and/admin/teams/…, and a project’s groups at/admin/projects/:id/groups. Old addresses redirect, so bookmarks and the links in held-deprovisioning emails already sent still open their page. - The audit trail. Entries are shown as
project.*,team.*andgroup.*, those written before the upgrade included, and a filter by a new name finds them; the stored entries are not changed. Entries about the old projects are shown asretiredproject.*and do not match a filter for today’s project actions.
The same release adds a third form of project address, a subdomain of the instance’s own host
(https://acme.auth.example.com); Project addresses describes it. What
an operator or an integration sees:
- Nothing changes until you turn it on. The new setting
projects.addressFormsstarts atpathon every instance, so teams are offered exactly what they had. Set up the wildcard DNS record and certificate first, then switch it tosubdomainorboth. db:setupadds a unique index on project slugs. It fails if two projects already share a slug. Before upgrading, find any such pair and give one of them a new address; nothing renames a slug for you, because a slug is part of an issuer.GET /admin/api/meanswerscan.addressFormsinstead ofcan.createHost. It lists the forms the caller may give a project; a super administrator getspath,subdomainandhost. No alias is kept, so a script that readcan.createHostmust readcan.addressFormsand look forhost.- New addresses are audited in full. A new
project.address.changeentry records the old and the new issuer, so a path and a subdomain with the same slug are told apart. Entries written before the upgrade are unchanged.
The same release adds plans: a named set of limits on each project, for a public instance where anyone may register as an administrator. What an operator or an integration sees:
- Nothing is limited until you turn it on. The new setting
plans.enabledstarts off on every instance, so teams can create exactly what they could before. - Every existing project is put on Free. The migration
2026-10-18-projects-record-planrecords the Free plan on every project outside the System team that records none, and reports how many. It cannot be undone, but it changes nothing else, and it can safely run twice.db:migrate --plannames it, anddb:migrateruns it. - New answers, no removed ones. A project’s summary gains a
planblock,GET /admin/api/megainscan.nameSlug,can.projectsandcan.teams, and the MCP surface gains fourplan_*tools andproject_plan_set. While plans are on, a creation over a limit is refused with409andcode: 'plan_limit'.
The same release tightens what one team, or one stranger, can do to the others on a shared instance. Run
db:setup (or db:setup:pg): it adds the TeamInvitationThrottle area, which counts team invitations. The
release carries one migration, 2026-10-19-admin-signup-requires-verification. What an operator or an
integration sees:
- Administrators’ sign-up needs email verification. The administrators’ project holds the console’s own accounts. It can no longer be saved with sign-up open and email verification off, because then anyone could register an administrator account under a colleague’s address. The migration closes administrators’ sign-up where it was open without verification, and its report says whether it did. To reopen it, turn sign-up and verification on together, in one change, on the Sign-in tab of the Administrators page. The migration can be undone, and it can safely run twice.
- An invitation can take over an unconfirmed account. While administrators must verify their address, an invitation to an address whose administrator account was never confirmed asks the invitee to choose a password. That password replaces the account’s, its second factor and linked identity providers are removed, and all its sessions end. A confirmed account joins the team as before. See Accepting an invitation.
- Only a super administrator adds an existing administrator to a team directly. A team owner who calls
POST /admin/api/teams/:id/membersorteam_member_addgets403, and invites the person instead. - An owner invitation is always sent. Under plans, whether the invitee may become an owner is checked when they accept it. A refused acceptance leaves the invitation usable.
- Team invitations are limited, for super administrators too: one per address and team every 10 minutes and at
most 5 a day, at most 100 a day per inviter across all teams, and at most 50 open invitations per team. A refusal
says how long to wait, and carries
retryAfterin seconds where waiting helps. - Signing keys are limited per key set, the instance’s and each project’s: at most 12 keys held and at most 20
made in a day, revoked keys included. A refusal is
409withreason: "key_limit". See How many keys. - Stored values have upper limits. Project, application and identity-provider names are up to 100 characters.
An application holds up to 50 redirect URIs and 50 post-logout redirect URIs, each up to 2000 characters, and up
to 50 browser origins, each up to 300 characters. An identity provider holds up to 50 scopes and up to 100
allowed email domains. Only new values are checked: a value already stored keeps working. A request body over
1 MiB is refused with
413, on every endpoint. - An application’s
jwks_uriandbackchannel_logout_urimust usehttpswhen they are set or changed. An application that already stores anhttpvalue keeps working, and its other fields can still be edited. - More words are refused as a new project path: every name on the subdomain form’s built-in list, and
account,healthandready. An existing project with such a path keeps it. See Names a path cannot take. - The console’s own sign-in applications work only at the instance’s root address. A sign-in request for one of them at a project’s address is refused.
email_verifiedistrueonly for an address that was proven. A person who signs up, or whom an administrator creates, now starts unverified in every project. Before, a project that did not require verification recorded them as verified, and applications were toldemail_verified: true. Sign-in does not change. Applications may notice new people arriving withemail_verified: false. Accounts created before the upgrade keep their recorded value, because nothing records why an account is verified. Turning verification on for a project asks everyone who is not verified, and only them.
Then 1.0.1
Section titled “Then 1.0.1”1.0.1 carries no schema migration and no new storage area. It fixes two things an operator sees:
- A new instance releases
emailandemail_verifiedunder theemailscope. Before, the shipped claims map had noemailscope, so no application learned whom it signed in. An instance that saved its claims setting keeps what it saved, because the stored map replaces the default whole: add"email": ["email", "email_verified"]under Settings → Claims there. db:setupno longer asks you to readdress the instance’s own projects. On a fresh database it reported the administrators’ and default projects as using reserved addresses. Their names never become a path, so there was nothing to do.
Then 1.1.0
Section titled “Then 1.1.0”1.1.0 carries no schema migration, no new storage area and no new setting, so there is nothing to run beyond the usual steps. What an operator sees:
- Provider buttons fit any number of providers. Past six, five stay on the sign-in page and the rest fold under More sign-in options; Google, Microsoft, Apple and GitHub are never folded. Check the Sign-in tab’s preview of a project with many providers, and act on any warning about a duplicate or confusingly named provider.
- The console says why a provider is missing from the sign-in page. While
federation.enabledis off, the providers list, the provider’s page and the sign-in preview say so. - Mail can be tested from its settings. Settings → Integrations → Email (SMTP) sends a test message to your own address and shows the mail server’s reply if it refuses. A refused message is now logged, and sends time out instead of holding a request.
After the restart
Section titled “After the restart”curl -sf https://auth.example.com/readycurl -s https://auth.example.com/.well-known/openid-configuration | jq -r .issuer/ready rather than /health on purpose: /health answers for the process alone and would pass
against a server that came up unable to reach its database. The second is the check worth
automating. The shipped deploy workflow does exactly this, because the advertised issuer is the
value a deploy can get wrong without anything else noticing.
Then open /admin and sign in. That single act exercises the seed, the console client, the
administrators’ project and the authorization code flow at once, so if it works, the upgrade landed.
Super administrators should also glance at Faults. Only genuine defects are recorded there,
never routine client rejections, so anything new after an upgrade is probably worth reading.