Skip to Content
Embedded

ESP32 OTA Updates from GitHub Releases, Without an OTA Server

28 October 20265 min read9 views
How I ship ESP32 firmware updates from GitHub Releases with GitReleaseOTA: a version.json, SHA-256 checks, a non-blocking check task, a one-command release script, and the security gaps I still want to close.

You can update ESP32 firmware over the air straight from GitHub Releases, without running your own OTA server. Publish firmware.bin and a small version.json with the version and a SHA-256 hash as release assets. The device checks version.json at the stable "latest release" URL, compares versions, then streams the binary into its spare OTA partition, checks the hash and reboots. That's what my open-source library GitReleaseOTA does. I built it from the firmware of my SmartPot plant companion.

This note covers how it works, the decisions behind it, and what I'd still harden before putting it on a fleet of devices.

Why GitHub Releases works as an OTA server

GitHub gives every release asset a stable address. Its docs on linking to releases say the latest release's uploaded assets are available at a URL ending in /releases/latest/download/ plus the file name. So a device can always ask for:

https://github.com/<user>/<repo>/releases/latest/download/version.json

and get whatever you published last. No server to pay for, no upload page to secure, and the files come from GitHub's own file hosting. Two practical points:

  • The releases repo must be public, or every device needs a token. GitReleaseOTA uses two repos: your firmware source (private is fine) and a public releases repo that only holds firmware.bin and version.json.
  • GitHub redirects. The download URL sends you through a redirect to the actual file host, so the HTTP client has to follow redirects. The library sets the ESP32 HTTPClient to follow them strictly.

The update flow on the device

otaCheckAsync()   fetch version.json, compare versions
      |
UPDATE_AVAILABLE  remote version is newer
      |
otaInstall()      stream firmware.bin in 1 KB chunks
      |           hash every chunk with SHA-256 as it arrives
VERIFYING         compare with the hash in version.json
      |           mismatch: abort, stay on current firmware
SUCCESS           mark the new partition bootable, restart

A few decisions that came out of running this on real hardware:

The check doesn't block the main loop. otaCheckAsync() runs the version check in a FreeRTOS task pinned to core 0, so loop() keeps reading sensors and driving the display while the request is in flight. On a plant monitor that also listens for a wake word, a request that stalls the loop is noticeable. I wrote more about splitting work across cores in FreeRTOS task scheduling on the ESP32.

A maintenance callback before flashing. Writing flash while I2S audio, LEDs and sensors are still running is asking for trouble. otaSetMaintenanceCallback() lets you stop them first, and a global g_otaInProgress flag tells your other FreeRTOS tasks to idle until the device restarts.

The hash is computed while downloading. The firmware streams in 1 KB chunks. Each chunk is written to the inactive partition and fed to SHA-256 at the same time, so there's no second pass over flash. If the final hash doesn't match version.json, the update is aborted and the device carries on with the old firmware.

Versions are compared as numbers. As strings, "1.10.0" sorts before "1.9.0", which breaks the first time you reach double digits. The library parses major, minor and patch as integers:

static bool versionGreater(const String &a, const String &b) {
  int ma=0,mi_a=0,pa=0, mb=0,mi_b=0,pb=0;
  sscanf(a.c_str(), "%d.%d.%d", &ma, &mi_a, &pa);
  sscanf(b.c_str(), "%d.%d.%d", &mb, &mi_b, &pb);
  if (ma!=mb)     return ma > mb;
  if (mi_a!=mi_b) return mi_a > mi_b;
  return pa > pb;
}

Partitions: why a failed update doesn't brick the board

OTA on the ESP32 relies on two app slots and a small otadata partition that records which slot to boot. Espressif's OTA documentation describes it: new firmware is written to the slot that isn't running, and only after it's complete is otadata switched to boot it. The library's README shows the 4 MB layout I use:

PartitionSizeRole
nvs20 KBSettings and Wi-Fi credentials
otadata8 KBWhich app slot boots next
app0 (ota_0)1,984 KBOne firmware slot
app1 (ota_1)1,984 KBThe other firmware slot

