Blog Index

Running iroh on an ESP32

by Rüdiger Klaehn

Running iroh on embedded systems

Iroh works very well on modern hardware of various sizes, from smartphones to big multicore servers. But what if you want to use it in an embedded context?

I have a little hobby project for home automation. Naturally I thought about putting iroh on it.

Of course it would be easy to just get a Raspberry Pi and put iroh on it. But that would double the cost of the project and also not be very elegant.

So let's instead try to get iroh to work on some really cheap embedded device, an ESP32. More specifically an Espressif ESP32 WROVER chip that is the part of many cheap ESP32 dev kits.

You might think that it is impossible to get a complete QUIC stack including TLS to run on such a small device. But on the other hand, this CPU is about as powerful as the first 32 bit computer I owned, an AMD 386DX40 with 4 MiB of RAM. Back in 1992 this was considered a very powerful computer. It even ran DOOM.

Surely it should be possible to run iroh on that...

Raspberry Pi vs ESP32 size comparison

Of course the ESP32 is a very limited environment compared to even the smallest Raspberry Pi. You get around 4MiB of flash for the application binary and ~500 KiB of internal RAM. For some variants, like the one we are working with, you get some additional memory.

So it is going to be a tight fit.

Getting started

Using rust on an ESP32 is very well documented. There is an entire book for it.

We are not going to spend too much time with the main point of an ESP32, input and output via GPIO ports. We just want to set up a tiny hello world project and then turn it into an iroh hello world project.

To set up a simple hello world project, there is a project template.

This will give you a rust project that uses a proper operating system (FreeRTOS). This is required since iroh needs std and TCP/IP and WiFi support.

❯ cargo generate esp-rs/esp-idf-template cargo
⚠️   Favorite `esp-rs/esp-idf-template` not found in config, using it as a git repository: https://github.com/esp-rs/esp-idf-template.git
🤷   Project Name: esp32-blog-post
🔧   Destination: /Users/rklaehn/projects_git/esp32-blog-post ...
🔧   project-name: esp32-blog-post ...
🔧   Generating template ...
✔ 🤷   Which MCU to target? · esp32
✔ 🤷   Configure advanced template options? · false
[ 1/13]   Done: .cargo/config.toml                     
...
🔧   Initializing a fresh Git repository
✨   Done! New project created esp32-blog-post

We now have a minimal hello world project with the ESP32 tool chain set up, and can run it.

Building this for the first time will download a custom toolchain. Some variants of the ESP32 use a RISC-V architecture, which does not require a custom toolchain.

Running it will try to flash it on a connected device, so you need an ESP32 connected to your development machine via USB-C Sometimes it does not find the device: unplugging and plugging in often helps.

❯ cargo run
    Finished `dev` profile [optimized + debuginfo] target(s) in 0.29s
     Running `espflash flash --monitor target/xtensa-esp32-espidf/debug/esp32-blog-post`
[2026-03-05T11:30:52Z INFO ] Serial port: '/dev/cu.usbserial-210'
[2026-03-05T11:30:52Z INFO ] Connecting...
[2026-03-05T11:30:58Z INFO ] Using flash stub
Chip type:         esp32 (revision v3.1)
Crystal frequency: 40 MHz
Flash size:        4MB
Features:          WiFi, BT, Dual Core, 240MHz, VRef calibration in efuse, Coding Scheme None
MAC address:       00:70:07:19:c8:4c
App/part. size:    574,272/4,128,768 bytes, 13.91%
[00:00:00] [========================================]      17/17      0x1000   Skipped! (checksum matches)                                                                   [00:00:00] [========================================]       1/1       0x8000   Skipped! (checksum matches)                                                                   [00:00:29] [========================================]     268/268     0x10000  Verifying... OK!                                                                              [2026-03-05T11:31:29Z INFO ] Flashing has completed!
Commands:
    CTRL+R    Reset chip
    CTRL+C    Exit

ets Jul 29 2019 12:21:46

