TinyOTA: Minimal Local OTA Server for ESP Devices
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
- Server reachable: from a machine on the LAN run curl -I http://server:8000/ to confirm index and the Basic Auth header challenge.
- Auth header: try curl -u ota:localpass http://server:8000/firmware.bin and confirm you get a binary response.
- 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.
- ESP client: enable serial logging on the ESP to see HTTP response codes and Update library messages.
- 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.