Skip to content

Updating NebulaCtrl

Your control plane checks once a day whether NebulaCtrl has a newer release. When it has, the dashboard says so, shows the release notes and lets the instance admin update with one click. The update runs the installer on the control plane's own host, backs the database up first, and puts the previous version back if the new one does not become healthy.

Updating the control plane is separate from updating the servers' agents, which upgrade themselves afterwards (agent upgrades). Your running apps are not touched by either.

The update notice#

When a newer release exists, everyone who is signed in sees a chip in the sidebar. It opens the Version card in Settings, which shows:

  • the version you run and the newest release, with its date and release notes (and a link to the release page);
  • the Update button, when you may use it, or the reason why you cannot (below);
  • the command to update by hand, always;
  • when the last check ran, and a short, soft message when it failed. A failed check never shows a warning banner: the next daily check tries again.

A development build (dev) never shows an update.

What the daily check sends#

The control plane fetches https://nebulactrl.com/releases/latest.json one minute after it starts and then once a day. It is a plain anonymous GET:

  • The User-Agent is nebulactrl/<version>, for example nebulactrl/0.1.0. That is all the request says about you.
  • No instance id, no email, no hostname, no list of servers and no cookies. There is no telemetry anywhere in NebulaCtrl, and the feed is a static file that answers every caller the same.
  • The answer is a small JSON file (the feed). Nothing is downloaded or installed because of it. An update only starts when an instance admin clicks Update.

To stop the check and the notice altogether, set NEBULACTRL_UPDATE_CHECK=false (settings).

Update from the dashboard#

Settings → Version → Update installs the release the card announces. Only the instance admin (the first account created on this control plane) can start it. It is audited as instance.update.start.

What happens#

  1. The control plane checks that the version you asked for is the announced one and newer than the one you run, that no update is already running, and that the agent on its own host is online and new enough to run the installer. If not, it says why and starts nothing.
  2. It sends one command to the agent on the control plane's own host, the machine the installer set up.
  3. The agent downloads the installer to /var/lib/nebulactrl-update/install-<timestamp>.sh (mode 0700, deleted when the unit ends), checks that it is the NebulaCtrl installer, and starts it as a transient systemd unit named nebulactrl-update-<unix timestamp> (systemd-run --collect). It refuses when another nebulactrl-update-* unit is active. The unit belongs to neither the agent nor the control plane's container, so it keeps running while both restart. Its log: journalctl -u nebulactrl-update-<timestamp>.
  4. The installer backs the control plane up first (backup before an upgrade). If the backup fails, it stops before it changes anything: the old version keeps running, and the card shows why (see the outcome).
  5. It pulls ghcr.io/nebulactrl/nebulactrl:<version>, recreates the control plane container (the database container stays) and upgrades the host's agent to the binary that ships with the new version.
  6. It waits until the container reports healthy (up to 240 seconds) and /readyz answers (about two minutes at most).

The dashboard is down for about a minute while the new container starts. Sign-in, the API and the CLI answer again once it is up. Running apps are not touched: the proxy is the agent's, not the control plane's, and other servers' agents reconnect on their own (upgrade).

The agent runs bash install.sh --update-to <version> (the option) and the options in /opt/nebulactrl/install.conf still apply.

The outcome#

The installer reports its own failure: when it stops (backup failed, pin refused, health failed and rolled back), it writes the reason, including the rollback text, to the instance, and the card shows it. A success cannot be reported by the old control plane, so the new one marks it when it starts with the version that was asked for. An update that is marked running and gets no result for 30 minutes is marked failed (look at journalctl -u 'nebulactrl-update-*' on the host).

Each outcome is audited as instance.update.succeeded or instance.update.failed. A result stays in the card for one hour.

If the new version does not start#

When the new version is not healthy, the installer puts the previous version back. Before it changed anything it kept the previous image by its ID (a moving tag such as latest is pointed back at it), and copies of the previous docker-compose.yml and .env (removed again when the installer exits, because .env holds secrets). The health wait is docker compose up --wait for up to 240 seconds, then /readyz for about two minutes. If it fails, the installer restores those files and that image, starts the previous version, prints that it rolled back and exits with status 4. You then see an update that failed with a message like The new version did not become healthy; the installer rolled back to 0.1.0., and the old version is running again.

If the rollback does not come up either, the installer exits with status 1 and says the control plane is down. Restore the pre-upgrade backup (below).

Two things to know:

  • Migrations that already ran stay applied. They only go forward. If the new version migrated the database before it failed, the previous version may refuse the newer schema and not start either. Then restore the pre-upgrade backup the installer took (Settings → Instance → History, trigger Before upgrade, or /var/backups/nebulactrl/control-plane on the host) into a clean database, following restore and roll back the control plane. Changes made after that backup are lost.
  • The release that failed is still announced. The notice stays until a newer release is out; read the release notes and the unit's log before you try again.

When one click is not available#

The card then shows the reason in plain words and keeps the manual command. The usual reasons:

