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"}
  ]
end

Child-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
end

Cut 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/onverification_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 to true as soon as whatever forced it off is resolved.
  • Change the repoowner_repo: "neworg/new_repo".
  • Switch channelchannel: :stable or :prerelease.
  • Toggle downgradeallow_downgrade: true / false.
  • Toggle expiry enforcementenforce_expiry: true / false.
  • Point at a different target — this one is a function opt, not a simple value: target_fn: fn -> "rpi4" end. Since target_fn is 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_key is nil, not 32 bytes, or still the all-zero sentinel. Provision a real key (step 3a) before enabling verification_required: true.
  • {:error, :invalid_signature} — the signature doesn't verify against the manifest bytes with the configured public key. Check you signed sha512(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's counter is 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's sha256 for 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's targets entry (signed) expected. Check the asset filename against your target_fn's value and the release.
  • {:error, :rate_limited} — GitHub's anonymous API quota (60/hr/IP) is exhausted. Set :github_token to lift it to 5,000/hr, and make sure you're reusing the ETag check/1 already tracks — a matching If-None-Match returns 304/:not_modified without counting against quota.