rst:0x1 (POWERON_RESET),boot:0x1b (SPI_FAST_FLASH_BOOT)
configsip: 0, SPIWP:0xee
clk_drv:0x00,q_drv:0x00,d_drv:0x00,cs0_drv:0x00,hd_drv:0x00,wp_drv:0x00
mode:DIO, clock div:2
load:0x3fff0030,len:6384
load:0x40078000,len:15916
load:0x40080400,len:3920
entry 0x40080644
I (27) boot: ESP-IDF v5.5.1-838-gd66ebb86d2e 2nd stage bootloader
I (27) boot: compile time Nov 26 2025 10:51:37
I (28) boot: Multicore bootloader
I (30) boot: chip revision: v3.1
I (33) boot.esp32: SPI Speed      : 40MHz
I (37) boot.esp32: SPI Mode       : DIO
I (40) boot.esp32: SPI Flash Size : 4MB
I (44) boot: Enabling RNG early entropy source...
I (48) boot: Partition Table:
I (51) boot: ## Label            Usage          Type ST Offset   Length
I (57) boot:  0 nvs              WiFi data        01 02 00009000 00006000
I (64) boot:  1 phy_init         RF data          01 01 0000f000 00001000
I (70) boot:  2 factory          factory app      00 00 00010000 003f0000
I (77) boot: End of partition table
I (80) esp_image: segment 0: paddr=00010020 vaddr=3f400020 size=2b068h (176232) map
I (148) esp_image: segment 1: paddr=0003b090 vaddr=3ffb0000 size=0279ch ( 10140) load
I (152) esp_image: segment 2: paddr=0003d834 vaddr=40080000 size=027e4h ( 10212) load
I (156) esp_image: segment 3: paddr=00040020 vaddr=400d0020 size=531f8h (340472) map
I (276) esp_image: segment 4: paddr=00093220 vaddr=400827e4 size=090f4h ( 37108) load
I (296) boot: Loaded app from partition at offset 0x10000
I (296) boot: Disabling RNG early entropy source...
I (306) cpu_start: Multicore app
I (315) cpu_start: Pro cpu start user code
I (315) cpu_start: cpu freq: 160000000 Hz
I (315) app_init: Application information:
I (318) app_init: Project name:     libespidf
I (323) app_init: App version:      1
I (327) app_init: Compile time:     Mar  5 2026 13:29:50
I (333) app_init: ELF file SHA256:  000000000...
I (338) app_init: ESP-IDF:          v5.3.3
I (343) efuse_init: Min chip rev:     v0.0
I (348) efuse_init: Max chip rev:     v3.99 
I (353) efuse_init: Chip rev:         v3.1
I (358) heap_init: Initializing. RAM available for dynamic allocation:
I (365) heap_init: At 3FFAE6E0 len 00001920 (6 KiB): DRAM
I (371) heap_init: At 3FFB30D0 len 0002CF30 (179 KiB): DRAM
I (377) heap_init: At 3FFE0440 len 00003AE0 (14 KiB): D/IRAM
I (384) heap_init: At 3FFE4350 len 0001BCB0 (111 KiB): D/IRAM
I (390) heap_init: At 4008B8D8 len 00014728 (81 KiB): IRAM
I (398) spi_flash: detected chip: generic
I (401) spi_flash: flash io: dio
W (405) pcnt(legacy): legacy driver is deprecated, please migrate to `driver/pulse_cnt.h`
W (414) i2c: This driver is an old driver, please migrate your application code to adapt `driver/i2c_master.h`
W (424) timer_group: legacy driver is deprecated, please migrate to `driver/gptimer.h`
I (434) main_task: Started on CPU0
I (444) main_task: Calling app_main()
I (444) esp32_blog_post: Hello, world!
I (444) main_task: Returned from app_main()

If we just do the naive thing and cargo add iroh, we get a lot of compile errors. It turns out that while the ESP32 platform espidf is an unix, it does not support some advanced features like cmsg. Several symbols in the ESP32 specific libc are just not there. Also, many 32 bit architectures used for embedded devices don't have 64 bit atomic support.

We will make sure to support ESP32 from iroh main in the future, but for now you will have to use a special branch of iroh.

Now let's do a minimal iroh endpoint setup and see what happens. An ESP32 is an incredibly constrained environment. Every thread requires its own stack, so we will manually set up a single threaded tokio runtime instead of using async fn main().

There is some setup needed before the runtime can even start, so using tokio::main is not an option even if you configure a single threaded runtime.

Inside the runtime, we will just create an endpoint.

    let rt = tokio::runtime::Builder::new_current_thread()
        .enable_time()
        .build()
        .expect("Failed to create tokio runtime");

    rt.block_on(async {
        let endpoint = iroh::Endpoint::builder()
            .bind()
            .await
            .expect("unable to bind endpoint");
        info!("Hello, iroh!");
    });

Missing symbols

When we compile this, we get a linker error. ESP32 does not provide a symbol that one of the iroh dependencies needs.

undefined reference to `gethostname'

We don't care that much about the hostname, so we can just define a noop implementation:

// ESP-IDF doesn't provide gethostname, but resolv_conf (via hickory-resolver) references it.
#[no_mangle]
unsafe extern "C" fn gethostname(name: *mut core::ffi::c_char, len: usize) -> core::ffi::c_int {
    if len > 0 && !name.is_null() {
        unsafe { *name = 0; }
    }
    0
}

Once we do that, compilation proceeds a bit further. We get to linking. But the troubles don't stop.

Binary size issues

region `iram0_2_seg' overflowed by 168642 bytes

Our binary is too large even in release mode to fit on the ESP32. But not by much. So we can just enable link time optimizations for release builds to get below the limit in Cargo.toml. While we are at it, we also optimize for size.

