ESP32 and Arduino

ESP32 OTA Update over MQTT: Trigger HTTPS Firmware Updates and Report Status

Trigger ESP32 firmware updates with an MQTT command, download the binary over HTTPS with HTTPUpdate, and publish progress and results to a retained status topic so you always know which version each device runs.

ESP32 devices receiving a firmware command in an ESP32 OTA update MQTT workflow with an HTTPS download server
Run this on your own MQTT brokerGet a private, managed MQTT broker on the Free plan and start from ready-made code samples.
Try it free Code samples No card required

An ESP32 OTA update over MQTT lets you upgrade devices in the field without opening the enclosure: you publish a command, the device downloads new firmware over HTTPS and reports its progress back on a status topic. This tutorial builds that workflow step by step with Arduino-ESP32's HTTPUpdate library and shows the safeguards you need before using it on real hardware.

Why Combine MQTT and HTTPS for OTA

It is tempting to push the firmware binary itself through MQTT, but it is rarely a good idea. Firmware images are hundreds of kilobytes to a few megabytes, many brokers and client libraries cap message sizes far below that, and splitting a binary into chunks means writing your own reassembly, ordering and checksum logic.

The cleaner split is to let each protocol do what it is good at:

  • MQTT carries the small control messages: "update to version 1.4.0 from this URL", plus progress and result reports. Devices are already connected, so the command arrives instantly even behind NAT.
  • HTTPS carries the large binary from a web server or object storage, with TLS, range handling and caching already solved.

The device never needs an open inbound port, and you keep a live record of every update attempt on the broker.

The Topic Design

Topic Direction Payload Retained
connectmq/demo/e47b10/<device>/ota/cmd Operator publishes {"version":"1.4.0","url":"https://..."} No
connectmq/demo/e47b10/<device>/ota/status ESP32 publishes {"state":"downloading","progress":42} Yes
connectmq/demo/e47b10/<device>/info ESP32 publishes {"version":"1.3.2"} Yes

Retaining the status and info topics means any tool that subscribes later, such as a dashboard or a script that checks a rollout, immediately sees the current version and the last OTA result of each device.

Prerequisites

  • An ESP32 with at least 4 MB flash and a partition scheme with two OTA app slots. The default Arduino scheme has them; "Huge APP (no OTA)" does not.
  • Arduino-ESP32 core 2.x or 3.x, which includes HTTPUpdate.
  • The PubSubClient and ArduinoJson libraries.
  • An HTTPS server that serves your .bin file. Any static file host works.

Export the binary with Sketch > Export Compiled Binary in the Arduino IDE, or take .pio/build/<env>/firmware.bin from PlatformIO.

Complete ESP32 OTA Update MQTT Sketch

#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPUpdate.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>

#define FW_VERSION "1.3.2"

const char* WIFI_SSID = "YOUR_WIFI";
const char* WIFI_PASS = "YOUR_WIFI_PASSWORD";
const char* ALLOWED_HOST = "https://firmware.example.com/";   // only accept URLs under this prefix

// Root CA of your firmware host in PEM format
const char* FIRMWARE_CA = R"EOF(
-----BEGIN CERTIFICATE-----
...paste the root CA of your download server here...
-----END CERTIFICATE-----
)EOF";

WiFiClient mqttNet;
PubSubClient mqtt(mqttNet);
String deviceId, topicCmd, topicStatus, topicInfo;

volatile bool otaRequested = false;
String otaUrl, otaVersion;
int lastReported = -1;

void publishStatus(const char* state, int progress = -1, const char* error = nullptr) {
  JsonDocument doc;
  doc["state"] = state;
  doc["from"] = FW_VERSION;
  if (otaVersion.length()) doc["to"] = otaVersion;
  if (progress >= 0) doc["progress"] = progress;
  if (error) doc["error"] = error;
  char buf[192];
  size_t n = serializeJson(doc, buf, sizeof(buf));
  mqtt.publish(topicStatus.c_str(), (const uint8_t*)buf, n, true);
}