If power drops halfway through a download, the running slot was never touched, so the device boots the old version. The one real constraint is size: the firmware has to fit in one slot, or Update.begin() fails and the library reports OTA_ERROR_NO_SPACE.

Releasing is one command

The part I use most is release.sh:

./release.sh patch "Fixed sensor timeout"

It reads FW_VERSION from the header, bumps it, builds with PlatformIO, computes the SHA-256, writes version.json, and creates the GitHub release with gh release create, uploading both files. Every device picks it up on its next check. If you haven't moved from the Arduino IDE to PlatformIO yet, this kind of scripted build is one of the better reasons; I compared the two in PlatformIO vs Arduino IDE.

Installing the library is one line in platformio.ini:

lib_deps = jishnumahanta/GitReleaseOTA@^1.0.2

What I'd harden before a production fleet

GitReleaseOTA works well for my own devices and prototypes. Before trusting it with hundreds of devices in someone else's building, I'd change three things, and I'd rather say so plainly:

  1. Verify the TLS certificate. The current release connects over HTTPS but calls setInsecure(), which encrypts the connection without checking who is on the other end. Pinning the right root certificates (or using the ESP32 certificate bundle) closes that gap.
  2. Sign the firmware. The SHA-256 check proves the file wasn't corrupted, not who made it. Anyone who can publish a release can publish a matching hash. ESP-IDF supports signed app verification, with or without hardware secure boot, and Espressif's OTA docs cover the options.
  3. Use rollback. With CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE, a new image boots in a "pending verify" state. If it doesn't confirm itself with esp_ota_mark_app_valid_cancel_rollback() after a self-test, the bootloader returns to the previous version. That protects against firmware that downloads fine but crashes on boot. It's on the library's roadmap.

What I'd do

For a hobby project, a classroom batch or an early pilot, GitHub Releases as the update server is hard to beat: free, versioned, and every release has notes attached. Start with the two-repo setup, keep the release script in your workflow from day one, and test a failed update on purpose: pull the power mid-download and change one byte of the binary to see the hash check reject it.

For a product going to customers, add certificate checks, signing and rollback before the first unit ships. Fixing OTA after devices are in the field is much harder than getting it right first.

If you're building ESP32 hardware and want a second pair of eyes on firmware or updates, my IoT development page explains how I work.

Frequently Asked Questions

Q:Can an ESP32 download firmware directly from GitHub Releases?

Yes. A release asset is available at a stable URL ending in /releases/latest/download/ followed by the file name. The ESP32 fetches it over HTTPS, follows GitHub's redirects to the file host, and streams it into the inactive OTA partition. The repository that holds the releases has to be public, or the device needs a token.

Q:What happens if power fails during an ESP32 OTA update?

With the standard two-slot partition layout, the new firmware is written to the slot that isn't running. Until the update is finished and the boot partition is switched, the device keeps booting the old firmware, so a power cut mid-download leaves it on the previous version.

Q:Does a SHA-256 check make OTA updates secure?

It proves the file arrived intact and matches what the release describes. It doesn't prove who published it: anyone who can publish a release can publish a matching hash too. For that you need signed firmware, which ESP-IDF supports with signed app verification or secure boot.

Q:Why compare firmware versions as numbers instead of strings?

Because string comparison gets double digits wrong: as text, 1.10.0 sorts before 1.9.0, so a device could refuse a newer version or reinstall an older one. Parsing major, minor and patch as integers avoids that.

Q:How large can the firmware be for OTA on a 4 MB ESP32?

It depends on the partition table. With two OTA slots of 1,984 KB each, which is the layout GitReleaseOTA's README shows for 4 MB flash, the firmware has to fit in one slot. If it doesn't, the update fails at the start and the device stays on its current version.

Working on something similar?

Let's collaborate to design custom PCB schematics, write deterministic FreeRTOS threads, or configure secure Next.js databases.

Let's Talk →