← Home Few Bits
Cover image for TinyOTA: Minimal Local OTA Server for ESP Devices

TinyOTA: Minimal Local OTA Server for ESP Devices

Alex Solis Alex Solis ·

Why a tiny local OTA server?

If you run a handful of ESP8266 or ESP32 devices on a local network and want a simple, private way to push firmware updates without relying on cloud services, a tiny local OTA server fits the bill. The goal here is minimalism: one small script, one firmware file, minimal dependencies, and a little basic authentication so updates stay within your LAN.

What you need

  • A device on the same LAN to act as the update server (Raspberry Pi, laptop, or even a low-power VM).
  • Python 3 (standard library only) on the server.
  • An ESP8266 or ESP32 with a sketch that checks for updates and applies them.
  • Basic firewall hygiene: keep the server reachable only inside your local network.

How it works at a glance

The server serves a single file at /firmware.bin and requires HTTP Basic Auth. The device requests that file, compares a version marker (or you can rely on update scheduling), and performs the update using the platform's HTTP update mechanism.

Minimal Python OTA server

Save this as ota_server.py on the machine that will host updates. It uses only the Python standard library and implements very small Basic Auth protection and file serving for a single firmware binary.

#!/usr/bin/env python3 from http.server import HTTPServer, BaseHTTPRequestHandler import base64, os USERNAME = 'ota' PASSWORD = 'localpass' FIRMWARE_PATH = 'firmware.bin' # file to serve class SimpleOTAHandler(BaseHTTPRequestHandler): def do_GET(self): auth = self.headers.get('Authorization') if auth is None or not auth.startswith('Basic '): self._unauthorized() return creds = base64.b64decode(auth.split(' ',1)[1]).decode() if creds != f"{USERNAME}:{PASSWORD}": self._unauthorized() return if self.path == '/' or self.path == '/index.html': self._send_index() return if self.path == '/firmware.bin': if not os.path.exists(FIRMWARE_PATH): self.send_response(404) self.end_headers() self.wfile.write(b'No firmware available') return self.send_response(200) self.send_header('Content-Type', 'application/octet-stream') self.send_header('Content-Length', str(os.path.getsize(FIRMWARE_PATH))) self.end_headers() with open(FIRMWARE_PATH, 'rb') as f: while True: chunk = f.read(8192) if not chunk: break self.wfile.write(chunk) return self.send_response(404) self.end_headers() def _unauthorized(self): self.send_response(401) self.send_header('WWW-Authenticate', 'Basic realm="TinyOTA"') self.end_headers() def _send_index(self): self.send_response(200) self.send_header('Content-Type', 'text/plain') self.end_headers() self.wfile.write(b'TinyOTA server. GET /firmware.bin with Basic Auth.') if __name__ == '__main__': server = HTTPServer(('0.0.0.0', 8000), SimpleOTAHandler) print('TinyOTA serving on port 8000') server.serve_forever()

Usage: place your compiled firmware in the same folder as the script and name it firmware.bin, then run:

python3 ota_server.py

ESP sketch notes

On the ESP side use the platform HTTP update routines. For ESP8266 the ESP8266HTTPUpdate library is handy; for ESP32 use HTTPUpdate. The server expects HTTP Basic Auth, so include credentials in the request URL or use a client that sets the header. Here is a minimal ESP32 snippet using Update and HTTPClient (Arduino core):

// call this when you want to update #include <HTTPClient.h> #include <Update.h> bool doOTA(const char* server, const char* user, const char* pass){ HTTPClient http; String url = String("http://") + server + ":8000/firmware.bin"; http.begin(url); String auth = String(user) + ":" + String(pass); String header = "Basic " + base64::encode(auth); // replace with your base64 helper http.addHeader("Authorization", header); int code = http.GET(); if(code != 200){ http.end(); return false; } int len = http.getSize(); WiFiClient * stream = http.getStreamPtr(); if(!Update.begin(len)){ http.end(); return false; } size_t written = Update.writeStream(*stream); if(written == len){ if(Update.end()){ if(Update.isFinished()){ http.end(); return true; // reboot to apply } } } Update.abort(); http.end(); return false; }

Notes: Arduino core helpers vary, and some environments provide ready-made HTTPUpdate clients that handle auth and headers for you. The sketch above omits error logging for brevity.

Security and privacy

  • Basic Auth in plaintext is acceptable only on trusted local networks. If you need transport security, proxy the Python server behind a device that supports TLS termination, or use nginx with a local self-signed cert.
  • Keep the server behind your firewall or on a VLAN for IoT devices. Do not expose it to the Internet.
  • Rotate the password when devices are decommissioned. You can also implement a token header instead of Basic Auth if you prefer.

Troubleshooting checklist

  1. Server reachable: from a machine on the LAN run curl -I http://server:8000/ to confirm index and the Basic Auth header challenge.
  2. Auth header: try curl -u ota:localpass http://server:8000/firmware.bin and confirm you get a binary response.
  3. File size: ensure firmware.bin is the right binary and not an error page. The server returns Content-Length; compare it to the expected firmware size.
  4. ESP client: enable serial logging on the ESP to see HTTP response codes and Update library messages.
  5. Chunked or unknown size: some update routines require a known Content-Length. The simple server sends a length header; avoid Transfer-Encoding: chunked unless your client supports it.

Variants and next steps

  • Serve multiple firmware versions: extend the handler to list files and serve by name, or add a small JSON endpoint that returns the latest version string.
  • Use mutual TLS or client certificates for stronger security if you operate in a higher-threat environment.
  • Automate releases: script copying freshly built firmware into the server directory and bumping a version.txt file so devices can poll and decide whether to update.
Small, local, and auditable OTA beats cloud-only updates for privacy-conscious deployments. Keep it simple and know exactly what your devices are pulling.