Tailscale-Compatible VPN Client for ESP32
MicroLink is a complete, production-ready implementation of the Tailscale protocol for ESP32 microcontrollers. It enables your ESP32 devices to join a Tailscale network and communicate securely with any other device on your tailnet.
-
Full Tailscale Protocol Support
- ts2021 coordination protocol
- WireGuard encryption (ChaCha20-Poly1305)
- DISCO path discovery (PING/PONG/CALL_ME_MAYBE)
- DERP relay for NAT traversal
- STUN for public IP discovery
- Bidirectional UDP data transfer (NEW in v1.2.0)
-
Production Ready
- Memory optimized (~100KB SRAM)
- Tested with ESP32-S3
- Works with
tailscale ping - Bidirectional VPN data transfer (ESP32 ↔ PC)
-
Easy Integration
- Simple C API
- ESP-IDF component format
- Kconfig configuration
- Callback-based UDP reception for low-latency handling
- ESP-IDF v5.0 or later (tested with v5.3)
- ESP32-S3 with PSRAM (recommended) or ESP32 with sufficient RAM
- WiFi connectivity
- Tailscale account with auth key
For production deployments, ESP32-S3 with PSRAM is recommended. The 8MB external PSRAM provides ample headroom for MicroLink's buffers plus your application logic.
Tested hardware:
- ESP32-S3 with 8MB PSRAM (recommended)
- Waveshare ESP32-S3-Touch-AMOLED-2.06
- Seeed Studio XIAO ESP32S3 Sense
MicroLink also runs on standard ESP32 boards without PSRAM, with reduced buffer sizes. This is suitable for simple IoT applications where memory headroom is less critical.
Tested hardware:
- HiLetgo ESP-32S
- ESP32-WROOM-32D
- ESP32-DevKitC
Memory profile on ESP32 (no PSRAM):
| Stage | Free Heap | Notes |
|---|---|---|
| Boot | ~267KB | Total available SRAM |
| After WiFi | ~225KB | WiFi stack overhead |
| After MicroLink | ~193KB | Core initialization |
| Running (connected) | ~140KB | DERP + coordination active |
| Minimum observed | ~9KB | Low-water mark during operation |
To use MicroLink on ESP32 without PSRAM, configure reduced buffer sizes:
# sdkconfig.defaults for ESP32 without PSRAM
CONFIG_MICROLINK_COORD_BUFFER_SIZE_KB=24
CONFIG_MICROLINK_MAX_PEERS=8See examples/ping_pong_esp32/ for a complete working example.
Copy the microlink folder to your project's components/ directory, or add it as a git submodule:
cd your_project/components
git clone https://github.com/CamM2325/microlink.gitAdd these settings to your sdkconfig.defaults file:
# PSRAM Configuration (required for ESP32-S3 with PSRAM)
CONFIG_SPIRAM=y
CONFIG_SPIRAM_MODE_OCT=y
CONFIG_SPIRAM_TYPE_AUTO=y
CONFIG_SPIRAM_SPEED_80M=y
CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY=y
CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=4096
CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=32768
# Partition table (app needs ~1MB+)
CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y
# TLS/HTTPS (required for DERP)
CONFIG_ESP_TLS_USING_MBEDTLS=y
CONFIG_MBEDTLS_SSL_PROTO_TLS1_2=y
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN=y
# Networking
CONFIG_LWIP_IPV4=y
CONFIG_LWIP_IP4_FRAG=y
CONFIG_LWIP_IP4_REASSEMBLY=y
# Stack size
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192Then run idf.py menuconfig to customize MicroLink options:
Component config → MicroLink Configuration
├── Enable MicroLink Tailscale VPN [*]
├── Maximum number of peers (16)
├── Maximum endpoints per peer (8)
├── Enable DERP relay support [*]
├── Enable DISCO path discovery [*]
├── Enable STUN NAT discovery [*]
├── Heartbeat interval (ms) (25000)
├── Default DERP region ID (9) ← Change this for different regions
├── Enable dynamic DERP region discovery [ ] ← Enable for custom derpMap setups
└── Enable debug logging [ ]
Important: If your tailnet uses a custom derpMap configuration, see DERP Server Configuration below.
#include "microlink.h"
void app_main(void) {
// Initialize WiFi first...
// Configure MicroLink
microlink_config_t config;
microlink_get_default_config(&config);
config.auth_key = "tskey-auth-xxxxx"; // Your Tailscale auth key
config.device_name = "esp32-device";
config.enable_derp = true;
config.enable_disco = true;
// Initialize
microlink_t *ml = microlink_init(&config);
if (!ml) {
ESP_LOGE(TAG, "Failed to initialize MicroLink");
return;
}
// Connect to Tailscale
microlink_connect(ml);
// Main loop
while (1) {
microlink_update(ml);
if (microlink_is_connected(ml)) {
char ip_str[16];
ESP_LOGI(TAG, "Connected! VPN IP: %s",
microlink_vpn_ip_to_str(microlink_get_vpn_ip(ml), ip_str));
}
vTaskDelay(pdMS_TO_TICKS(100));
}
}// Get default configuration
void microlink_get_default_config(microlink_config_t *config);
// Initialize MicroLink
microlink_t *microlink_init(const microlink_config_t *config);
// Deinitialize
void microlink_deinit(microlink_t *ml);// Connect to Tailscale network
esp_err_t microlink_connect(microlink_t *ml);
// Disconnect
esp_err_t microlink_disconnect(microlink_t *ml);
// Update state machine (call regularly)
esp_err_t microlink_update(microlink_t *ml);
// Check connection status
bool microlink_is_connected(const microlink_t *ml);
microlink_state_t microlink_get_state(const microlink_t *ml);// Create UDP socket (port 0 = ephemeral, or specify port to listen)
microlink_udp_socket_t *sock = microlink_udp_create(ml, port);
// Send UDP data to peer
esp_err_t err = microlink_udp_send(sock, dest_vpn_ip, dest_port, data, len);
// Receive UDP data (with timeout in ms, 0 = non-blocking)
esp_err_t err = microlink_udp_recv(sock, &src_ip, &src_port, buffer, &len, timeout_ms);
// Register callback for incoming packets (low-latency option)
void my_callback(microlink_udp_socket_t *sock, uint32_t src_ip, uint16_t src_port,
const uint8_t *data, size_t len, void *user_data);
microlink_udp_set_rx_callback(sock, my_callback, user_data);
// Close socket
microlink_udp_close(sock);
// Parse IP string to uint32_t
uint32_t ip = microlink_parse_ip("100.64.0.1");// Send data to peer
esp_err_t microlink_send(microlink_t *ml, uint32_t dest_vpn_ip,
const uint8_t *data, size_t len);
// Receive data from peer
esp_err_t microlink_receive(microlink_t *ml, uint32_t *src_vpn_ip,
uint8_t *buffer, size_t *len);// Get our VPN IP
uint32_t microlink_get_vpn_ip(const microlink_t *ml);
// Get peer list
esp_err_t microlink_get_peers(const microlink_t *ml,
const microlink_peer_t **peers,
uint8_t *count);
// Get statistics
esp_err_t microlink_get_stats(const microlink_t *ml, microlink_stats_t *stats);
// Get peer latency
uint32_t microlink_get_peer_latency(const microlink_t *ml, uint32_t peer_vpn_ip);| Option | Default | Description |
|---|---|---|
auth_key |
Required | Tailscale auth key |
device_name |
Required | Device hostname |
enable_derp |
true |
Enable DERP relay |
enable_disco |
true |
Enable path discovery |
enable_stun |
true |
Enable STUN NAT discovery |
max_peers |
16 |
Maximum peer count |
| Option | Default | Description |
|---|---|---|
MICROLINK_MAX_PEERS |
16 |
Maximum peers to track (reduce to 8 for non-PSRAM) |
MICROLINK_COORD_BUFFER_SIZE_KB |
64 |
Coordination buffer size (use 24 for non-PSRAM) |
MICROLINK_DERP_REGION |
9 (Dallas) |
Default DERP region ID |
MICROLINK_DERP_DYNAMIC_DISCOVERY |
n |
Enable dynamic DERP region discovery |
MICROLINK_HEARTBEAT_INTERVAL_MS |
25000 |
Heartbeat interval |
By default, MicroLink uses hardcoded DERP servers (Dallas primary, NYC fallback). This is ideal for:
- Self-hosted DERP: Point to your own DERP server
- Deterministic selection: Always use a specific region
- Standard Tailscale setups: Works out of the box
If your tailnet has a custom derpMap configuration that disables certain regions, enable dynamic discovery:
idf.py menuconfig
# Navigate to: Component config → MicroLink Configuration
# Enable: "Enable dynamic DERP region discovery"When enabled, MicroLink will:
- Parse the
DERPMapfrom the Tailscale MapResponse (supports up to 32 regions) - Prioritize connecting to the preferred region configured in Kconfig (
MICROLINK_DERP_REGION) - Automatically fall back to other discovered regions if the preferred region is unavailable
- Ensure the ESP32 connects to the same DERP region it advertises as
PreferredDERP
This solves issues where users have custom derpMap configurations that null out certain region IDs.
Important: The ESP32 must connect to the same DERP region it advertises. Tailscale's DERP mesh does support cross-region routing, but peers send packets to whichever DERP region you advertise as your PreferredDERP. If there's a mismatch, DISCO PING/PONG packets won't reach your device.
| Component | SRAM | PSRAM |
|---|---|---|
| Core | ~50KB | - |
| Per Peer | ~200B | - |
| Coord Buffer | - | 64KB |
| Total | ~50KB | 64KB |
Leaves ~400KB+ SRAM free for your application.
| Component | SRAM |
|---|---|
| WiFi Stack | ~42KB |
| MicroLink Core | ~50KB |
| Coord Buffer | 24KB |
| DERP/TLS | ~20KB |
| Per Peer (×8) | ~1.6KB |
| Total | ~138KB |
Leaves ~9-15KB headroom. Suitable for:
- Simple sensor reporting (temperature, humidity, GPIO states)
- Remote relay/switch control
- Status monitoring and heartbeats
- Small data payloads (<1KB per message)
Not recommended for:
- Display drivers (require significant RAM)
- Audio/video processing
- Large data buffers or file transfers
- Running alongside other memory-heavy components
See the examples/ directory:
basic_connect/- Minimal connection exampleping_pong/- Respond totailscale pingwith latency monitoring (ESP32-S3)ping_pong_esp32/- Memory-optimized version for ESP32 without PSRAMsensor_node/- Practical IoT example: send sensor data over VPNudp_netcat_example/- Bidirectional UDP communication (NEW in v1.2.0)- Send/receive UDP over Tailscale VPN
- Equivalent to Linux
netcat -u - Echo mode for latency testing
- See examples/udp_netcat_example/README.md
After flashing, test with:
# From any device on your tailnet
tailscale ping esp32-deviceYou should see responses like:
pong from esp32-device (100.x.x.x) via DERP(dfw) in 150ms
- Check auth key is valid and not expired
- Ensure WiFi is connected
- Check coordination server connection in logs
- Verify DISCO is enabled
- Check DERP connection in logs
- Look for "PONG sent" in logs
- If using custom derpMap: Run
tailscale netcheck --verboseto verify the configured DERP region is available. ChangeMICROLINK_DERP_REGIONin menuconfig or enable dynamic discovery.
- This is normal for DERP relay (100-300ms)
- Direct connections are faster but require UDP hole-punching
- Ensure PSRAM is enabled in sdkconfig (see Configuration section)
- Check that
CONFIG_SPIRAM=yis set - Verify your board has PSRAM (most ESP32-S3 dev boards do)
- This can happen when dynamic discovery is enabled but the ESP32 connects to a different region than it advertises
- Check logs for "DERP: Connecting to region X" and ensure it matches your configured
MICROLINK_DERP_REGION - The ESP32 must physically connect to the DERP region it advertises as
PreferredDERP, otherwise peers will send packets to the wrong relay
- Add
CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=yto sdkconfig.defaults - Clean build:
rm -rf build sdkconfig && idf.py build
If your tailnet has a custom derpMap configuration that disables certain DERP regions:
- Run
tailscale netcheck --verboseto see available regions - Either change
MICROLINK_DERP_REGIONin menuconfig to an available region, OR - Enable
MICROLINK_DERP_DYNAMIC_DISCOVERYin menuconfig to auto-detect available regions
MIT License - see LICENSE
- Tailscale for the protocol specification
- Headscale for open-source coordination server insights
- WireGuard for the cryptographic foundation
- lwIP for the TCP/IP stack
- wireguard-lwip for WireGuard-lwIP integration
This is an independent implementation created for educational and interoperability purposes. It is not affiliated with or endorsed by Tailscale Inc. Use at your own risk.