When I first started this project, my initial goal was to create a fully self-hosted alternative to popular IOT platforms that enable remote device management. Right now the project has the fundamentals in place needed to accomplish this monumental task, namely support for automatic and manual OTA flashing with the addition of a cloud based IDE to compile without the need to lug around the full tool chain.
The code relating to this project can be found in my GitHub repository
The steps required
This project relies on the Arduino core for the esp8266/esp32 families, and uses the VSCode extension for Arduino.
For the cloud based IDE I chose Code-Server. Code-Server handles compiling the sketch for the target Espressif module. I’ll be demoing the platform with the esp-07 module in my portable DSTIKE board.
The compiled firmware binary is then served with a custom web server written in Rust and using the Actix-Web crate to do the heavy lifting.
An Overview of the services
Currently I have each service running in a different container for security and practicality. However, this means that file transfers between different containers is a little complex in my setup. If you’re going for simplicity I would recommend running everything on the same server.
The external facing part of the infrastructure is the Caddy web server, handling interaction with each component via reverse proxy. This allows you to access the Cloud IDE [Alongside the Arduino board manager] and the Actix-Web server through the same address, and it also allows for easy implementation of HTTPS. The lines in the diagram below represent SSH reverse tunnels that connect the external Caddy server to the web services on each container.

Setting up Code-Server
Downloading Code-Server
To setup Code-Server you’re going to need to run a few commands. I’ll skip mentioning the install commands as they’ll be specific to you platform. First to download Code-Server, you’ll want to navigate to the releases page on the Code-Server GitHub repository.
Configuration
Once you have code-server installed, you can choose to create a custom password for the server by setting the PASSWORD environment variable. To do this simply add the following line to the bottom of your .bashrc
, where [your-password-here]
is replaced with the password of your choice.
export PASSWORD=[your-password-here]
Once you have that setup, launch code-server. I recommend using tmux or screen to do this. Once code-server is running, you should be greeted with the following view.

Open up a browser and point it to the address given by code-server and you should be greeted with a login page. Enter your password environment variable here, and if you’ve used VSCode you’ll be greeted with a familiar sight. The next step is to download the Arduino extension and enable it. To do this click the extensions button on the left toolbar, and then search for “Arduino”.

