---
title: "Upgrading | FoxAuth"
description: "Read the changelog, move the tag, re-run db:setup, apply migrations and restart. Five steps, and what each one is actually for."
canonical: "https://foxauth.dev/docs/deploy/upgrade/"
lastmod: "2026-10-12T20:43:16+03:00"
---
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 --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.

Re-run the provisioning step against the same database:

Terminal window

bun run db:setup # MongoDB

bun run db:setup:pg # PostgreSQL

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.

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.

Upgrading from before ownership groups

A deployment old enough to predate ownership groups must upgrade through an
earlier release first. The one-off conversion that introduced them has been
retired, and current releases do not attempt it.

Why step 3 is not optionalSection 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:setup is what applies it: a new
reserved client, a new field on the administrator bucket, a group the ownership model now expects.
The reserved admin-mcp agent client arrived exactly this way. The script carries an explicit
$addToSet for it, because $setOnInsert would not touch a project that already existed.

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

Caution

Re-running db:setup does not repair a changed ISSUER. The console client’s redirect URI is
written once, with $setOnInsert, when the client is first created. If the public URL of a
deployment has to change, plan for fixing that client’s redirect URI through the console (or the
admin API) as part of the change.

What a restart carries, and what it does notSection 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 buckets 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.

One upgrade signs your end users in againSection titled “One upgrade signs your end users in again”

Upgrading to the release that made user buckets 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 bucket it belongs to —
_session_default, _session_acme — so that somebody can be signed in to two buckets at once
without either sign-in disturbing the other. Cookies written before the upgrade carry the old,
bucket-less name. 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 bucket address, and that turned out not to be necessary — the
administrators bucket is served at the root and never puts its name in a URL.

One upgrade gives each bucket keys of its ownSection titled “One upgrade gives each bucket keys of its own”

Upgrading to the release that gave addressable buckets 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 buckets with a path or a hostname of their own.

A bucket signs with its own key from its first use. Before, every bucket 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 bucket’s metadata picks this up by itself; one configured
with the instance’s /jwks by hand has to be pointed at the bucket’s.

Tokens a bucket issued before the upgrade stop verifying at its resource servers. They were
signed with the instance keys, which the bucket 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 bucket 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 keysSection 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 (or db:setup:pg). The release adds storage areas the migrations and the new
features write into — bucket groups and their memberships, provisioning connections, the
deprovisioning tally — and db:migrate expects them to exist.

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 bucket’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 bucket declares, or an end user holds, becomes
a bucket 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 bucket 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 group.
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 bucket 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 bucket key, is refused unless its body names the key: { "confirm": "<kid>" }.
The console makes the operator type the kid.

Then 0.9.0Section 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.0Section titled “Then 0.10.0”

0.10.0 carries one schema migration, and it cannot be undone. A personal group 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 group and writes one
audit entry per group. Before you upgrade, list the personal groups with more than one member
(group_list with kind: personal). Move what they hold into a regular group, 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 bucket 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’ bucket with bucket_get and bucket_update, as any other bucket.

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.

Then 0.11.0Section 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 bucket 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 a project’s bucket, tightening a bucket’s
sign-in (passwords off, verification or an authenticator required, sign-up opened on a reserved bucket)
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 bucket schedules it. DELETE /admin/api/buckets/:id switches the bucket off and destroys
it 30 days later; POST /admin/api/buckets/:id/purge destroys a scheduled bucket at once, and
bucket_restore brings it back. Its address stays taken until it is destroyed.

New buckets are closed to sign-up unless the request asks for it, and requiring a verified address
is refused on every bucket 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.

After the restartSection titled “After the restart”

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
administrator bucket 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.

PreviousPostgreSQLNextConnect an upstream identity provider
