NodeMCU from Scratch: Building and Flashing the Lua Firmware Yourself
Technical Context: The ESP8266
The ESP8266 is an inexpensive WiFi-enabled microcontroller from Espressif that has been a mainstay of hobbyist, IoT, and prototyping projects for many years.
Technically, it is a 32-bit microcontroller with an integrated WiFi stack, external flash storage, and sufficient processing power for typical tasks such as sensing, actuator control, or simple network protocols (e.g. MQTT or HTTP).
One important point is that the ESP8266 is not tied to a particular runtime environment. Depending on the use case, different firmwares can be used, including AT firmware, MicroPython, or NodeMCU.
In this context, the ESP8266 provides the hardware foundation for the following build and flashing process.
Starting Point
The ESP8266 boards used here originally came with NodeMCU preinstalled. Without giving it much thought, I later flashed them with MicroPython, mainly for pragmatic reasons. That was sufficient for quick experiments and worked reliably.
Only after gaining some distance did NodeMCU come back into focus. Looking at it again made it clear that the firmware is fully open source and can actually be built from source.
This led less to a specific project than to a straightforward question:
How difficult is it to build such firmware yourself β and what do you gain by doing so?
Besides providing better control over the build and modules, Lua also plays a role. I now find the language pleasantly direct and well suited to embedded contexts, without this being intended as a fundamental comparison with Python.
Motivation
NodeMCU is often used as a ready-made environment: flash the image, write Lua scripts, and you are done.
That is perfectly sufficient for many scenarios. I was deliberately interested in the step before that: the path from source code to running firmware on real hardware.
Not out of necessity, but out of interest:
- Which modules are actually included in the image?
- How is the firmware binary created?
- How reproducible is the build process?
Especially with embedded systems, this understanding provides a solid foundation for later experiments and troubleshooting.
Preparation: Toolchain and Source Code
$ sudo apt install python-is-python3 esptool cu
$ mkdir nodemcu
$ cd nodemcu
$ git clone https://github.com/nodemcu/nodemcu-firmware.git
$ cd nodemcu-firmware
$ git submodule update --init --recursive
Configuration
NodeMCU has a modular architecture. The modules determine which functionality will later be available in the Lua REPL and in scripts. The following file can be used to specify which Lua modules are included in the image:
$ nano app/include/user_modules.h
Overview: NodeMCU Modules (user_modules.h)
| Module | Default | Category | Purpose / typical use |
|---|---|---|---|
| adc | β | Hardware | Analog-to-digital converter |
| bit | β | Core | Bit operations |
| dht | β | Sensor | DHT11 / DHT22 temperature & humidity |
| file | β | Core | Access to flash filesystem |
| gpio | β | Hardware | Digital inputs / outputs |
| i2c | β | Bus | IΒ²C communication |
| mqtt | β | Network | MQTT client |
| net | β | Network | TCP / UDP sockets |
| node | β | System | System functions (restart, heap, etc.) |
| ow | β | Bus | OneWire (e.g. DS18B20) |
| spi | β | Bus | SPI communication |
| tmr | β | Timing | Timers, delays |
| uart | β | Communication | Serial interface |
| wifi | β | Network | WiFi configuration & status |
| ads1115 | β | Sensor | External ADC |
| adxl345 | β | Sensor | Accelerometer |
| bme280 | β | Sensor | Temperature / humidity / pressure |
| bmp085 | β | Sensor | Barometric pressure sensor |
| http | β | Network | HTTP client |
| mdns | β | Network | mDNS / Zeroconf |
| pwm | β | Hardware | Pulse-width modulation |
| pwm2 | β | Hardware | Extended PWM |
| sntp | β | Time | Network time synchronization |
| tls | β | Security | TLS / SSL |
| websocket | β | Network | WebSocket client |
| ws2812 | β | Hardware | RGB LEDs (NeoPixel) |
| u8g2 | β | Display | Graphics displays |
| enduser_setup | β | Provisioning | WiFi setup via captive portal |
Run the Build
The actual build is then uneventful:
$ make
After a successful build, NodeMCU places the generated firmware images in the
nodemcu-firmware/bin/ directory.
Typically, it contains at least two files:
0x00000.binβ bootloader and initial code0x10000.binβ the actual NodeMCU firmware
These images are later written to the corresponding flash offsets of the ESP8266.
Check the Hardware and Prepare for Flashing
$ sudo esptool --port /dev/ttyUSB0 flash_id
$ sudo esptool --port /dev/ttyUSB0 erase_flash
Flash the Firmware
$ sudo esptool --port /dev/ttyUSB0 write_flash 0x0 0x00000.bin 0x10000 0x10000.bin
Serial Console
A serial terminal can be used to communicate with the firmware. Depending on the tool and terminal settings, input and output may behave with varying degrees of reliability.
For simple tests and interactive work, picocom has proven in practice to be
a robust and straightforward solution.
$ sudo apt install picocom
$ sudo usermod -a -G dialout mpeterma
$ picocom -b 115200 /dev/ttyUSB0
...
NodeMCU 3.0.0.0
modules: adc,bit,dht,file,gpio,i2c,mqtt,net,node,ow,spi,tmr,uart,wifi
powered by Lua 5.1.4
cannot open init.lua:
>
After connecting through the serial console, you arrive at a REPL (ReadβEvalβPrint Loop) provided by the NodeMCU firmware.
In this mode, Lua commands can be entered directly and executed immediately. This is well suited to initial tests, debugging, and gradually trying out hardware functions.
A simple example is switching a GPIO pin on and off, with an LED connected to it, for example:
Changes take effect immediately, without having to flash the firmware again.
> gpio.mode(4, gpio.OUTPUT) -- GPIO4 als Ausgang konfigurieren
> gpio.write(4, gpio.LOW) -- LED einschalten
> gpio.write(4, gpio.HIGH) -- LED ausschalten
A First Program: init.lua
As a first step, no external tool is used. Instead, the program is created directly in the REPL of the NodeMCU firmware.
a = file.open("init.lua", "w")
a.write("print('Hello World')")
a.close()
node.restart()
The following happens:
- Lua commands are executed directly on the device through the REPL.
- file.open(…) creates a file named init.lua in the ESP8266’s flash storage.
- a.write(…) writes the program code directly to this file.
- After the file is closed, the device is restarted.
- On restart, NodeMCU automatically loads the init.lua file and executes its contents:
Hello World
init.lua is NodeMCU’s standard entry point.
If this file exists in flash, it is loaded and executed automatically at startup.
For simple experiments, the entire program can be placed directly in this
file.
For more complex projects, it is common to split the logic across multiple files
or modules and load them from init.lua.
Upload Using a Tool
Creating files directly through the REPL is practical for initial tests, but scales only to a limited extent. As soon as programs become larger or consist of multiple files, typing code through the serial console quickly becomes unwieldy.
For real projects, it is therefore much more sensible to edit Lua files locally and then transfer them to the device in one batch.
An upload tool enables:
- Editing code with an editor, version control, and syntax highlighting
- Transferring multiple files in a single step
- Reproducible updates without manual REPL input
The actual firmware image remains unchanged; only files in the flash filesystem are updated.
On my Debian system, nodemcu-uploader is not available in the package repositories.
Using a Python virtual environment (venv) ensures
that the tool is installed in isolation without affecting the system Python or other packages.
sudo apt install python3-pip python3-venv
mkdir ~/Apps/nodemcu-uploader
cd ~/Apps/nodemcu-uploader
python3 -m venv venv
source venv/bin/activate
pip install --upgrade pip
pip install nodemcu-uploader
Example: Blinking an LED on GPIO4
As a somewhat more realistic example, here is a small Lua program that periodically switches an LED on GPIO4 (D2 on many NodeMCU boards) on and off.
-- init.lua
local pin = 4
gpio.mode(pin, gpio.OUTPUT)
while true do
gpio.write(pin, gpio.HIGH)
tmr.delay(500000) -- 500 ms
gpio.write(pin, gpio.LOW)
tmr.delay(500000) -- 500 ms
end
The file is then transferred to the device:
nodemcu-uploader --port /dev/ttyUSB0 --baud 115200 upload init.lua
After restarting the ESP8266, the program runs automatically and the LED starts blinking.
Infinite loops using tmr.delay() block the Lua interpreter and are suitable only for simple tests.
In production applications, timer callbacks (tmr.create()) or event-based approaches are used instead.
Conclusion
NodeMCU can be built from source, flashed, and run on real hardware without major obstacles. The entire path, from cloning the repository through the build to interactive work in the REPL, is transparent and easy to follow.
The real value lies less in the result than in the process: you understand which modules are part of the firmware, how the image is created, and how logic and firmware can be cleanly separated. Especially with embedded systems, this knowledge provides a solid foundation for later experiments, extensions, or troubleshooting.
In this context, Lua proves to be a pleasantly direct language, particularly in combination with the REPL and NodeMCU’s simple file structure. A single init.lua is enough for small experiments, while structure and tooling can easily be expanded for larger projects.
NodeMCU is therefore not merely a βready-made runtime environmentβ, but a manageable system, suitable both for quick tests and as a foundation for further ideas and projects.