Skip to content
Meith
Documents
Running a board

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 upgrade

On 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:

  1. Core migrations. Everything else assumes the schema they create.
  2. Plugin migrations, per plugin, in dependency order.
  3. Plugin versions recorded, one per plugin.
  4. 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 upgrade

Each 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 --build

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