Updates
How a self-hosted instance learns about a new Warmbly version, what the indicator in the admin panel means, and how Update and restart pulls, rebuilds and restarts the stack for you.
A self-hosted Warmbly checks for a newer version on its own and shows it in the admin panel. Applying it is one button when the updater runs next to the stack: the checkout is pulled, every image is rebuilt, the services that changed are recreated, and the panel reconnects once the backend answers again. Nothing in flight is lost: sending and syncing pause for the restart and resume from the database on the other side.
The indicator
The top bar of the admin panel (:5174) carries a version pill next to the environment label. It reads the running backend's build identity, so it shows exactly what is deployed, not what the checkout says.
| Pill | Means |
|---|---|
v1.4.0 (grey) | Up to date. Click it for the details and a manual check |
Update to v1.5.0 (amber) | A newer release exists on the configured channel |
Update available (amber) | The checkout is behind its branch (an install that tracks main) |
Updating (blue, spinning) | An update job is running; the pill follows it from any tab |
Restarting (blue, spinning) | The backend is coming back after an update |
The same facts sit at the top of Instance > Setup and health, and an available update is also a finding there (update_available, info severity), so a page that only lists findings still tells you.
In the dashboard
Every member of a self-hosted workspace sees the same version pill in the dashboard header, next to the plan badge: the running version in grey, or "Update to vX.Y.Z" in amber with a pulsing dot once a newer release exists. It is there so nobody has to ask which version the server runs, and so the people who cannot update still know one is waiting.
Who can act on it follows platform admin access, not workspace roles:
- Members see a badge. Its tooltip names the version and says to ask a platform admin.
- Platform admins click it and get the update dialog: the running and available versions with the release notes, the checkout and updater state, a "Check now" button, and Update and restart. That button leads to a confirmation pane that spells out what the update does (pull, rebuild and restart, sending pauses and resumes, migrations apply, the tab reconnects) before anything runs.
While the update runs the dialog shows a progress bar, the step list with the live step highlighted, and the log behind a toggle. When the backend goes away for the restart the dialog says it is reconnecting and keeps polling; the pill in the header turns into a spinner so the job stays visible with the dialog closed, and a reload picks it back up. When the new backend answers, the dialog shows the result and reloads the dashboard after a short countdown, or, if the dialog was closed, a toast reports the new version and every list refreshes.
The dashboard reads GET /auth/instance for the version (any member) and the admin endpoints below for the rest (platform admins only, the same permission gates as the admin panel).
What counts as newer
Two signals, and either one lights the pill:
- A release. The newest release of
RELEASES_GITHUB_REPOonUPDATE_CHANNEL(stable, ordevto include prereleases) is compared with the running build's version. A build stampedv1.4.0-3-gabc1234(three commits past thev1.4.0tag) is treated as newer thanv1.4.0and older thanv1.4.1, so an instance built frommainis not nagged about the release it already contains. - Commits. When the updater runs, it fetches the remote on
UPDATER_FETCH_INTERVALand reports how far the checkout is behind its branch. Any distance counts as an update on an install that tracks a branch.
A build that carries no version (an image built without the build arguments, reported as dev) cannot be compared with a release, and only the commit distance applies.
The version comes from the binary itself: the Dockerfiles and make up stamp the tag, commit and build time in (internal/version), CI does the same for published images, and warmblyctl status prints it as the first line.
Update and restart
The button appears when the updater is reachable and something newer exists. It needs the manage_settings admin permission, and every press is an audit row (upgrade on instance).
What happens, in the order the dialog shows it:
- Fetch.
git fetch --tags --pruneon the checkout. - Pull. On a branch, a fast-forward merge of the remote branch. On a checkout pinned to a tag, a checkout of the newest release. A checkout with local modifications is refused rather than merged over; commit or stash them, or set
UPDATER_ALLOW_DIRTY=true. - Build.
docker compose buildfor every service, with the version stamped in. - Restart.
docker compose up -d --no-buildfor every service the checkout defines plus everything running, minus the updater itself. Compose recreates only the containers whose image or configuration changed, so Postgres, Redis and NATS stay up, and the backend applies migrations as it boots. - Clean up.
docker image prune -f, unlessUPDATER_PRUNE=false. - Wait for backend. The updater polls
/healthuntil it answers, for up to six minutes, and marks the job as succeeded or failed.
If the updater's own image changed, it recreates itself last, after the outcome is already on disk, so the new updater reports the finished job.
The dialog streams the log. Close it and the pill keeps following the job; reload the page and the pill picks the job up again. When the backend answers with a new build, a toast says which version is now running and every list refreshes.
Migrations are forward-only, so an update never needs to be rolled back to be safe with data in place. Read backing up anyway: a backup before an update is the one you are glad to have.
The updater holds the docker socket
Inside the compose stack the updater mounts /var/run/docker.sock, which is root on the host. That is why it lives behind the updater compose profile and answers only to a bearer token the backend holds, on the compose network, with no published port. The backend gates the button on an admin permission and audits it. If any of that is more trust than the host should extend, leave the profile off and update by hand.
Enabling the updater
Docker Compose
make up starts the stack with the updater profile, so a stock install already has the button. For a plain docker compose up, put this in .env:
COMPOSE_PROFILES=updaterThe service mounts the checkout at its own host path (WARMBLY_REPO_DIR, $PWD by default) so the compose file's relative paths resolve the same way inside the container, and shares INTERNAL_API_TOKEN with the backend as UPDATER_TOKEN. Nothing else to configure.
Files git writes as root are given back to the checkout's owner after each pull, so your own git pull on the host keeps working.
To update by hand instead: make upgrade (a fast-forward pull, then make up). To leave the button out entirely, start with make up UPDATER=false and set UPDATER_URL=none in .env; the panel then only reports.
Without Docker
On a bare-metal install the updater is a systemd unit that runs scripts/upgrade-bare-metal.sh after pulling. The script builds every artifact this host runs as the checkout's owner, then hands off to one privileged installer, warmbly-install-release, which copies the artifacts into /opt/warmbly, keeps each frontend's config.js, restarts the backend first and the rest once it answers.
cd /opt/warmbly/src
go build -o out/updater ./cmd/updater && sudo install -m 0755 out/updater /opt/warmbly/bin/
sudo install -o root -g root -m 0755 deploy/systemd/warmbly-install-release.sh /usr/local/sbin/warmbly-install-release
sudo install -m 0644 deploy/systemd/warmbly-updater.service /etc/systemd/system/
printf 'UPDATER_TOKEN=%s\n' "$(grep ^INTERNAL_API_TOKEN= /etc/warmbly/warmbly.env | cut -d= -f2-)" | sudo tee /etc/warmbly/updater.env >/dev/null
sudo chmod 0600 /etc/warmbly/updater.env
sudo systemctl daemon-reload && sudo systemctl enable --now warmbly-updaterThe unit runs as the user who owns the checkout (deploy as shipped; edit it). The only root step is the installer, so that user needs exactly one sudoers line, and nothing broader:
echo "deploy ALL=(root) NOPASSWD: /usr/local/sbin/warmbly-install-release" | sudo tee /etc/sudoers.d/warmbly-upgrade >/dev/null
sudo chmod 0440 /etc/sudoers.d/warmbly-upgradeThe installer is root-owned and not writable by that user, takes no arguments, reads only from fixed paths under the checkout and refuses symlinks there, so owning the checkout does not become owning the host. The binaries it installs run as the unprivileged warmbly user either way.
Then point the backend at the updater in warmbly.env and restart it:
UPDATER_URL=http://127.0.0.1:8095The same script works by hand: scripts/upgrade-bare-metal.sh --pull.
Configuration
Backend:
| Variable | What it does | Default |
|---|---|---|
UPDATE_CHECK_ENABLED | Polls GitHub Releases and shows a newer version in the panel | true |
UPDATE_CHECK_INTERVAL | How often the release check runs; minimum 5m | 30m |
UPDATE_CHANNEL | stable follows releases; dev also offers prereleases | stable |
RELEASES_GITHUB_REPO | The owner/repo whose releases are Warmbly versions. Point a fork's instance at the fork | warmbly/warmbly |
RELEASES_GITHUB_TOKEN | Optional; only raises the GitHub API rate limit | unset |
UPDATER_URL | The updater. Unset, or none, leaves the panel report-only | http://updater:8095 under compose |
UPDATER_TOKEN | Bearer token presented to the updater | INTERNAL_API_TOKEN |
Updater:
| Variable | What it does | Default |
|---|---|---|
UPDATER_TOKEN | The token it accepts; falls back to INTERNAL_API_TOKEN. Refuses to start without one | unset |
UPDATER_MODE | compose rebuilds and recreates the compose project; command runs UPDATER_COMMAND | compose |
UPDATER_COMMAND | The build-and-restart command for command mode | unset |
UPDATER_REPO_DIR | The checkout | working directory |
UPDATER_REMOTE | The git remote to fetch and pull from | origin |
UPDATER_COMPOSE_PROJECT | The -p the stack was started with | warmbly |
UPDATER_COMPOSE_PROFILES | Extra profiles on every compose call, so its own image is rebuilt | updater |
UPDATER_BACKEND_HEALTH_URL | Polled after the restart until it answers | http://backend:8080/health |
UPDATER_FETCH_INTERVAL | How often it fetches for the commits-behind count | 30m |
UPDATER_PRUNE | docker image prune -f after a successful update | true |
UPDATER_ALLOW_DIRTY | Update over local modifications in the checkout | false |
UPDATER_STATE_DIR | Where the last job is kept across restarts | /var/lib/warmbly-updater |
UPDATER_ADDR | Listen address | :8095 |
Endpoints
| Endpoint | Auth | Returns |
|---|---|---|
GET /admin/instance/update | platform admin, view_analytics | Running build, latest release, whether an update is available, updater and job state. ?log=1 includes the job log |
POST /admin/instance/update/check | platform admin, manage_settings | Runs both checks now and returns the state |
POST /admin/instance/update/apply | platform admin, manage_settings | Starts an update job ({"target": "latest"} or a release tag) and returns it. The backend restarts as part of it |
The updater's own API (GET /status, POST /check, POST /update) is bearer-authenticated and meant for the backend only.
Findings
Two instance health checks belong to this page:
update_available(info): a newer version exists, with the version and how to apply it.updater_unreachable(warning):UPDATER_URLis set but nothing answers there, so the button cannot work. Start the updater, fix the address, or remove the variable to update by hand.
Workers on other machines
This page is about the control plane. Remote workers installed from the panel keep their own daily self-update timer and the Pull latest and restart action on the worker's page; see day-2 operations.