Reason What to do
You are not the instance admin Ask the instance admin, or use the manual command. Everyone still sees the notice.
The control plane was installed with --no-local-server, so its host has no agent of NebulaCtrl Use the manual command on the host.
The agent on the control plane's host is offline Bring the agent back (servers), or use the manual command.
That agent is too old, or the host does not run systemd Only Linux hosts with systemd can run the update (the agent offers it as the capability controlplane.update). An old agent upgrades itself when it next connects to a current control plane; until then, and on hosts without systemd, use the manual command.
The installation lives in another directory (--dir), or curl is missing on the host The agent refuses, and says so. Use the manual command, with --dir if you set it.
An update is already running (this one, or another nebulactrl-update-* unit on the host) Wait for its outcome.
Checks are off (NEBULACTRL_UPDATE_CHECK=false) Nothing is announced; use the manual command.
The installation was pinned with --image The update stops with a message in the card: re-run the installer with --image <the new image>.

MCP clients and AI agents cannot start an update: the operation is denied over MCP, like the other operations that replace software on a host (MCP).

Update by hand#

Run this on the control plane's host. The card shows it with the version filled in (and from your own feed origin when you use a mirror):

sh
curl -fsSL https://nebulactrl.com/install.sh | sudo bash -s -- --update-to 0.1.2

It is the same installer, and the same command the dashboard's update runs: the same backup first and the same rollback to the previous image if the new version is not healthy (installer). Know how it picks the version:

  • --update-to X.Y.Z installs that release for this run only. It is not remembered, so a later plain re-run still upgrades. An installation that was pinned with --version moves its pin to the new version. Without a version, the installer installs the default of the script you ran.
  • --version X.Y.Z is for pinning on purpose. It is remembered in /opt/nebulactrl/install.conf, so a later plain re-run installs the same version again, not a newer one. --version latest drops the pin. Use it when you want to stay on a version.
  • A plain re-run installs the default of the script you ran. The script from nebulactrl.com installs the release the site was last published with. A script fetched from your own control plane (https://<your host>/install.sh) installs that control plane's own version, so using it does not upgrade anything.
  • Options such as --domain are remembered either way, so you do not repeat them.

A host with no route to ghcr.io loads the image from an archive instead: --image nebulactrl:X.Y.Z --image-archive <file> (upgrade).

Settings#

Set these in /opt/nebulactrl/.env and run cd /opt/nebulactrl && docker compose -p nebulactrl up -d (or re-run the installer, which keeps both) to apply them. A value that is not true or false, or a feed URL that is not http or https, stops the start.

Variable Default Meaning
NEBULACTRL_UPDATE_CHECK true false stops the daily check, the notice and the Update button. The control plane then makes no request to the feed at all.
NEBULACTRL_UPDATE_FEED_URL https://nebulactrl.com/releases/latest.json Where the check reads the feed, for a mirror or an air gapped network. See the feed for where the update gets the installer.

The feed#

https://nebulactrl.com/releases/latest.json is written by the release workflow, next to releases/<version>.json (the same object for that release). It is a static file:

json
{
  "version": "0.1.2",
  "publishedAt": "2026-10-29T12:34:56Z",
  "notesUrl": "",
  "notes": "## 0.1.2\n\n### Bug fixes\n\n* ..."
}
Field Meaning
version The release, without the v.
publishedAt ISO 8601, UTC.
notesUrl The release page.
notes The release notes as Markdown, trimmed. Longer than 20,000 characters they are cut and end with a link to notesUrl.
minAgent Optional. The official feed does not set it.

To mirror it (a network that cannot reach nebulactrl.com), copy the file to a host you control, serve it as application/json, and set NEBULACTRL_UPDATE_FEED_URL to its address. The one click update downloads the installer from <feed origin>/install.sh when the feed URL ends in /releases/latest.json, so serve /install.sh from the same origin. For any other feed URL it takes the installer from this control plane's own /install.sh. A mirror that cannot serve the installer means updating by hand. With the check off or no feed, you update by hand whenever you choose.

API#

Operation Endpoint Needs
getSystemUpdate GET /api/v1/system/update any signed-in user
startSystemUpdate POST /api/v1/system/update {version} instance admin, Idempotency-Key

GET returns the running version (current), the announced one (latest), whether it is newer (available), the notes (notes, notesUrl, publishedAt), when the last check ran and how it ended (checkedAt, checkError), whether checks are on (checkEnabled), whether you can start an update now (canUpdate, with cannotUpdateReason when you cannot), the manual command (manualCommand) and, while an update runs and for an hour after it, updating (state running, succeeded or failed, fromVersion, targetVersion, startedAt, finishedAt, error).

POST answers 202 with the same body. version must be the announced latest. Errors: 403 when you are not the instance admin, 409 up_to_date (nothing newer), 409 update_in_progress, 422 update_unavailable (no local server, agent offline, too old or without systemd; the message says which). The operation is not available over MCP.

Audit log: instance.update.start (the instance admin who started it, from which version to which), then instance.update.succeeded or instance.update.failed (recorded by the control plane itself, actor system, when the outcome is known).