void onMessage(char* topic, byte* payload, unsigned int length) {
  JsonDocument doc;
  if (deserializeJson(doc, payload, length)) return;
  String url = doc["url"] | "";
  String version = doc["version"] | "";
  if (!url.startsWith(ALLOWED_HOST) || version.length() == 0) {
    publishStatus("rejected", -1, "url or version invalid");
    return;
  }
  if (version == FW_VERSION) {
    publishStatus("up_to_date");
    return;
  }
  otaUrl = url;
  otaVersion = version;
  otaRequested = true;              // do the work in loop(), not here
}

void onProgress(int current, int total) {
  int pct = total > 0 ? (current * 100) / total : 0;
  if (pct / 10 != lastReported / 10) {   // report every 10 %
    lastReported = pct;
    publishStatus("downloading", pct);
    mqtt.loop();
  }
}

void runOta() {
  otaRequested = false;
  lastReported = -1;
  publishStatus("starting");

  WiFiClientSecure https;
  https.setCACert(FIRMWARE_CA);

  httpUpdate.rebootOnUpdate(false);
  httpUpdate.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
  httpUpdate.onProgress(onProgress);

  t_httpUpdate_return result = httpUpdate.update(https, otaUrl);

  switch (result) {
    case HTTP_UPDATE_OK:
      publishStatus("success", 100);
      mqtt.loop();
      delay(500);
      ESP.restart();
      break;
    case HTTP_UPDATE_NO_UPDATES:
      publishStatus("no_update");
      break;
    case HTTP_UPDATE_FAILED:
    default:
      publishStatus("failed", -1, httpUpdate.getLastErrorString().c_str());
      break;
  }
}

void connectMqtt() {
  while (!mqtt.connected()) {
    if (mqtt.connect(deviceId.c_str(), topicStatus.c_str(), 1, true,
                     "{\"state\":\"offline\"}")) {
      mqtt.subscribe(topicCmd.c_str());
      String info = String("{\"version\":\"") + FW_VERSION + "\"}";
      mqtt.publish(topicInfo.c_str(), info.c_str(), true);
    } else {
      delay(3000);
    }
  }
}

void setup() {
  Serial.begin(115200);
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  while (WiFi.status() != WL_CONNECTED) delay(250);

  deviceId = "esp32-ota-" + String((uint32_t)ESP.getEfuseMac(), HEX);
  String base = "connectmq/demo/e47b10/" + deviceId;
  topicCmd = base + "/ota/cmd";
  topicStatus = base + "/ota/status";
  topicInfo = base + "/info";

  mqtt.setServer("broker.connectmq.com", 1883);
  mqtt.setBufferSize(512);
  mqtt.setCallback(onMessage);
  Serial.printf("Device %s running %s\n", deviceId.c_str(), FW_VERSION);
}

void loop() {
  if (!mqtt.connected()) connectMqtt();
  mqtt.loop();
  if (otaRequested) runOta();
}

After the first flash over USB, read the device ID from the serial monitor and trigger an update:

mosquitto_sub -h broker.connectmq.com -p 1883 -i pc-e47b10 -t 'connectmq/demo/e47b10/#' -v

mosquitto_pub -h broker.connectmq.com -p 1883 -i pc-e47b10-pub \
  -t connectmq/demo/e47b10/esp32-ota-1a2b3c4d/ota/cmd \
  -m '{"version":"1.4.0","url":"https://firmware.example.com/sensor-1.4.0.bin"}'

You will see starting, a series of downloading messages, success, then an info message with the new version once the device reboots and reconnects.

How the Pieces Fit Together

The callback only records the request. httpUpdate.update() blocks for the entire download. Running it inside the MQTT callback would block mqtt.loop() from within itself, so the sketch sets a flag and lets loop() start the update. This also keeps the device from tripping the task watchdog; ESP32 task watchdog resets in MQTT projects explains why.

Progress keeps the MQTT connection alive. During a long download, the progress callback publishes every 10 % and calls mqtt.loop(), which also sends keepalive pings so the broker does not drop the session halfway through.

rebootOnUpdate(false) gives you a final word. By default HTTPUpdate restarts as soon as the image is written. Disabling that lets the device publish success first and then restart itself.

The Last Will reports crashes. The connect call registers a retained offline will message on the status topic, so a device that dies mid-update shows up as offline rather than stuck at "downloading". The ESP32 MQTT Last Will guide covers that pattern in detail.