Unfortunately this will lead to even longer build times than normal release builds. But there is nothing we can do about it, and in any case flashing is even slower.

[profile.release]
opt-level = "z"
lto = true
codegen-units = 1

After enabling lto, we finally get to flash the program on the ESP32, which takes a while. We are almost at the size limit (88.53%).

Once flashing is complete we are greeted with a runtime error:

> cargo run --release

[2026-03-05T14:14:04Z INFO ] Serial port: '/dev/cu.usbserial-210'
[2026-03-05T14:14:04Z INFO ] Connecting...
[2026-03-05T14:14:10Z INFO ] Using flash stub
Chip type:         esp32 (revision v3.1)
Crystal frequency: 40 MHz
Flash size:        4MB
Features:          WiFi, BT, Dual Core, 240MHz, VRef calibration in efuse, Coding Scheme None
MAC address:       00:70:07:19:c8:4c
App/part. size:    3,655,104/4,128,768 bytes, 88.53%
[00:00:00] [========================================]      17/17      0x1000   Skipped! (checksum matches)                                                                                                                                          [00:00:00] [========================================]       1/1       0x8000   Skipped! (checksum matches)                                                                                                                                          [00:03:44] [========================================]    2051/2051    0x10000  Verifying... OK!                                                                                                                                                     [2026-03-05T14:17:56Z INFO ] Flashing has completed!

