Upgrading a board
Taking a board from one version to the next: what to do, in what order, and how far you can jump.
The short version
Deploy the new code, then run the upgrade:
community upgrade --dry-run # read what it will do
community upgradeOn the documented deployments the core migrations are already applied by then
— the migrate container runs to completion before anything serves — so
upgrade is what carries plugin migrations and records the version. The admin
panel shows a notice until you run it.
forum is the operator CLI, and how you invoke it depends on how the board was
deployed; Running a board § The operator CLI
has the three spellings.
Take a backup first
There is no down migration and there will not be one. A down migration that drops a column is a data-loss button on a live board, and some migrations — a destructive backfill, a column collapsed into another — cannot be reversed at all. A "roll back" that worked for some and silently did nothing for others would be worse than its absence.
Take a backup before every upgrade, and make sure it is one you have actually restored at least once. See backup and restore.
What community upgrade does
Four things, in this order:
- Core migrations. Everything else assumes the schema they create.
- Plugin migrations, per plugin, in dependency order.
- Plugin versions recorded, one per plugin.
- The core version recorded, last.
Why the version is written last. A version written before the work means a failed upgrade leaves a board claiming to be something it is not — and the next run finds nothing to do. Same reasoning as the installer's seal.
Dependency order is declared, not guessed
A plugin says what it needs:
export const badges = definePlugin({
key: "badges",
name: "Badges",
version: "1.2.0",
dependsOn: ["points"],
// …
})Declared rather than inferred, because the dependency that matters is a schema one, and nothing in an import graph reveals it.
The planner sorts topologically and breaks ties on the plugin key, so the sequence is identical on your staging board and on production. That is the only thing that makes rehearsing an upgrade worth anything.
| Problem | What happens |
|---|---|
| A dependency cycle | Refused, with the tangled keys named |
| A plugin depends on something not installed | Refused by name, rather than quietly running against a table that does not exist |
An interrupted upgrade is safe to re-run
Each plugin migration is applied and recorded in one transaction. That is the only arrangement that survives a crash between the two:
- Applied but not recorded → the next run applies it again.
- Recorded but not applied → a column that never exists, and a plugin that fails on every request.
Because the two are atomic, "try the upgrade again" is a safe instruction: an interrupted run re-applies nothing it already did.
How far you can jump
Two majors. A board at 1.x can upgrade directly to 3.x. 1.x to 4.x is refused.
The limit is honesty rather than caution. Supporting an arbitrary jump means every migration must remain correct against every schema that ever existed — a promise nobody can test, and therefore one that should not be made. Two majors is what the migration set is exercised against, so two majors is what is claimed.
A board further behind is not stuck. Upgrade in stages — check out each major in turn, deploy it, and run the upgrade before moving on:
git checkout v2 && docker compose up -d --build && community upgrade
git checkout v3 && docker compose up -d --build && community upgrade
git checkout main && docker compose up -d --build && community upgradeEach stage is an ordinary upgrade with an ordinary backup in front of it.
Downgrades
Refused.
Migrations are forward-only, so "downgrading" means running old code against a schema that has already been migrated past it — which usually appears to work and corrupts something a week later.
| Situation | Do this |
|---|---|
| You deployed a version you did not mean to | Deploy the newer one again |
| The newer one is broken | Restore the backup |
On your own server
Under Coolify, the upgrade is the Redeploy
button — or nothing at all, if you have enabled the webhook and a push to main
deploys itself.
Under Compose it is two commands:
git pull
docker compose up -d --buildEither way the ordering is handled for you:
migrate runs to completion first and web and worker wait for it, so the
new code never serves against the old schema. Take a backup before you start —
migrations are forward-only, and recovery is by restore. Coolify's scheduled
backup covers Postgres; the uploads volume is a second thing, and yours.
That applies core migrations only. Plugin migrations run through
community upgrade, which carries your board's plugin list with it — see
the operator CLI for how to run it on your
deployment.
When the deploy and the migration are separate events
Deploy some other way, and the two come apart: the board runs the new code as soon as the deployment is live, and the schema does not change until you run the command. Between them, new logic is talking to an old schema.
That window is why the admin notice exists. It names both versions and the number of migrations waiting — so the failure mode (surfacing as "column does not exist" in whichever request touches it first) becomes a sentence somebody read before it happened.
For a board with real traffic:
| Migration kind | When to run it |
|---|---|
| Adds things only | Before or after the deploy; either is safe |
| Removes or renames | Two-step: ship code that tolerates both shapes, migrate, then ship code that assumes the new one |
Releases say which kind they are.
Settings whose defaults have changed
A board setting is stored only once somebody changes it, so a default that moves applies to every board that never touched that switch. There is no migration to run and nothing to undo; the point of listing them is that behaviour changed without anybody on your board doing anything.
| Setting | Was | Is | What changes on a board that never set it |
|---|---|---|---|
reputation.comment_required |
on | off | Posts gain a one-press Thanks button. A rating no longer has to carry a reason — a click is the whole interaction, which is what makes thanking an answer worth doing. |
Set it back from Admin → Settings → Reputation if your board wants every rating to say why. That is the right choice for a board that allows negative ratings, and it is why the two switches are worth reading together: a criticism with no reason attached is the part of reputation people argue about, and a thanks is not.
Configuration that moved out of the environment
Two things that were environment variables and nothing else are board settings now. Nothing changes for a board that had them set — the environment still wins, outright, and the screen says so rather than accepting an edit it would ignore. What changes is the board that never set them, which previously could not fix either without a redeploy.
| Was | Is | |
|---|---|---|
MAIL_DRIVER and friends, read at boot |
MAIL_DRIVER=http or =smtp still wins. log, or unset, now hands the decision to /admin/settings?group=mail — which has a Send a test message button that reports the provider's own refusal verbatim. |
|
| The board's address | APP_URL, read at boot |
APP_URL still wins. Unset, it comes from Board address on /admin/settings?group=board, and the installer asks for it on a fresh board. |
The upgrade needs no action either way. Worth doing once, though: open
/admin/settings?group=mail and press the test button. Mail is the subsystem
where a misconfiguration is silent by construction — the reset form still says
"check your inbox" — so "we believe mail works" and "a message arrived" are worth
reconciling on a board you have just moved.
MAIL_DRIVER=smtp boots now
It used to refuse to start, on purpose: there was no SMTP driver, and quietly
downgrading to the log driver would have meant an operator watching password
resets vanish with no error. There is one now. MAIL_SMTP_HOST and MAIL_FROM
are required with it, and the username and password must be set together or not
at all — see Mail.
If you have been running a separate relay to bridge this gap, it can go.
TICK_DEADLINE_MS and TICK_MAX_JOBS are gone
They were read by nothing. Both were declared, documented in three places, and consulted by no code — a task's wall-clock budget comes from its own definition, and the worker bounds a tick with a constant of its own. Tuning them changed nothing, and there was no way to discover that.
Leaving them in your .env is harmless — unknown variables are ignored, not
rejected, so nothing fails on the next boot. Delete them when convenient; the
only cost of keeping them is the next person believing they do something.
Settings that gained a reader
A setting can also change behaviour by starting to be read. That is not a default moving, and there is nothing to run — but it is worth knowing which switches on your board were, until now, decorative.
registration.method now decides what a new account has to do
registration.method had been a setting with no reader: the dropdown
moved, the value was stored, and every account was created as though it said
none. It is now honoured everywhere the board creates an account.
Its default moved to none in the same release, which is what keeps this
from changing anything under you. Read the two together:
| Your board stored | Before | Now |
|---|---|---|
Nothing (never opened the screen, or chose email while it did nothing) |
Accounts active immediately | Accounts active immediately — unchanged |
none |
Accounts active immediately | Unchanged |
admin |
Accounts active immediately, contrary to the setting | Accounts wait for an administrator |
both |
Accounts active immediately, contrary to the setting | A confirmation link, then an administrator |
The first row is the one that needs explaining: a value equal to its default
is not stored, so an operator who selected email back when it did nothing
has no row, and is indistinguishable from somebody who never opened the screen.
Defaulting to email would have switched confirmation on for both of them — on
boards that very often had no mail configured at all, which would have left them
unable to register anybody. The default follows the behaviour every board
actually had.
If you did want confirmed addresses, you now have to say so — and this time
saying so works. Configure mail first at /admin/settings?group=mail, prove it
with the Send a test message button, then set the method in
Admin → Settings → Registration (Mail). The last two
rows of the table are the boards that get a real behaviour change: they asked for
vetting, and now they get it.
Accounts stuck at awaiting activation can be activated by hand from their
member screen under Admin → Members, and anybody who never received a link
can ask for another at /verify/resend.
The CLI and the installer are deliberately unaffected: community user:create and
the founding administrator are still created active, because an operator at a
terminal cannot follow a link in somebody else's mailbox, and an unactivatable
first administrator is a board with no way in.
The password and username rules now come from the settings screen
registration.min_password_length, registration.username_min and
registration.username_max were registered settings with no reader either —
every one of them served from a constant, so the fields moved and the
registration form went on enforcing 8, 3 and 30.
They are read now, by the board and by community user:create, which matters
more than it sounds: a CLI that enforced different rules is a way to create
accounts the board itself would have rejected.
The registry defaults are 10, 3 and 30. A board that never touched them gets a minimum password length of 10 rather than 8 — the one change here that can surprise somebody, and it applies to new passwords only. Existing passwords are untouched and no one is locked out; they rehash on next login regardless.
Links into a post changed shape
A post used to be anchored by its id — #post-90, under a corner that read
#6. It is anchored by that number now, #post-6, and nothing links a post by
id in a fragment any more. Everything the board writes links ?post=90
instead, and the thread page answers that by finding the post and redirecting
to the page holding it, anchored at its number.
The board rebuilds its own links, so there is nothing to run. The gain is
that they now land: a fragment never reaches the server, so the old
#post-90 could only work when that post happened to be on the page that
loaded, and a link to the four-hundredth post of a thread arrived at the top of
page one. This one arrives at the post.
What changes without asking is a link already out in the world — pasted into
a chat, another forum, or a post on your own board before the upgrade. An old
#post-90 now names the ninetieth post of that thread if it has one, and
otherwise lands at the top. Either way the reader is on the right thread.
A theme you maintain anchors posts by number. The board resolves the hrefs
and the theme owns what they land on; a PostBit that anchors by post.id
leaves every one of these links at the top of the page. Theme API § The post
anchor is the shape.
Quotes written before the upgrade are unaffected: their attribution is text in
the post, so it keeps rendering, without the member link and the link back that
new quotes carry. A quote written against the build that had two anchors carries
#pid-90 in its text — that link now lands at the top of its thread rather than
at the post, and quoting the post again writes the current form.
A category is a page now
A category — the heading a group of forums sits under — used to be a 404 if you
asked for it directly, which the breadcrumb on every thread and forum page
happily invited you to do. /{id}-{slug} on a category is a section page: its
forums, listed the way the index lists them, under the trail that got you there.
Nothing to run, and nothing to configure. A board with no categories is unaffected.
A category can be opened to threads
Allow new threads on a category now means what it says: the category takes
threads of its own, and its page lists them under its forums. It is off on every
category, and the migration that ships with this version is what makes that
true — allow_threads defaulted to on for rows that could not use it, so every
existing category is set to off as the migration runs. A board that wants
nothing to change does nothing.
Turn it on from Admin → Forums → Options on the category. Turning it off again stops new threads and returns the page to its forums; threads already posted there keep their addresses and stay in search, but the category stops listing them until it is turned back on.
An import behaves the same way: a category coming from MyBB arrives closed to threads, which is what it was there.
Two notice parameters were renamed
Deleting a post returned to /thread/12-slug?post=deleted, and restoring one
that was already visible to ?post=unchanged. ?post= now means "take me to
this post", so those two moved out of its way: they are ?removed=post and
?unchanged=post. They are notices on a redirect the board issues itself —
nothing stores them, and there is nothing to update.
What the CLI applies
community upgrade applies core migrations, then each installed plugin's, then
records the version — the three steps it prints. It reads the plugin list from
your board's community.plugins.ts, which is compiled into the command when the
image is built, so there is no separate entry point to remember and nothing to
point it at.
A plugin listed with enabled: false is skipped: creating tables for code that
will not run leaves your schema ahead of your board, which is the state the
panel's refusal to offer a migrate button exists to prevent.
This is a real limitation rather than an oversight, and it is written down here because discovering it during an upgrade is the wrong moment.