Hosting a website on a $1 microcontroller
I once spent an afternoon reading a blog post that was served from a Raspberry Pi tucked behind someone’s router in Auckland. The page took four seconds to load. I felt like I was on a phone call with a real person instead of fetching a document from a CDN edge node 20 miles away.
This is slightly more extreme.
Maurycy got a $1 chip to serve a website. Not a Pi, not an ESP32, not a “microcontroller with WiFi.” An AVR64DD32 - a single 8-bit core, 8 kB of RAM, 64 kB of flash, 24 MHz. The chip has no peripheral that can talk to Ethernet. It doesn’t matter. He shipped it anyway.
The hardware
The AVR64DD32 costs about a dollar. Its spec sheet, for a chip in this class, is generous:
| Resource | Amount |
|---|---|
| CPU | 1× 8-bit AVR @ 24 MHz |
| RAM | 8 kB |
| Flash | 64 kB |
| EEPROM | 256 bytes |
| Voltage range | 1.8 – 5.5 V |
| Cost | ~$1 |
The catch: all of its IO pins max out at a 12 MHz clock. Ethernet’s slowest standard, 10BASE-T, runs at 10 Mbps - but uses Manchester encoding, which doubles the actual signal rate to 20 Mbps effective. The AVR cannot produce that signal.
The normal solution is to buy a dedicated Ethernet IC from DigiKey and wait weeks for shipping. Maurycy did not want to wait weeks.
The networking hack: SLIP
SLIP - Serial Line Internet Protocol, RFC 1055 (1988) - is the oldest and simplest way to carry IP packets over a serial line:
Before sending a packet:
- Wrap it in 0xC0 bytes.
- If the packet contains 0xC0, replace with 0xDB 0xDC.
- If the packet contains 0xDB, replace with 0xDB 0xDD.
That’s the entire protocol. Three rules. No headers, no length fields, no framing negotiation.
--> // making it invisible to querySelectorAll. // // `data-cfasync="false"` keeps this rescue script executable even when // Rocket Loader is active. It rescues module scripts via two strategies: // 1. Query the DOM for type$="-module" + src (covers case A) // 2. Regex-parse the raw HTML for commented-out script tags (covers case B) // Dynamically-created scripts bypass Rocket Loader entirely. (function () { if (window.__markdyRescue) return; window.__markdyRescue = true; var rescued = false; function rescueModuleScripts() { if (rescued) return; rescued = true; var srcs = []; // Strategy 1: Rocket Loader kept the tag in DOM but changed the type. // type="module" → type="{uuid}-module" (still has src attribute) document.querySelectorAll('script[type$="-module"][src]').forEach(function (s) { srcs.push(s.src); }); // Strategy 2: Rocket Loader COMMENTED OUT the script tag entirely: // // These are invisible to querySelectorAll, so we parse the raw HTML. // We handle both attribute orderings (type-first or src-first). var html = document.documentElement.innerHTML; var reSrcFirst = //g; var reTypeFirst = //g; var m; while ((m = reSrcFirst.exec(html)) !== null) { srcs.push(m[1]); } while ((m = reTypeFirst.exec(html)) !== null) { srcs.push(m[1]); } // Re-inject each found src as a real module script. // Deduplicate first, then inject. Dynamically-created scripts bypass // Rocket Loader entirely. Modules with the same URL are only executed // once by the browser (cached), so re-injecting already-running scripts // is safe. var seen = {}; srcs.forEach(function (src) { if (seen[src]) return; seen[src] = true; var fix = document.createElement('script'); fix.type = 'module'; fix.src = src; fix.setAttribute('data-cfasync', 'false'); document.head.appendChild(fix); }); } // Rescue when user clicks the placeholder (fallback if autoplay failed). document.addEventListener('click', function (e) { var t = e.target; if (t && typeof t.closest === 'function' && t.closest('.markdy-placeholder')) { rescueModuleScripts(); } }); // Rescue automatically after a short delay for autoplay. // Only fires if initAll() never ran (no data-markdy-init on any root). setTimeout(function () { if (document.querySelector('.markdy-root:not([data-markdy-init])')) { rescueModuleScripts(); } }, 1500); }());SLIP is ancient, but Linux still supports it natively. On the host side, the setup is three commands:
stty -F /dev/ttyUSB0 115200 raw cs8
slattach -m -F -L -p slip /dev/ttyUSB0
# /dev/ttyUSB0 is now a network interface
The hardware on the AVR side is literally nothing extra - no external components needed. Maurycy added a reverse-polarity diode (for inevitably connecting the power backwards) and a few LEDs for blinkenlights. The chip draws a few milliwatts and runs off the USB serial adapter’s 5V rail. One cable.
The stack
Now it has an internet connection. Building the server means implementing three protocols on a chip with 8 kB of RAM.
IP was easy. The AVR only needs to respond, never to initiate. Implementing IP response is mostly: swap the source and destination addresses, reset the TTL, and you’re done. IPv6 removed packet fragmentation entirely, and modern OSes disable it anyway, so the complexity from the old RFC 3 is gone.
TCP was not easy. TCP requires the microcontroller to track connection states, retransmit lost packets, handle window sizes, process RST and FIN correctly, and deal with a large edge-case surface. It took Maurycy several days to get a custom implementation working. It still has bugs. The site still loads.
HTTP he did not implement at all. The server sends one hardcoded response to any request, regardless of the URL. This is fine as long as there’s exactly one page to serve.
--> // making it invisible to querySelectorAll. // // `data-cfasync="false"` keeps this rescue script executable even when // Rocket Loader is active. It rescues module scripts via two strategies: // 1. Query the DOM for type$="-module" + src (covers case A) // 2. Regex-parse the raw HTML for commented-out script tags (covers case B) // Dynamically-created scripts bypass Rocket Loader entirely. (function () { if (window.__markdyRescue) return; window.__markdyRescue = true; var rescued = false; function rescueModuleScripts() { if (rescued) return; rescued = true; var srcs = []; // Strategy 1: Rocket Loader kept the tag in DOM but changed the type. // type="module" → type="{uuid}-module" (still has src attribute) document.querySelectorAll('script[type$="-module"][src]').forEach(function (s) { srcs.push(s.src); }); // Strategy 2: Rocket Loader COMMENTED OUT the script tag entirely: // // These are invisible to querySelectorAll, so we parse the raw HTML. // We handle both attribute orderings (type-first or src-first). var html = document.documentElement.innerHTML; var reSrcFirst = //g; var reTypeFirst = //g; var m; while ((m = reSrcFirst.exec(html)) !== null) { srcs.push(m[1]); } while ((m = reTypeFirst.exec(html)) !== null) { srcs.push(m[1]); } // Re-inject each found src as a real module script. // Deduplicate first, then inject. Dynamically-created scripts bypass // Rocket Loader entirely. Modules with the same URL are only executed // once by the browser (cached), so re-injecting already-running scripts // is safe. var seen = {}; srcs.forEach(function (src) { if (seen[src]) return; seen[src] = true; var fix = document.createElement('script'); fix.type = 'module'; fix.src = src; fix.setAttribute('data-cfasync', 'false'); document.head.appendChild(fix); }); } // Rescue when user clicks the placeholder (fallback if autoplay failed). document.addEventListener('click', function (e) { var t = e.target; if (t && typeof t.closest === 'function' && t.closest('.markdy-placeholder')) { rescueModuleScripts(); } }); // Rescue automatically after a short delay for autoplay. // Only fires if initAll() never ran (no data-markdy-init on any root). setTimeout(function () { if (document.querySelector('.markdy-root:not([data-markdy-init])')) { rescueModuleScripts(); } }, 1500); }());Getting it on the public internet
The AVR is running on a serial link from Maurycy’s home network. His ISP does not give him a public IPv4 address. For outside visitors, the data path had to route through a VPS in Helsinki.
WireGuard creates a virtual network link between the home machine and the VPS. No NAT traversal tricks needed. Then nginx on the VPS proxies any request for /mcu to the AVR’s local IP address. Visitors connect to the VPS; the VPS forwards traffic over WireGuard over SLIP over USB to an 8-bit chip.
The roundtrip is roughly: your browser → Helsinki VPS → WireGuard → home machine → USB serial (115200 baud) → AVR64DD32.
That’s why the page loads slowly. It is also functioning correctly at every step.
The HN thread
The thread is small (seven comments at time of writing) but good.
dragontamer praises the AVR DD/EA/EB lines while noting unease about Microchip’s recent 32-bit PIC push - new chips like the PIC32CM MC offer similar peripherals, making AVR’s future uncertain.
JdeBP points out that there’s a 2025 erratum to RFC 1055 that isn’t reflected in www.c. The erratum updates the decoding algorithm. It also suggests RFC 1144 (Van Jacobson’s TCP/IP header compression for SLIP links) is a natural next step.
steve_taylor says:
I love how I can see the HTML being streamed onto the page in real time, like the good old days of dialup when images gradually rendered from top-to-bottom.
reassess_blind: “Took a while but she loaded. I’ve seen enough, we’re pushing this to production.”
What I find interesting here
The choices are coherent. Ethernet was out because Manchester encoding at 10 Mbps exceeds the AVR’s IO clock. USB-native protocols would require a USB host stack. SLIP is the right tool: it’s decades old, it’s six lines to implement, and Linux has supported it since the 1990s.
The IP implementation is also correctly minimal. The author explicitly notes that IPv6 removed fragmentation entirely, and modern Linux disables IPv4 fragmentation anyway. The AVR doesn’t need to handle it. Strip the protocol to what the counterparty actually sends, and the “hard part” shrinks.
TCP is genuinely hard. The author spent several days on it and acknowledges bugs. This is honest. TCP is not a protocol you implement in an evening on any hardware. The fact that it mostly works on 8 kB of RAM is the impressive part.
The IPv6 aside at the end - “just get our stuff together, IPv6 has existed for thirty years” - is the kind of note that gets added when a problem forces you to think about why it exists.
The page
The site is live. You can visit maurycyz.com/mcu and watch the HTML arrive one TCP segment at a time, like a fax machine.
Source code is at maurycyz.com/projects/mcusite/ along with a prebuilt binary.
Discussion: news.ycombinator.com/item?id=48165295
Hoang Yell
A software developer and technical storyteller. I read Hacker News every day and retell the best stories here — in English and Vietnamese — for curious people who don't have time to scroll.