Skip to content

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.

  1. Read the changelog. The changelog is kept per release; every entry that needs an operator action says so. Releases are also on GitHub.

  2. Move to the new version. Change the pinned image tag, or pull the new git tag and rebuild:

    Terminal window
    git fetch --tags
    git checkout v<version>
    bun install
    bun run build

    The 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.

  3. Re-run the provisioning step against the same database:

    Terminal window
    bun run db:setup # MongoDB
    bun run db:setup:pg # PostgreSQL
  4. Apply any schema migrations the release declares:

    Terminal window
    bun run db:migrate

    One command for either datastore. It reports current and exits when there is nothing to do, which is how most upgrades go. --plan lists 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.

  5. 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.

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:setup is what applies it: a new reserved client, a new field on the administrators’ project, a team the ownership model now expects. The reserved admin-mcp agent client arrived exactly this way: an existing install has it only once db:setup has 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.

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 /jwks serves 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 into bucketKeys without 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.

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_uri at the instance’s /jwks. Now its jwks_uri is 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 /jwks by 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 --plan names it; take a backup before db: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:

  1. Take a backup. There is no way back from either migration except a restore.

  2. Run db:setup (or db:setup:pg). The release adds storage areas the migrations and the new features write into — groups and their memberships, provisioning connections, the deprovisioning tally — and db:migrate expects them to exist.

  3. Run db:migrate --plan, then db: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:

  • roles is gone from the admin API and from MCP. A script or agent that set roles on 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_grant and admin_super_withdraw, and end users are grouped with the bucket_group_* tools. Relying parties that read roles from a claim read the groups claim instead.
  • Refresh tokens are served only while offline_access is a supported scope. The token endpoint now accepts exactly the grant types discovery advertises, so with offline_access absent from the scopes setting, a refresh, including one using a token issued before the upgrade, is refused with unsupported_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 of amr as “used a second factor” now gets that wrong. Look for mfa or otp in the array instead.
  • jwks_delete is 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 the kid.

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.

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. GET and PATCH /admin/api/admins/settings and the MCP tools admin_settings_read and admin_settings_update are removed. Read and change the administrators’ project with bucket_get and bucket_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 malformed richAuthorizationRequests.types stops startup too, even while RAR is off, because the consent screen reads it.

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/users and bucket_user_list answer { 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: true and the same expect. A script that did any of these in one request must now send the second.
  • Deleting a project schedules it. DELETE /admin/api/buckets/:id switches the project off and destroys it 30 days later; POST /admin/api/buckets/:id/purge destroys a scheduled project at once, and bucket_restore brings 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_name from a user’s claims document while the profile held another now receives the profile’s.

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 with Content-Language and Vary: Accept-Language, Cookie. Protocol answers do not change. Both discovery documents publish ui_locales_supported.
  • Every project has a connected-apps page at /account/apps on its own address, where people remove the access they gave. A removal is audited as self-service.

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/projects routes does not work against the new ones.
  • Fields. ownerGroupId is ownerTeamId, an application’s or API’s bucketId is projectId, and groupId is teamId when creating or moving a project, switching scope (PUT /admin/api/scope) and accepting an invitation. /admin/api/me answers activeTeamId, and the audit filter bucket is project. Error messages say project, team and group.
  • MCP tools. bucket_*, bucket_user_*, bucket_group_* and bucket_key_* are project_*, project_user_*, project_group_* and project_key_*, and group_* is team_*. 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.* and group.*, 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 as retiredproject.* 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.addressForms starts at path on every instance, so teams are offered exactly what they had. Set up the wildcard DNS record and certificate first, then switch it to subdomain or both.
  • db:setup adds 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/me answers can.addressForms instead of can.createHost. It lists the forms the caller may give a project; a super administrator gets path, subdomain and host. No alias is kept, so a script that read can.createHost must read can.addressForms and look for host.
  • New addresses are audited in full. A new project.address.change entry 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.enabled starts 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-plan records 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 --plan names it, and db:migrate runs it.
  • New answers, no removed ones. A project’s summary gains a plan block, GET /admin/api/me gains can.nameSlug, can.projects and can.teams, and the MCP surface gains four plan_* tools and project_plan_set. While plans are on, a creation over a limit is refused with 409 and code: '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/members or team_member_add gets 403, 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 retryAfter in 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 409 with reason: "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_uri and backchannel_logout_uri must use https when they are set or changed. An application that already stores an http value 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, health and ready. 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_verified is true only 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 told email_verified: true. Sign-in does not change. Applications may notice new people arriving with email_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.

1.0.1 carries no schema migration and no new storage area. It fixes two things an operator sees:

  • A new instance releases email and email_verified under the email scope. Before, the shipped claims map had no email scope, 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:setup no 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.

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.enabled is 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.
Terminal window
curl -sf https://auth.example.com/ready
curl -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.