...
thread 'main' (1) panicked at src/main.rs:30:10:
Failed to create tokio runtime: Os { code: 13, kind: PermissionDenied, message: "Permission denied" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

abort() was called at PC 0x40350e8e on core 0
0x40350e8e - std::sys::pal::unix::abort_internal
    at ??:??

What is it this time? Asking your favourite coding LLM reveals that we need to register an eventfd VFS, which is used by the tokio runtime.

// Register eventfd VFS — needed by mio's poll implementation which powers tokio I/O
let eventfd_config = esp_idf_svc::sys::esp_vfs_eventfd_config_t {
    max_fds: 5,
    ..Default::default()
};
unsafe { esp_idf_svc::sys::esp_vfs_eventfd_register(&eventfd_config) };

Memory

After flashing this change, we get a little bit further. This time we have a problem with malloc failing. Guru Meditation Error? Somebody likes Amiga it seems...

Guru Meditation Error: Core  0 panic'ed (LoadProhibited). Exception was unhandled.

Core  0 register dump:
PC      : 0x400897b9  PS      : 0x00060733  A0      : 0x800892c0  A1      : 0x3ffb6410  
0x400897b9 - tlsf_malloc
    at ??:??

The default configuration uses only the internal memory of the ESP32. But that is very little. You can see a list of memory ranges during startup:

I (1413) heap_init: Initializing. RAM available for dynamic allocation:
I (1420) heap_init: At 3FFAE6E0 len 00001920 (6 KiB): DRAM
I (1426) heap_init: At 3FFB8178 len 00027E88 (159 KiB): DRAM
I (1432) heap_init: At 3FFE0440 len 00003AE0 (14 KiB): D/IRAM
I (1439) heap_init: At 3FFE4350 len 0001BCB0 (111 KiB): D/IRAM
I (1445) heap_init: At 4008B934 len 000146CC (81 KiB): IRAM

While it would be a fun challenge to try to get iroh to work with only internal memory, for now we will just use the external memory. While we're at it we will also increase the stack size.

sdkconfig.defaults:

CONFIG_ESP_MAIN_TASK_STACK_SIZE=98304

# Enable external PSRAM
CONFIG_SPIRAM=y
CONFIG_SPIRAM_USE_MALLOC=y
CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=0
CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=0

We have configured all dynamic allocation to use the external SPIRAM, and this has allowed us to increase the stack size generously.

Enabling external memory makes our memory problems go away for now:

I (2368) esp_psram: Adding pool of 4096K of PSRAM memory to heap allocator

Crypto provider

After all these rather tedious problems, we finally get to something interesting. The tokio runtime starts, and even the iroh endpoint init runs.

Now we get the following error:

thread 'main' (1) panicked at /Users/rklaehn/.cargo/git/checkouts/iroh-a3a56ab68883433d/e96d7b4/iroh/src/tls/resolver.rs:33:14:
no default crypto provider installed
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

abort() was called at PC 0x40350e7a on core 0
0x40350e7a - std::sys::pal::unix::abort_internal
    at ??:??

Published iroh by default uses ring as the crypto provider. But ring is a C dependency with lots of platform-specific assembly code, which does not work on ESP32.

There is an alternative crypto provider aws-lc-rs, but it has the same problem — it wraps AWS-LC, a C library with platform-specific assembly that does not support the Xtensa architecture.

So what do we do? Rustls provides pluggable crypto providers, and the latest version of iroh makes sure to always use the configured provider.

So now we would have two options. Implement a rust only crypto provider, or implement a crypto provider that uses the ESP32 built in hardware acceleration.

The latter would be the right thing to do for a production system, but for now we are going to just do a pure rust version.

Since we are already very close to the binary size limit, we will only implement the absolute minimum number of cryptographic primitives that we need for iroh to work, and even take some shortcuts.

There is a crate rustls-rustcrypto that provides glue between rustls and rustcrypto. Due to binary size issues we had to fork it and feature gate the various implemented algorithms. For iroh itself we only need TLS13_AES_128_GCM_SHA256 and X25519.

Now all that is needed is to add some glue code to make the crypto providers work with QUIC, and configure the global crypto provider.

    // Install pure-Rust crypto provider with QUIC support
    quic_crypto_provider::provider()
        .install_default()
        .expect("Failed to install rustls crypto provider");

WiFi

After all this ceremony, we get a bit further.

assert failed: tcpip_send_msg_wait_sem /IDF/components/lwip/lwip/src/api/tcpip.c:449 (Invalid mbox)

So we have the problem that TCP/IP does not work. But how is it supposed to work anyway? The ESP32 does not have a wired network card. What it does have is WiFi.

We need to set up WiFi, and also connect to an access point. This is an embedded project, so we are going to include the WiFi credentials into the binary. We don't want to hardcode them in the repo, so we configure them using an environment variable WIFI_CONFIG that is set at compile time.

While we are at it, we will also make sure the system time is set so certificates we use have somewhat correct times. ESP32 has built in SNTP support that we just need to enable.

Normal iroh stuff

At this point the endpoint setup works. Now all that remains to be done is to add a simple echo protocol and an accept loop.

Also we will use a compile time environment variable IROH_SECRET to allow configuring the endpoint id.

We also have the endpoint print a short and long ticket on the debug output, so we can try dialing it either using an ip address or address lookup.

I (7567) esp32_blog_post: Iroh endpoint bound
I (7567) esp32_blog_post:   Listening on: 192.168.0.186:62781
I (7567) esp32_blog_post:   Endpoint ID: 88096ffd6d3048ad7c1050645ae5b8fbf731963d892abe282736a7fbabd8f212
I (7587) esp32_blog_post:   Short ticket: endpointaceas375nuyerll4cbigiwxfxd57ommwhwesvprie43kp65l3dzbeaa
I (7597) esp32_blog_post:   Long ticket:  endpointaceas375nuyerll4cbigiwxfxd57ommwhwesvprie43kp65l3dzbeaibadakqaf2xxvag
I (7607) esp32_blog_post: Router started, accepting connections

To test this, we have a client in the repo for this blog post that uses published iroh from crates.io.

esp32-blog-post/client on  main is 📦 v0.1.0 via 🦀 v1.93.1 took 12s 
❯ cargo run endpointaceas375nuyerll4cbigiwxfxd57ommwhwesvprie43kp65l3dzbeaa
Connecting to ESP32...
Connected!
Sent: Hello from iroh (crates.io)!
Received: Hello from iroh (crates.io)!
Echo OK — crates.io iroh <-> ESP32!

And that's it! We got a complete iroh endpoint running on a tiny device, that we can talk to from any iroh endpoint.

Next steps

For my home automation project, the next step is to finally wire up some sensors and actuators. I am using two DHT22 temperature and humidity sensors for the sensor part, a LED display, and a solid state relay to switch a 220V load.

Sensor project

This is where the ESP32 shines: you can wire up all kinds of sensors and actuators in minutes to the numerous GPIO ports. For example you can drive most servos directly from the ESP32, which has built in PWM support.

And ESP32 boards are cheap and small enough that you can fit a complete project in a tiny box.

Project in a box

For the iroh project, this experiment revealed a number of places where we can reduce dependencies, several of which already made it to iroh main. We will make sure that iroh main compiles on 32 bit embedded architectures, and that expensive dependencies are optional.

In this project we succeeded in running iroh on an ESP32 with just 4 MiB of flash and 4 MiB of SPIRAM. But more powerful variants are also available and cheap, if you need some heavy non-iroh dependencies or memory for e.g. image processing.

Trying it out

For a hobby project, I would suggest getting one of the more powerful variants. Playing with sensors is fun, waiting for lto compilation for every build is not.

This example project uses a patched version of iroh and its dependencies. If you are interested in putting this into production on an IoT device for a commercial purposes, please reach out for more information by either booking a meeting or emailing us at hello@n0.computer.

Iroh is a dial-any-device networking library that just works. Compose from an ecosystem of ready-made protocols to get the features you need, or go fully custom on a clean abstraction over dumb pipes. Iroh is open source, and already running in production on hundreds of thousands of devices.
To get started, take a look at our docs, dive directly into the code, or chat with us in our discord channel.