Airwaves OS updates in layers, all surfaced in the control app under
Settings → System Update (/updates):
| Layer | What it is | How it updates |
|---|---|---|
| System Manager | ghcr.io/airframesio/airwaves-manager (Rust API) |
pull image, recreate container |
| Control Panel | ghcr.io/airframesio/airwaves-gateway (nginx + the React control app) |
pull image, recreate container |
| System Configuration | /etc/airwaves/docker-compose.yml |
download from manifest (sha256-verified), atomic replace |
| App Catalog | /etc/airwaves/catalog.json |
download from manifest (sha256-verified), atomic replace |
| OS packages / kernel | Debian/apt packages | apt-get upgrade (reboot-flagged if kernel changed) |
| Major OS upgrade | e.g. Bookworm → Trixie | in-place apt full-upgrade after repointing apt sources, then reboot |
Each update carries a severity, shown as a badge and used to drive the global banner:
- nice-to-have — optional, quality-of-life.
- recommended — preferred; apply soon.
- required — necessary (security/compat). Surfaced as a red banner across the whole app until applied.
The device checks a JSON manifest for the configured channel:
https://raw.githubusercontent.com/airframesio/airwaves-os/main/releases/<channel>.json
(Default channel stable → releases/stable.json.) Schema (schema: 1):
The manager overrides the source with env vars when needed:
AIRWAVES_MANIFEST_URL (full URL) or AIRWAVES_MANIFEST_BASE (base dir).
- manager —
env!("CARGO_PKG_VERSION")baked into the binary. - control app — the gateway serves
/version.json(stamped at image build from the control-apppackage.jsonvia theCONTROL_APP_VERSIONbuild-arg); the manager fetcheshttp://airwaves-gateway/version.json. - compose / catalog — integer revisions tracked in
/etc/airwaves/.versions.json. - OS —
/etc/os-release(codename) +/etc/airwaves-release(Airwaves version); upgradable package count viaapt-get -s upgrade.
semver is used to compare versions; non-semver strings fall back to string
inequality. unknown/empty never counts as an update.
The manager runs inside the compose stack, so it cannot recreate itself. Instead all host mutations go through one audited path:
- The manager writes a job spec to
/etc/airwaves/update/request.jsonand triggerssystemctl start airwaves-update.service(viansenterinto the host PID namespace — the manager already runs withpid: host+HOST_VIA_NSENTER=1). /opt/airwaves/scripts/airwaves-updateruns as host root, outside the container, writing incremental progress to/etc/airwaves/update/status.json.- Because the status file lives on the host bind-mount, it survives the
manager being recreated mid-update. The control app polls
GET /api/v1/system/update/progressand reconnects automatically.
Steps: snapshot config + image digests → download/verify (sha256) compose &
catalog → docker compose pull → docker compose up -d --remove-orphans →
health-gate (wait for manager + gateway healthy) → on failure roll back
config and re-up the previous stack → apt upgrade / major upgrade → set
reboot_required.
Dry-run: set AIRWAVES_UPDATE_DRYRUN=1 to log actions without mutating.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/system/update/status |
cached check result |
| POST | /api/v1/system/update/check |
force a fresh manifest fetch |
| POST | /api/v1/system/update/apply |
{ "components": ["manager","gateway","compose","catalog","os_packages","os_major"] } or ["all"] |
| GET | /api/v1/system/update/progress |
host updater progress |
A background task re-checks every 12h and broadcasts a UpdateAvailable
WebSocket event when a recommended/required update appears.
- Bump component versions (manager
Cargo.toml, control-apppackage.json,/etc/airwaves-release, and the.versions.jsonseed inairwaves-base.shfor compose/catalog as needed). - Build & push the container images (CI: Build Containers).
- Recompute
sha256for the compose template + catalog and updatereleases/stable.json(and archive a copy asreleases/<version>.json). - Commit/push.
stable.jsonpoints atmain(rolling latest); versioned manifests should pin theirurls to the release tag once tags are cut (see ROADMAP).
Devices flashed before the updater existed don't have the host script or the
pid: host compose yet. Run once, as root, on the device:
curl -fsSL https://raw.githubusercontent.com/airframesio/airwaves-os/main/scripts/bootstrap-updater.sh | sudo bashThis installs the updater + unit, refreshes docker-compose.yml, pulls images,
and brings the stack up. After that, updates are fully in-app.
- Container image rollback is best-effort (compose pins
:latest); config-file rollback is exact. Atomic, rollback-safe OS updates are tracked for A/B partitions — see ROADMAP.md. - Major in-place Debian upgrades are powerful but inherently risky without A/B partitions; back up first.
Devices track one of three channels, stored in /etc/airwaves/.versions.json
("channel"), which selects the manifest fetched (releases/<channel>.json):
- stable — production releases (default).
- beta — early access to upcoming releases.
- dev — bleeding edge; may be unstable.
Switch channels in the control app (System Update → channel selector) or via the API:
POST /api/v1/system/update/channel { "channel": "beta" }
Switching persists the choice and immediately re-checks against the new channel's manifest.
The Build Containers workflow tags images by git ref so each channel has
its own moving image tag, plus an immutable version tag and a sha- tag:
| Git ref | Channel | Image tags published |
|---|---|---|
push to main |
dev | dev, <version>, sha-… |
push to beta branch |
beta | beta, <version>, sha-… |
push tag v* |
stable | stable, latest, <version>, sha-… |
workflow_dispatch |
choose (auto infers from branch) |
as above |
(<version> = manager Cargo.toml / control-app package.json.)
Each channel manifest (releases/<channel>.json) pins the component tag to
its channel:
stable.json→ immutable version tag (e.g.1.0.3) for reproducibility; bump on each stable release.beta.json→beta(moving; republished on everybetabranch push).dev.json→dev(moving; republished on everymainpush).
On apply, the host updater rewrites docker-compose.yml to the manifest's
pinned tag before pulling, so a dev device runs :dev, a beta device runs
:beta, and stable devices stay on the exact stable version — no fighting
over :latest.
- dev: merge to
main. CI builds, publishes<version>-dev.<run#>, and thepin-manifestjob auto-updatesreleases/dev.jsonto that concrete tag — no manual manifest edit. The unique<run#>makes each dev build a distinct concrete tag, so dev devices detect and pull the exact new image. - beta: push the
betabranch. Same flow →<version>-beta.<run#>, manifest pinned automatically. - stable: push a
v<version>tag (buildsstable+latest+ version); bump versions andreleases/stable.jsonto the version tag, and recompute the compose/catalogsha256. (Stable is human-maintained, not auto-pinned.)
All releases/*.json are read from main, so update the channel manifest on
main even when the images came from another branch.
{ "schema": 1, "channel": "stable", "os_version": "1.0.0", "codename": "Sideband", "released": "2026-05-28", "severity": "recommended", // rollup = max of components "min_os_version": "1.0.0", "summary": "…", "notes_url": "…", "components": { "manager": { "version": "1.0.0", "severity": "recommended", "image": "ghcr.io/…", "tag": "latest" }, "gateway": { "version": "1.0.0", "control_app_version": "1.0.0", "severity": "recommended", "image": "ghcr.io/…", "tag": "latest" }, "compose": { "version": "1", "severity": "required", "url": "…/docker-compose.yml.template", "sha256": "…" }, "catalog": { "version": "1", "severity": "nice-to-have", "url": "…/catalog.json", "sha256": "…" } }, "os": { "major_upgrade": null, // or { "from": "bookworm", "to": "trixie", "severity": "recommended", "guide_url": "…" } "reboot_expected": false } }