Patching the extension to work behind a reverse proxy
Out of the box, the extension is not compatible with a reverse proxy. This is because the Arduino board manager web server chooses a random port each time it’s spun up. This makes it rather difficult to use with Caddy since you need to know the port of the service for the proxy directive. To fix this, the express.js app inside the extension’s code needs a small patch. On my system the extension installed to ~/.local/share/code-server/extensions/vsciot-vscode.vscode-arduino-0.3.0
. If you’re running Linux then it should be similar. Navigate to this directory. The express.js code that you’re looking for should be in a file called localWebServer.js
. In my case this was located at vsciot-vscode.vscode-arduino-0.3.0/out/src/arduino/localWebServer.js
. Open this file in the text editor of your choice and replace the line
const port = this.server.listen(0).address().port;
with
const port = this.server.listen(8090).address().port;
If you aren’t using a reverse proxy then just make this 80 for simplicity.
Enable Arduino Compiler Output
There is one more change that needs to be made before the compiled binaries can be served up on the Actix-Web server. By default the Arduino IDE does not keep compiled binaries, and stores them in a temp folder for deletion after uploading to a device. To remedy this, add a target build path to .arduino15/preferences.txt
. Firstly, you’ll want to change the following line to be false
export.delete_target_folder=true
After that is marked as false, add this line in
build.path=/path/to/your/target/folder
Now when you validate your sketch with the Code-Server Arduino extension, it will save the compiled binaries to the directory you put in the line above. This is what the Actix-Web server will send to client boards.
Setting up Actix-Web for handling the HTTP updating.
When the ESPhttpUpdate function is called, you can optionally specify a version string, when the esp send the get request it adds this string in as a header. The web server, in our case Actix-Web, can respond in one of two ways based on this header. If the firmware version is below the target version, i.e. and update is needed, the ESP expects a response with code 200 “OK”, as well as the file. If the firmware is already at the latest version, then the ESP expects a response code 304 “Not Modified”.
ESPhttpUpdate.update("xxx.yyy.zzz.iii", 80, "/", String(VERSION));
The implementation for this can be rather simple depending on how you want to validate the firmware version. However, for an automatic setup the most effective way to achieve this that I’ve found is to use gcc preprocessing macros to include the date/time of compilation into the binary, which then can be sent to the Actix-Web server for comparison against the latest binary compile date/time. More how this is done below under Preparing the Firmware
Parsing the sketch version number inserted during preprocess
The sketch gets a precompile rendition created in the target build folder, under the preproc directory as a file called ctags_target_for_gcc_minus_e.cpp
. To extract the date/time from this for easier parsing, I created a simple shell script.
cat preproc/ctags_target_for_gcc_minus_e.cpp | grep "compile_time" | head -n 1 | cut -c30-57 > compile_time
This extracts the string “MMM DD YYYY” “;” “HH:MM:SS” from the processed firmware. Then, using some some simple rust we can create a chrono::DateTime<Utc>
struct with this extracted data, that way we can compare to the date/time of the firmware running on the updating client. For the main Actix function, an OTA endpoint is required
#[actix_rt::main]
async fn main() -> io::Result<()> {
// Values used to set the listening address and port of the Actix runtime.
let port: String = String::from("80");
let addr: String = String::from("localhost");
println!("Actix-web listening on {}:{}", addr, port);
let mut server = HttpServer::new(||
App::new()
.route("/ota", web::get().to(ota))
);
server.bind(format!("{}:{}", addr, port).as_str())?.run().await
}
This is the function I wrote to handle this route
async fn ota(req: HttpRequest) -> impl Responder { //println!("OTA Request received..."); let headers: &HeaderMap = req.headers(); //dbg!(&headers); if let Some(val) = headers.get("x-esp8266-version") { // Extract esp8266 firmware version as an i32... let firmware_version = extract_firmware_from_request(val.clone().to_str().unwrap()); // If the headers contain the version number then continue parsing update... if firmware_version.timestamp() < get_latest_firmware_date().timestamp() { let mut buffer:Vec= Vec::new(); if let Ok(mut f) = std::fs::File::open(std::path::Path::new("/path/to/your/bin")) { f.read_to_end(buffer.as_mut()); } else { panic!("Not accepted."); } match headers.get("x-esp8266-sta-mac") { Some(mac) => println!("Sending firmware dated {} to esp8226 id {} running firmware dated {}", get_latest_firmware_date(), mac.clone().to_str().unwrap(), firmware_version), _ => () } HttpResponse::build(StatusCode::from_u16(200).unwrap()).body(buffer) } else { match headers.get("x-esp8266-sta-mac") { Some(mac) => println!("esp8226 id {} running latest firmware already.", mac.clone().to_str().unwrap()), _ => () } HttpResponse::NotModified().finish() } } else { // If the version header is not found, reply with 403 HttpResponse::Forbidden().finish() } } fn get_latest_firmware_date() -> chrono::DateTime { if let Ok(mut file) = std::fs::read_to_string(std::path::Path::new("/path/to/shell/script/output")) { let lines: Vec<&str> = file.lines().collect(); let line: String = lines.into_iter().map(String::from).collect(); let year: i32 = line[8..12].to_string().parse().clone().unwrap(); let month: u32 = match &line[1..4] { "Jan" => 1, "Feb" => 2, "Mar" => 3, "Apr" => 4, "May" => 5, "Jun" => 6, "Jul" => 7, "Aug" => 8, "Sep" => 9, "Oct" => 10, "Nov" => 11, "Dec" => 12, _ => {panic!("Error parsing firmware date string")} }; let day: u32 = line[5..7].parse().clone().unwrap(); let hour: u32 = line[19..21].parse().unwrap(); let minute: u32 = line[22..24].parse().unwrap(); let second: u32 = line[25..27].parse().unwrap(); Utc.ymd(year, month, day).and_hms(hour, minute, second) } else { panic!("Error reading firmware version."); } } fn extract_firmware_from_request(req_string: &str) -> chrono::DateTime { let year: i32 = req_string[7..11].parse().clone().unwrap(); let month: u32 = match &req_string[0..3] { "Jan" => 1, "Feb" => 2, "Mar" => 3, "Apr" => 4, "May" => 5, "Jun" => 6, "Jul" => 7, "Aug" => 8, "Sep" => 9, "Oct" => 10, "Nov" => 11, "Dec" => 12, _ => {panic!("Error Parsing Month")} }; let day: u32 = req_string[4..6].parse().unwrap(); let hour: u32 = req_string[12..14].parse().unwrap(); let minute: u32 = req_string[15..17].parse().unwrap(); let second: u32 = req_string[18..20].parse().unwrap(); Utc.ymd(year, month, day).and_hms(hour, minute, second) }
Its pretty rough but it works for now. There will be better code in my repository should you follow in my footsteps, and I recommend you just clone my repo if you want to use this project.
Configuring Caddy to serve everything with HTTPS
My Caddyfile is structured as follows
[host] {
proxy /ide localhost:8080 {
transparent
}
proxy /manager localhost:8090 {
transparent
}
proxy /ota locahost:8081/ota {
transparent
}
}
All the services are tunneled to the host with SSH reverse tunnels. In my setup port 8080 points to the Code-Server tunnel, 8090 points to the Express.js tunnel, and 8081 points to the Actix-Web tunnel.
Preparing the firmware
The firmware part is rather simple. Near the top, the gcc macros __DATE__
and __TIME__
can be seen, which get replaced during preprocessing with a string that is formatted as MMM DD YYYY
and HH:MM:SS
respectively. An example of this would be Apr 30 2020
and 05:33:11
. They are concatenated together with a semicolon, and inside the ESPhttpUpdate function these amalgamation is converted to a string and sent as a header to the Actix-Web server. Worth noting, the day does not get padded with a zero if its below 10.
#include <ESP8266WiFi.h>
#include <ESP8266WiFiMulti.h>
#include <ESP8266HTTPClient.h>
#include <ESP8266httpUpdate.h>
#define ENTER_BTN 14
#ifndef APSSID
#define APSSID "SSID"
#define APPSK "AP-PASSWORD"
#endif
const char compile_time [] = __DATE__ ";" __TIME__;
ESP8266WiFiMulti WiFiMulti;
void setup() {
WiFi.mode(WIFI_STA);
WiFiMulti.addAP(APSSID, APPSK);
WiFiMulti.run();
pinMode(ENTER_BTN, INPUT_PULLUP);
}
void loop() {
if(digitalRead(ENTER_BTN) == LOW) {
ota();
}
}
void ota() {
if ((WiFiMulti.run() == WL_CONNECTED)) {
t_httpUpdate_return ret = ESPhttpUpdate.update("your.ota.endpoint.here", 80, "/", String(compile_time));
switch (ret) {
case HTTP_UPDATE_FAILED:
// Log failure
break;
case HTTP_UPDATE_NO_UPDATES:
// Log no updates
break;
case HTTP_UPDATE_OK:
// Log Update Succeeded
break;
}
}
}
The results
Success! We have clients connecting to the Actix-Web server through Caddy, sending a version string, and downloading the new firmware should the Actix-Web server decide the client needs an update.

This should be enough information to get you to a functional cloud development system for your ESP8266/32’s. I’m currently not using any ESP’s for anything where I can’t physically access them, but if you have them running code inside enclosures or around your house this could be super useful. My goal is to use this with my custom sensor boards which are currently still under development.
Where to go from here…?
My goals for where to take the project is changing every day. Some features that I want to implement next are support for different firmware for different devices, which I think could be solved by something like this
ESP-ID | FIRMWARE FILE |
xx:yy:zz:ii:jj:kk | xxxxxx.bin |
xx:yy:zz:ii:jj:kk | yyyyyy.bin |
xx:yy:zz:ii:jj:kk | xxxxxx.bin |
As for authentication, I think appending some form of access token to the version string would solve this issue. Integrating all of this into a VSCode extension would be the best way to handle everything I think, given that the base of the system is code-server. Implementing HTTPS would also certainly be a good idea, I think that’ll be my next goal. Anyways, I hope this post helps you out if you’re following in my footsteps.