Security Safeguards You Must Not Skip

Remote updates are the most powerful thing your device can do, which makes the update command the most attractive target. Before this goes anywhere near a real deployment:

  1. Never trigger OTA from a public topic in production. The public area of the ConnectMQ test broker is readable and writable by everyone, which is fine for the demo with a placeholder URL but not for real devices. Use a private broker with credentials.
  2. Allow-list the download host. The sketch rejects any URL that does not start with your own HTTPS prefix, so a malicious command cannot point the device at an arbitrary binary.
  3. Validate the TLS certificate. Load the root CA of your firmware host with setCACert(). setInsecure() would let anyone on the network path substitute the image.
  4. Sign your images. For stronger guarantees, ESP-IDF supports secure boot and signed app verification, so the bootloader rejects firmware you did not sign. This requires an ESP-IDF build or a custom Arduino configuration.
  5. Check versions. Ignore commands for the version already installed, and consider refusing downgrades unless explicitly flagged.

Rolling Out to a Fleet

Step What to do
1 Upload the new binary under a versioned file name, never overwrite the old one
2 Update one test device and watch its status topic until info shows the new version
3 Update a small group and watch for devices that stay offline
4 Roll out to the rest in batches, not all at once
5 Keep the previous binary available so you can push it back if needed

Sending commands device by device, rather than to a shared topic every device subscribes to, gives you this control. A small Python or Node.js script that reads a list of device IDs, publishes the command and waits for each success is usually all the tooling you need.

Troubleshooting

  • HTTP_UPDATE_FAILED with "Not Enough Space": the binary is larger than the OTA slot. Choose a partition scheme with bigger app partitions.
  • "Wrong HTTP Code" with a redirect: some hosts redirect to a CDN. The sketch follows strict redirects; make sure the CA you loaded also matches the final host.
  • TLS handshake fails: wrong CA or insufficient heap. Free large buffers before calling runOta().
  • Device reboots into the old firmware: the new image failed to boot. Test the binary over USB first.
  • Broker drops the device during download: the progress callback is not running often enough to call mqtt.loop(). Lower the reporting step.

Run Your OTA Workflow on ConnectMQ

Test the whole flow on the public MQTT test broker at broker.connectmq.com, then move the command topics behind ConnectMQ credentials. ConnectMQ is a hosted MQTT broker with multi-tenant isolation, client IDs bound to issued credentials and TLS on 8883, so only your tools can send update commands to your devices. Create a broker on the free Serverless plan at app.connectmq.com with no credit card, and use the live topic explorer to follow every device's status topic during a rollout. For the complete device project around this, see the ESP32 IoT tutorial.

Frequently Asked Questions

Can I send the firmware binary itself over MQTT?

It is possible but rarely worth it. Binaries are large, message sizes are limited, and you would need your own chunking and verification. Send a URL over MQTT and download the binary over HTTPS instead.

What happens if power fails during an OTA update?

The ESP32 writes the new image to the inactive OTA slot and only switches the boot partition after a complete, verified write. A power loss mid-download leaves the old firmware in place.

Why does my device show "downloading" forever?

It probably crashed or lost power without a clean disconnect. With a retained Last Will on the status topic, the broker replaces that state with "offline" after the keepalive timeout.

Do I need QoS 1 for the OTA command?

QoS 1 is a good choice for commands because it guarantees delivery while the device is connected. Make the handler idempotent by ignoring commands for the version that is already installed.

Related articles

ESP-IDF MQTT Example: Build an esp-mqtt Client from Scratch

Build an ESP-IDF project from an empty folder with the esp-mqtt component: Wi-Fi station setup, the MQTT event handler, publish and subscribe, MQTT 5 properties and TLS with the certificate bundle.

8 min read

Using esp-mqtt MQTT 5 Features on ESP32 with ESP-IDF

A hands-on ESP-IDF project that enables MQTT 5.0 in esp-mqtt and uses user properties, message expiry, topic aliases, session expiry and response topics against a real MQTT 5 broker.

7 min read

All articles →

Your private MQTT broker is a few clicks away

Create an account, get your broker credentials and connect your first device — no servers to install, patch or scale.