A task-oriented walkthrough for wiring nerves_github_updater into a
Nerves app, cutting your first (unsigned) release, then graduating to
signed releases with rollback protection. For the full opt reference
see the README; for the manifest wire format see
guides/manifest-format.md.
1. Add + wire the library
# mix.exs
def deps do
[
{:nerves_github_updater, "~> 0.1"}
]
endChild-spec NervesGithubUpdater.Supervisor somewhere in your app's
supervision tree. This is the minimal set of opts to get a device
checking and installing:
# lib/my_app/application.ex
children = [
# ...
{NervesGithubUpdater.Supervisor,
owner_repo: "myorg/my_app",
verification_required: false,
download_dir: "/data/fw_update",
reboot_fn: fn -> Nerves.Runtime.reboot("0 tryboot") end,
devpath_fn: fn -> Nerves.Runtime.KV.get("nerves_fw_devpath") end,
target_fn: fn -> Nerves.Runtime.KV.get_active("nerves_fw_platform") end,
current_version_fn: fn -> Nerves.Runtime.KV.get_active("nerves_fw_version") end,
kv_get: &Nerves.Runtime.KV.get/1,
kv_put: &Nerves.Runtime.KV.put/2,
asset_matcher: &MyApp.FirmwareUpdate.match_asset/2}
]asset_matcher is your own (tag_name, assets) -> {:ok, fw_asset} | {:error, reason} function — see step 2 for what it needs to match.
The README's "Usage / wiring" section
has the complete opt list (PubSub, :channel, :allow_downgrade,
etc.); this guide only calls out what each task needs.
A common pattern (see NervesGithubUpdater.Updater's moduledoc) is to
put all of this behind a small host-owned facade module — one that
owns the child-spec, exposes check/0 / install_latest/0 /
update_config/1, and translates your app's own config surface
(env vars, a Nerves.Runtime.KV-backed store, a LiveView settings
page — whatever it is) into these opts. That facade is also where
reboot_fn, devpath_fn, target_fn, kv_get/kv_put, and
asset_matcher live, since they all reach into Nerves.Runtime in
ways the library itself never does.
2. Cutting an unsigned release (bootstrap)
verification_required: false (the default) is the simplest path:
no manifest, no signature — just a .fw asset the device finds by
filename and flashes.
Name the firmware asset so your asset_matcher can find it. The
convention (also used by the manifest path) is <app>_<target>.fw,
e.g. my_app_rpi3.fw:
def match_asset(_tag_name, assets) do
target = Nerves.Runtime.KV.get_active("nerves_fw_platform")
fw_name = "my_app_#{target}.fw"
case Enum.find(assets, fn a -> a.name == fw_name end) do
nil -> {:error, :no_fw_asset}
asset -> {:ok, asset}
end
endCut the release:
mix firmware # or your Nerves build step, producing _build/.../my_app.fw
cp _build/rpi3_dev/nerves/images/my_app.fw my_app_rpi3.fw
gh release create v0.2.0 my_app_rpi3.fw --title "v0.2.0"
The device picks it up the next time it calls check/0 — the
NervesGithubUpdater.Updater state machine goes
:idle → :checking → :idle and caches the release. Call
install_latest/0 to actually download and flash it
(:downloading → :flashing → :idle).
The release's tag_name (v0.2.0 above) must compare as newer than
:current_version_fn.(), or the install is refused with
{:downgrade_refused, ...} — set allow_downgrade: true if you
genuinely need to reinstall an older tag.
This path has no rollback protection and no authenticity check —
anyone who can attach an asset named right to any release you poll
gets flashed, unverified. It's a deliberate bootstrap convenience for
getting a fleet manifest-aware before you've provisioned a signing
key; every such install fires a Logger.warning so the exposure is
auditable in your logs. Move to signed releases (below) before this
matters in production.
3. Switching on signed releases
a. Generate a signing key
openssl genpkey -algorithm ed25519 -out fw_signing.pem # keep private!
openssl pkey -in fw_signing.pem -pubout -outform DER \
| tail -c 32 > firmware_signing.pub # 32 raw bytes
firmware_signing.pub is the raw 32-byte Ed25519 public key every
device trusts. How it gets onto the device is up to you — the
library only takes it as the :public_key opt, as binary bytes, and
compares it byte-for-byte against a manifest's signature. A common
approach is baking it into the firmware image itself, e.g. a file
under rootfs_overlay/ that your host code reads at boot and passes
through:
public_key: File.read!("/etc/firmware_signing.pub")Until a real key is provisioned, the device holds the all-zero
sentinel key — NervesGithubUpdater.Signature.verify_manifest/3
refuses to validate against it ({:error, :missing_public_key}),
so an unprovisioned device fails closed rather than trusting anything.
For production signing you generally don't want the private key
sitting in a file at all — see
guides/manifest-format.md for
signer-agnostic recipes (AWS KMS, GCP Cloud KMS, a local key/HSM in
CI) and the reusable
.github/workflows/sign-firmware.yml
workflow that wraps all of them.
b. Cutting a signed release
A signed release adds release-manifest.json + release-manifest.sig
alongside the .fw asset(s). The manifest schema is documented in
full in guides/manifest-format.md;
a minimal example for one target:
{
"version": 1,
"counter": 1789475200,
"signed_at": "2026-07-15T12:00:00Z",
"expires_at": null,
"targets": {
"rpi3": {
"asset": "my_app_rpi3.fw",
"sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"size": 47185920
}
},
"deltas": {}
}Sign sha512(manifest_bytes) — not the raw manifest — and write the
raw 64-byte signature to release-manifest.sig:
sha512sum release-manifest.json | cut -d' ' -f1 | xxd -r -p > digest.bin
openssl pkeyutl -sign -rawin -inkey fw_signing.pem \
-in digest.bin -out release-manifest.sig
In CI, call the reusable
sign-firmware.yml workflow
instead of hand-rolling this — it downloads the release's .fw
assets, builds the manifest (including the counter), signs it via
whichever signing-backend you configure (aws-kms / gcp-kms /
local-key / manual), and uploads both files back to the release.
Attach all three files to the release:
gh release create v0.3.0 my_app_rpi3.fw
gh release upload v0.3.0 release-manifest.json release-manifest.sig
c. Flip the device to signed verification
Once a real public key has replaced the sentinel on-device, turn on enforcement — through whatever config surface your host facade exposes (see step 4), which under the hood calls:
NervesGithubUpdater.Updater.update_config(verification_required: true)From then on, check/0 + install_latest/0 take the manifest path
(:verifying → :downloading → :flashing): signature, schema, expiry
(if enabled), and rollback counter are all checked before a single
byte of firmware is downloaded.
d. The monotonic counter
Every manifest carries a counter. After a successful flash, the
device persists it via your :kv_put opt (key
fw_manifest_counter, typically backed by Nerves.Runtime.KV) —
only after the flash succeeds, so a failed install never moves
the rollback floor. A future manifest with a lower counter is
refused ({:manifest_rollback, manifest_counter, stored_counter});
an equal counter is allowed (reinstalling the current release);
a device with no stored counter yet accepts and sets it
(first-contact trust, same model as the public key). CI's
sign-firmware.yml uses Unix epoch seconds at signing time, which is
monotonic by construction as long as you don't sign two releases with
manually-forced counters out of order.
4. Operator runtime controls
Everything below maps directly onto
NervesGithubUpdater.Updater.update_config/2's mutable opts — the
library itself only exposes that one call; it's up to your host app
to expose a corresponding control (an IEx/SSH helper, a LiveView
settings page, a config file reload, etc.) that persists the change
somewhere durable and forwards it. A common pattern is the single
update_config/1 entry point on the host's facade module: it writes
to the app's own config store first, then best-effort propagates the
same sanitized values to the live Updater process so an in-flight
device picks them up without a restart.
Runtime-mutable opts, and what to expose for each:
- Turn signed validation off/on —
verification_required: false/true. Turning it off drops back to the unverified legacy path (step 2) — no signature, no rollback counter, only the semver downgrade gate. Treat this as an emergency/bootstrap switch, not a steady state; flip it back totrueas soon as whatever forced it off is resolved. - Change the repo —
owner_repo: "neworg/new_repo". - Switch channel —
channel: :stableor:prerelease. - Toggle downgrade —
allow_downgrade: true/false. - Toggle expiry enforcement —
enforce_expiry: true/false. - Point at a different target — this one is a function opt, not
a simple value:
target_fn: fn -> "rpi4" end. Sincetarget_fnis itself an opt, your host facade decides whether "switch target" is even a runtime-exposed control or a compile-time constant.
update_config/2 returns {:error, :immutable} for opts that are
fixed at supervisor start (:pubsub, :pubsub_topic,
:download_dir, :fwup_devpath, :fwup_task) and {:error, :unknown} for anything it doesn't recognize. Changes take effect on
the next check/1/install_latest/1 — an install already in
flight keeps running with the opts it started with.
5. Observing progress
Subscribe to your configured :pubsub_topic (requires :pubsub +
:pubsub_topic both set, and :phoenix_pubsub as a dependency):
Phoenix.PubSub.subscribe(MyApp.PubSub, "firmware_update:progress")Every phase transition broadcasts:
{:fw_update_progress, %{phase: phase, pct: pct, message: message}}phase is one of :checking, :verifying, :downloading,
:flashing, :idle, or :error. Which phases you see depends on
the install path:
- Unsigned (legacy) —
:downloading → :flashing(no:verifying). - Signed (manifest) —
:verifying → :downloading → :flashing.
pct updates on phase transitions and via intermediate download/flash
progress callbacks; a state/1 snapshot taken mid-download or
mid-flash can lag briefly behind the most recent PubSub event (see
NervesGithubUpdater.Updater's moduledoc "Progress snapshot caveat").
6. Troubleshooting
{:error, :missing_public_key}—:public_keyisnil, not 32 bytes, or still the all-zero sentinel. Provision a real key (step 3a) before enablingverification_required: true.{:error, :invalid_signature}— the signature doesn't verify against the manifest bytes with the configured public key. Check you signedsha512(manifest_bytes), not the raw manifest, and that the public key on-device matches the private key that signed it.{:error, {:manifest_rollback, manifest_counter, stored_counter}}— the release'scounteris lower than what the device already persisted. You're trying to install an older signed release than one it's already run; this is the rollback guard doing its job.{:error, {:sha256_mismatch, expected: _, actual: _}}— the downloaded asset's hash doesn't match the manifest'ssha256for that target. Usually a stale/wrong asset attached to the release, or a manifest built against different bytes than what's uploaded.{:error, :no_fw_asset}(legacy path) /{:error, {:target_asset_missing, asset_name}}(manifest path) — no asset on the release matched what:asset_matcher(legacy) or the manifest'stargetsentry (signed) expected. Check the asset filename against yourtarget_fn's value and the release.{:error, :rate_limited}— GitHub's anonymous API quota (60/hr/IP) is exhausted. Set:github_tokento lift it to 5,000/hr, and make sure you're reusing the ETagcheck/1already tracks — a matchingIf-None-Matchreturns304/:not_modifiedwithout counting against quota.