Before you start
- Open Settings > Updates, or run
levelrail-cli upgrade --plan. Read the breaking changes between your version and the target. Ones that need acknowledgement block the upgrade until you tick them (dashboard) or pass their ids with--ack-breaking. - Check the preflight card or
levelrail-cli upgrade: release signature published, Docker Engine not on the known-bad list, free disk (APP_UPGRADE_MIN_FREE_BYTES, 2 GiB default), a recent control plane backup. - Make sure the master key is backed up somewhere safe. A snapshot never contains it.
Fix a blocked check
Every check that blocks an upgrade says what it protects and carries its own fix. The Version card on Settings > Updates lists only the blockers, so you can clear them without leaving it.
Database backups of your own databases are not part of this gate: the upgrade replaces the control plane program and its database, not your data volumes.
Run it
Dashboard: Settings > Updates > the Version card. When the checks pass, Upgrade to vX.Y.Z now opens a summary of the breaking changes to acknowledge, the backup that will be taken and the rollback behaviour. Confirming starts the upgrade, shows its steps live, and reloads the page once the new release answers. CLI:Schedule an upgrade
Pick a time when nobody is deploying. Settings > Updates > Schedule upgrade, or:--replace (the dashboard’s Reschedule) swaps a pending one. --window uses the automatic upgrade window if you set one, otherwise the next enabled alert maintenance window that covers everything.
What the scheduler does at the scheduled time, from persisted state, so a control plane restart before then loses nothing:
- Checks again that the target is still a release on the configured channel and that no new breaking change appeared since you scheduled it.
- Re-runs the preflight. If only the backup blocks, it takes one and re-checks.
- If any check still fails it does not apply. It records why, raises an attention item and an alert on your notification channels, and retries every
APP_UPGRADE_RETRY_INTERVAL(2m) until the grace period (APP_UPGRADE_SCHEDULE_GRACE, 2h; for a window, the window’s end). After that the schedule is marked missed and you are alerted once. - If they pass it takes a fresh backup, marks the schedule running, and starts the same engine as Upgrade now. The attempt is recorded with initiator
schedule:<id>; the schedule becomes applied, or failed when the engine rolled back.
APP_UPGRADE_SCHEDULE_MAX_AHEAD (90d) away, a target that is not newer or not on the channel, unreadable release notes, unacknowledged breaking changes, and a major version jump without --ack-major.
Automatic upgrades
Off by default. Settings > Updates > Automatic upgrades, or:
Automatic upgrades apply only releases that need no human to read the notes first. A release is held, and the reason is shown on the page and sent to your channels, when it has a breaking change that needs acknowledgement, when its notes cannot be fetched, when it is a new major version, or when it is younger than the minimum release age. A release whose automatic attempt failed is not retried automatically. Each qualifying release becomes a normal schedule in the next window, so it goes through the same fire-time checks, backup and rollback as everything above, and pausing or switching off cancels a pending automatic schedule.
Threat model
Scheduling and the policy are root-only (a root session or a root-ability token), and every change is written to the audit log, as are the scheduler’s own actions. A leaked root token could already start an upgrade immediately withPOST /api/v1/updates/self-upgrade, so scheduling adds no new capability: it only delays when that action happens, and it can be cancelled until it starts. What the policy does add is that a root token can opt a server into applying future releases on its own. The targets are constrained to releases published by the configured repository and verified by the engine (checksum, and signature when cosign is installed), breaking changes and major jumps are never applied unattended, and APP_UPGRADE_* environment variables, which only the host operator controls, set the bounds. Keep the policy off, or paused, on a server where you want every upgrade to be a decision.
What happens
Nothing on the host changes before the stop step. Everything before it is safe to interrupt.
Automatic rollback
If a step after the stop fails, or the new release is not healthy within--timeout (90 seconds by default):
- the previous binary is restored byte for byte and started;
- if the new release already migrated the database, the pre-upgrade snapshot is put back and the database written by the failed run is kept beside it (
levelrail.db.before-rollback-<timestamp>); - if the schema did not change, the database is left alone, so nothing written meanwhile is lost.
rolled_back with its step timeline, visible at Settings > Updates and with levelrail-cli upgrade --attempts.
What is not kept: data the control plane wrote between the swap and the rollback, only when the schema changed. That window is the health timeout at most. App containers keep running throughout.
If the rollback itself fails, the attempt is failed and the message names the previous binary (<binary>.prev) and the snapshot, so a person can finish the job by hand with restore-snapshot.
Breaking defaults
Three defaults became stricter. Each has an override, so a deployment that relied on the old behavior can keep it until it is ready.install.sh and the container image set APP_HTTP_ADDR themselves, so neither changes. After upgrading, levelrail-cli doctor shows the guard mode and any request it denied.
What survives an upgrade
API tokens from before the built-in auth library (releases up to 0.2.0-beta.18) are imported on the first start of the new release, keeping their stored hash, so nothing has to be re-issued. Any token the start-up import missed is imported the first time it is used. Abilities are copied one to one and never widened; a token whose owner no longer holds an ability loses it, as for any token. Tokens that are revoked, expired, belong to a deleted user or carry an unknown ability are skipped. Each import writes an audit log entry (
api_token.legacy_migrated) that names the token id, never the secret.
The journal shows legacy tokens migrated migrated=N skipped=M after the first start, and levelrail-cli doctor (or Settings > System status) has a Pre-upgrade API tokens check with the same counts. It warns if any owned token is still waiting to be imported.
Browser sessions before 0.2.0-beta.19 lived only in the control plane’s memory, so there is nothing to carry over (a restart of those releases signed everyone out too). After the upgrade, an open dashboard goes to the sign-in page with a note that the session ended. Sign in again; your password, passkeys and second factors still work.
The upgrade was interrupted
An SSH drop or a power cut can end the host command mid-way. The journal in<data dir>/self-upgrade/ records every step. The next upgrade refuses to start while an attempt is unfinished and tells you to run:
Signature pin
The first verified release signature writes<data dir>/release-signature.pin. After that every upgrade, from the dashboard, levelrail-cli upgrade --apply, sudo levelrail self-upgrade or install.sh upgrade, requires a valid signature: a missing bundle (an unsigned or pre-signing release, including a downgrade to one) or a missing cosign is refused with the reason and the way out. A bad signature is always refused, pinned or not.
levelrail-cli doctor (check signature_pin) and Settings > Updates warn while the host is not pinned. Fix it by installing cosign and upgrading once.
To get past a pinned refusal on purpose, both of these are logged:
A binary older than the database
Migrations only go forward. A binary whose newest migration is older than the database refuses to start, exits with status 78 and prints:- its own version and schema, and the database schema and the release that last ran on it;
- the exact command to install that release or newer;
- how to list restore points if you really must run the older binary.
RestartPreventExitStatus=78 so systemd does not restart-loop it.
Release notes metadata
Breaking changes are read from each release’s notes. A release author can declare them explicitly with a comment anywhere in the body:ack defaults to true. Without the comment, every bullet under a heading named “Breaking changes” (including release-please’s “BREAKING CHANGES” section) counts and requires acknowledgement. When the notes cannot be fetched the dashboard refuses to start the upgrade and the host command asks for --skip-notes-check.
Channels and the edge channel
Settings > Updates shows the active channel on the Version card with a one-line description, and the same descriptions sit under the channel picker.
Edge is not a GitHub release. It has no binaries, no
checksums.txt and no signature, so the “Release checksums and signature” preflight check is shown as not applicable instead of blocking, and the card says so. The version shows as main-<short sha> and links to the commit.
What edge means for your install:
- If the control plane runs as a container, the preflight card shows the exact image (
ghcr.io/<owner>/<name>:edge) with thedocker pullcommand and the compose pull and recreate command.levelrail-cli upgradeprints the same. - If it runs as a host binary under systemd, edge is not installable that way. The card offers one-click buttons to move the channel to Beta or Stable (
levelrail-cli settings updates set --channel betadoes the same).
--apply, and the dashboard Upgrade button) only ever applies a release tag, so it refuses a main-<sha> target. The same holds for the rest of this page on the edge channel:
- Upgrade now and Schedule upgrade are hidden. The Version card shows the pull guidance instead. A schedule request for an edge target is refused with a 409 that says edge builds are container images.
- Automatic upgrades cannot be turned on. The policy is refused while the channel is edge, and a policy that was already on records “held: edge builds are container images” instead of scheduling anything.
- A schedule created earlier is re-checked at fire time. If its target turns out to be an edge image it is marked failed and nothing is applied.
- A stable or beta release that is still being published shows as Waiting with a Re-check button. Only edge reports checks as Not applicable.
Mirrors and air-gapped hosts
LEVELRAIL_RELEASE_BASE_URL points the download at an https mirror laid out like GitHub release downloads (<base>/<tag>/<asset>). LEVELRAIL_INSECURE_MIRROR=1 allows plain http for a local test mirror. Use --skip-notes-check when GitHub’s API is unreachable.
Next steps
Installing
First install and the installer’s own upgrade.
Disaster recovery
Restore from backups after a failed upgrade.