A public website running entirely on a single ESP32 with 520 KB of RAM. Every page, every sensor reading, every guestbook entry is served by the microcontroller itself. There is no backend server.
Live at helloesp.com
Because it's fun to see how far a $10 microcontroller can go. The last version ran until 2023 on an ESP32 behind a Cloudflare tunnel. It eventually went offline and the domain lapsed.
Years later I came back to it. The internet had gotten heavier in the meantime: more of everything, most of it wasteful. This whole website weighs less than a single phone wallpaper. I wanted to see what a single small chip could still do against all that. The domain was available again, which felt like permission.
This time it stays up. If it goes down, I fix it. That's the point now.
The ESP32 holds a persistent outbound WebSocket to a Cloudflare Worker. When a browser hits helloesp.com, the Worker relays the request over that socket and streams the response back. The ESP never accepts an inbound TCP connection from the internet, only from the LAN.
Browser ──HTTPS──▶ Cloudflare Worker ──WSS──▶ ESP32 (on your home network)
▲ │
└────── response ────────┘
Responses larger than a single WebSocket frame are chunked and base64-encoded. Admin endpoints return 404 through the relay and are only reachable on the LAN. A second shared secret (HMAC) can be enabled so a leaked Worker secret alone cannot impersonate the device.
The same WebSocket also carries live push events the other direction: every 15 seconds the device pushes sensor stats, and every tracked public request triggers a console event. The Worker fans these out to connected browsers via Server-Sent Events (/_stream), so the homepage ticks in real time without polling.
Without the Worker, the site still runs on LAN via mDNS at http://helloesp.local.
| Part | Qty | ~Price | Source | Role |
|---|---|---|---|---|
| Inland ESP32-WROOM-32D | 1 | $10 | Microcenter | MCU, 520 KB RAM, dual-core Xtensa. Different silkscreen layout from the DOIT V1, but builds under esp32doit-devkit-v1 since the firmware addresses pins by GPIO number |
| BME280 breakout | 1 | $7 | Amazon | Temperature, humidity, barometric pressure |
| CCS811 breakout | 1 | $14 | Amazon | CO₂, VOC |
| Adafruit DS3231 + CR1220 cell | 1 | $14 | Adafruit | Battery-backed real-time clock, accurate timekeeping across reboots |
| SSD1306 128×64 OLED | 1 | $3 | Amazon | Rotating info pages with burn-in shift |
| Adafruit MicroSD breakout | 1 | $8 | Adafruit | SPI SD card adapter |
| Good quality Micro SD card (FAT32, ≤32 GB) | 1 | $15 | anywhere | Filesystem: HTML, images, logs, config |
| 5mm LEDs + 220Ω resistors | 2 each | <$1 | Microcenter | Status LED (visit blink) + notification LED (pending guestbook) |
| Full-size premium breadboard | 1 | $5 | Adafruit | Build substrate; see "build gotchas" below |
| Solid-core hookup wire | 1 | $17 | Adafruit | Cut-to-length connections between modules |
Total parts: ~$95 as actually built (mix of Amazon, Microcenter, Adafruit). Going all-clones with no Adafruit breakouts gets you closer to $35. Clone-shop the DOIT V1 itself with the most care, since some clones ship undersized USB-UART chips or unstable AMS1117 LDOs.
Power: runs from any 1 A USB-A phone charger. Total draw averages well under 1 W, with brief spikes during WiFi association and SD writes.
My setup: USB-C brick → USB-C-to-micro-USB cable → smart plug → UPS. The smart plug gives remote power-cycling for when something wedges; the UPS makes grid blips invisible. Both are optional, but both are part of why "this stays up" is a real claim and not an aspiration.
| Signal | ESP32 Pin |
|---|---|
| I²C SDA (BME280, CCS811, DS3231, OLED) | GPIO 21 |
| I²C SCL (BME280, CCS811, DS3231, OLED) | GPIO 22 |
| SD CS | GPIO 5 |
| SD MOSI | GPIO 23 |
| SD MISO | GPIO 19 |
| SD SCK | GPIO 18 |
| Status LED | GPIO 33 |
| Notification LED | GPIO 32 |
| CCS811 WAKE | GND |
I²C addresses: BME280 0x76, CCS811 0x5A, DS3231 0x68, SSD1306 0x3C.
DS3231 is optional. If absent, the firmware falls back to NTP-only timekeeping with a brief 1970-until-NTP window after each reboot; with it, the system clock is pre-seeded from the RTC at boot and writes back on every NTP sync.
A few things that aren't obvious until you ship one of these and watch it fail:
- Don't oversize the AMS1117 output caps. The on-board LDO on the DOIT DevKit V1 is stable across roughly 1µF to 100µF on the output. Adding a bulky 470µF "for safety" pushes it into oscillation at boot. The symptom looks like WiFi flake (brownout reboots, repeating connect attempts). Stick with what the dev board ships, or add a small ceramic if you must.
- Desolder R1 and R2 on the DS3231 module. Most generic DS3231 boards ship with their own 4.7kΩ I²C pull-ups installed. Leaving them in place stacks four sets of pull-ups on one bus (BME280, CCS811, OLED, DS3231), dropping the effective resistance to ~1.95kΩ. SDA/SCL high levels sag and CCS811 reads start corrupting. Lift R1 and R2 with a soldering iron; the bus settles around 2.4kΩ, which is what the rest of the parts expect.
- SPI and I²C don't like sharing parallel jumpers. The SD card's SPI bus runs at ~25 MHz on GPIO 18/19/23. If those wires sit flush against the I²C lines on a breadboard, capacitive coupling shows up as CCS811 readings that look like the sensor is dying. Route the SD lines on a different row, or lift them ~1mm above the I²C wires for vertical air separation.
- Use solid-core wire, and don't put any jumper under tension. Stranded jumpers work fine on the bench. Once the device is framed and mounted, any flex on a stranded contact intermittently fails and the bus wedges. Solid-core, cut to length, with no pull on either end.
- Tie 3.3V and GND on both rails, at both ends. Most full-size breadboards split each power rail in the middle, and even the ones that look continuous can have a single weak contact mid-board. Tying the rails at one end works on the bench, but on the wall a single point of contact is one whisker of flex away from a brown-out. Two jumpers across, one at each end, costs nothing.
These are all lessons from this build. A PCB respin would fix most of them at the layout level, but with the gotchas above applied the breadboard build is solid.
Frontend
- Live dashboard: 12 sensor/system metrics, trend arrows, degraded-sensor indicators
- Real-time SSE updates (push every 15s from the device), with graceful fallback to 30s polling
- Live connection indicator (pulsing green dot) and a "Right now" request ticker on the homepage
- Historical CSV charts with day/week/month switcher; weekly/monthly/yearly aggregate archives
- Hall of Fame (lifetime extremes: peak CO₂, temp range, busiest day, longest uptime) and year-over-year monthly visitor deltas on
/history - Visitor country map, request-rate chart, changelog, photo carousel
/consolelive feed: last 50 public requests with country flags, updated instantly via SSE- Outdoor weather context (via Cloudflare Worker proxy to Open-Meteo)
- Guestbook with two-level reply threading, tombstone deletes, moderation queue, inline AI translation, rate limiting
- Snake easter egg with global leaderboard on the 404 / offline / timeout pages: replay-verified server-side, 3-letter initials with content blocklist, idle demo mode
- Dark mode, responsive, SRI-pinned CDN scripts, no tracking
Firmware
- Full async HTTP server over WebSocket-relayed traffic
- Non-blocking WebSocket client with linear backoff (500 ms first retry, then 5 s × fails up to 30 s cap), WiFi reassociation on consecutive fails, and a 60 s wall-clock safety-net reboot if recovery stalls
- Atomic writes for all critical files (
tmp → bak → rename) - Period aggregation: weekly, monthly, yearly checkpoints
- CSV sensor logging every 5 minutes
- NTP with three-server failover and bounded boot retry; DS3231 pre-seeds the system clock at boot so timestamps are correct before NTP arrives
- WiFi runtime watchdog: reboots if disconnected >10 min
- Heap safety net: reboots at <30 KB free rather than hang
- Event-driven SSE push for sensor stats (15s) and console entries (on each request)
- Admin panel (LAN-only): OTA updates, file manager (with overwrite-confirm + gzip-pair tooltips), full SD backup/restore, R2 backup health + liveness test, SMTP2GO test email, self-test (with I²C bus scanner), sensor + error log viewers, device health sparklines (heap + RSSI), Worker link status, data management (reset counters / export state / clear Snake leaderboard / repair monthly+yearly stats from weekly archives / drop out-of-spec CSV rows / fix truncated record timestamps), maintenance mode toggle
- OLED boot sequence, rotating runtime pages, burn-in protection
Edge
- Cloudflare Worker (Durable Object) with per-IP rate limiting, 8 KB POST cap
- SSE fanout hub: multiple browser viewers, constant ESP load
- Maintenance mode with auto-expiring window and dedicated 503 page
- Auto-retry + pulsing indicator on all error pages (offline / timeout / maintenance)
- Cloudflare edge caching honors the device's
Cache-Control: max-ageheaders so repeat visitors hit the edge instead of the chip - Optional
worker_exclusivemode: LAN visitors to public pages (homepage, guestbook, etc.) get redirected to the public site so they go through the Worker's edge cache rather than hitting the chip directly./adminstays direct on LAN. Eliminates LAN-burst-vs-WS-write collisions - Hourly outdoor-weather refresh cached in the Durable Object
- Inline guestbook translation via Workers AI (
@cf/meta/m2m100-1.2b), per-(message-id, target-lang) cached in DO storage so each unique pair costs at most one neuron - Snake leaderboard storage (Durable Object) with replay-verify anti-cheat, ROT13'd 3-letter initials blocklist, per-IP rate limit on score submissions
- Embeddable live status badges at
/status.svg(and/status-wide.svg) - Optional HMAC challenge-response device auth
- Optional SMTP2GO integration for guestbook-pending alerts, dead-man's-switch (device silent >N hours), backup failures, and overdue-backup warnings
- Optional daily off-site backups to Cloudflare R2 with GFS rotation (7 daily + 4 weekly + 12 monthly + yearly) and sha256 integrity manifests
- Security headers, no-cache list for dynamic endpoints
- RSS feeds (
/changelog.rss,/guestbook.rss),sitemap.xml,robots.txt,.well-known/security.txt
Live badges you can drop into your own README, blog, or status page. All variants pull from the Worker's cache, so embedding them puts zero load on the ESP no matter how many pages link them. Edge-cached 60 seconds. Keep working (showing "offline") even when the device is unreachable.
Compact badges (shields.io-compatible layout, 20px tall):
Wide stat card (340×78, multi-line mini-dashboard):
https://helloesp.com/status-wide.svg
Embedding
Markdown:
[](https://helloesp.com)HTML:
<a href="https://helloesp.com">
<img src="https://helloesp.com/status.svg" alt="HelloESP status">
</a>State colors (applies to both compact and wide):
| Color | Meaning |
|---|---|
| Blue | Live: device online, heard from within 45s |
| Gray | Offline or stale: no stats received in >2 minutes, or WS socket is gone |
| Orange | Maintenance: owner-declared downtime window |
Accessibility: every badge includes <title> text and role="img", so screen readers announce the state rather than saying "image." The aria-label reflects the current value ("HelloESP: up 5d 3h" etc.).
Cache policy: Cache-Control: public, max-age=60. Most embedders don't need sub-minute freshness; plan accordingly if you do.
git clone https://github.com/Tech1k/helloesp.git
cd helloesp
pio run -t uploadRequires PlatformIO.
Format a FAT32 SD card (≤32 GB) and copy the contents of data/ to the root. The data/ folder is not flashed. It lives on the SD card.
Rename config.example.txt to config.txt on the SD card and fill in:
wifi_ssid=YOUR_SSID
wifi_pass=YOUR_PASSWORD
admin_user=admin
admin_pass=your-admin-password
timezone=MST7MDT,M3.2.0,M11.1.0
Timezone is a POSIX TZ string. Common US examples are listed in the file. Leave worker_url, worker_key, and device_key blank to run LAN-only.
Optional worker_exclusive=true redirects LAN public-page hits (/, /guestbook, etc.) to the Worker so they go through CF's edge cache. /admin always serves direct on LAN. Default off; flip to true only if your Worker is set up and you want LAN visitors to share the same cache layer as public visitors.
# Generate a shared secret; don't pick one by hand
openssl rand -hex 32The same value goes in two places:
worker_keyin the SDconfig.txt- Worker secret
WORKER_SECRET
Then deploy:
cd worker
wrangler deploy
wrangler secret put WORKER_SECRET # paste the value from opensslAdd worker_url to config.txt (e.g. helloesp.com). The ESP will connect on next boot.
Adds a second secret so a leaked WORKER_SECRET alone cannot impersonate the device. On every reconnect, the Worker sends a random nonce and the ESP signs it with the device key.
openssl rand -hex 32Set the same value as device_key in config.txt and:
wrangler secret put HMAC_SECRETIf either side is unset, HMAC is disabled and auth falls back to WORKER_SECRET only.
Configuring SMTP2GO once unlocks every alert channel the Worker uses:
- Guestbook moderation notifications (throttled 1/5min)
- Dead-man's-switch: device silent for longer than
DEADMAN_HOURS(default 6) + recovery email when it comes back - Backup failures (throttled 1/hr) and overdue-backup warnings (>48 h since last success, 1/day)
- Manual test email from the admin panel
wrangler secret put SMTP2GO_KEY
wrangler secret put NOTIFY_EMAIL
wrangler secret put NOTIFY_FROM # optional, e.g. "HelloESP <no-reply@yourdomain>"
wrangler secret put DEADMAN_HOURS # optional, default 6, accepts fractional hoursThe ESP never blocks on outbound HTTPS; all email sending lives on the Worker. If any required secret is unset, the corresponding alerts silently no-op.
Full SD snapshots go to Cloudflare R2 once a day at 4 AM local. Excludes config.txt, *.tmp/*.bak files, and /logs. Each backup lives at state/YYYY-MM-DD/ with a sha256 manifest; state/latest.json is the atomic commit pointer.
wrangler r2 bucket create helloesp-backup
wrangler deployThe binding is declared in wrangler.toml. If the binding isn't present, the Worker falls back to emailing the bundle as an attachment (if SMTP2GO is configured) so you don't silently lose backups.
Rotation (Grandfather-Father-Son). Keeps 7 daily + 4 weekly (Sundays) + 12 monthly (1st of month) + every Jan 1 forever. Anything outside those rules older than 8 days is pruned. A prefix guard refuses to delete anything not matching state/YYYY-MM-DD/.
Storage math. ~1.4 MB per snapshot × ~23 retained snapshots ≈ 32 MB/year. Well under R2's 10 GB free tier.
Alerting. No email on success. A single email fires if a backup fails (throttled 1/hr) or if no successful backup has been committed for >48 h (once per day).
- Format a new FAT32 SD card (≤32 GB).
- Copy the
data/folder from this repo to the SD root. HTML/CSS/JS/images are deploy-recreatable and not in the backup. - Open the R2 bucket. Read
state/latest.jsonfor the most recent snapshot date. - Download every object under
state/{date}/, preserving subdirectories (e.g.stats/weekly/2026-W16.json→/stats/weekly/2026-W16.jsonon SD). - Recreate
config.txtfrom your own records. Secrets aren't in the backup on purpose. - Insert the SD card and boot.
For single-file fixes, download one object from R2 and drop it in via the admin file manager. No full restore needed.
config.txtis in.gitignore. Do not commit it. TreatWORKER_SECRETanddevice_keylike passwords.- Admin endpoints (
/admin,/admin/*,/_upload,/_ota,/guestbook/pending,/guestbook/moderate) return 404 to any request arriving through the Worker. They are only reachable from the LAN. - Admin Basic Auth uses a per-IP lockout (5 failed attempts → 10-minute block).
- Worker enforces 60 req/min per IP, caps POST bodies at 8 KB, and strips hop-by-hop headers.
- CDN scripts are pinned with SHA-384 SRI hashes.
- Guestbook input is stripped of control bytes and CSV-breaking characters before storage; output is JSON- and HTML-escaped on both sides.
If you're thinking about building your own, here's what to expect:
- Concurrency is modest. Roughly 5 simultaneous requests before latency is noticeable. Cloudflare's edge absorbs bursts of static content; dynamic endpoints hit the chip.
- No HTTPS origin. TLS terminates at the Worker. LAN access is HTTP only, so don't treat the admin panel as secure without a VPN or physical access.
- Single point of failure. One chip, one WiFi link, one SD card. A power blip takes the site down until reboot (usually under 30 seconds).
- SD wear. CSV logging every 5 min plus guestbook writes is a few hundred writes per day. Consumer SD cards will last years, not decades; swap annually if the site matters.
- Memory is tight. 520 KB total, ~180 KB free at idle. Large responses or many concurrent frames can OOM; a heap watchdog reboots at under 30 KB free rather than hang.
The full stack is documented above: firmware, hardware, Worker, optional R2 backups, optional email alerts. Everything you need to run your own is here.
Got one running? Open a PR or issue with the link. I'll list notable builds in this section as they come in. Curious to see what people do with it.
src/main.cpp Firmware (Arduino framework)
data/ SD card contents: HTML, CSS, JS, images, favicon SVG
data/*.html.gz Pre-gzipped HTML (auto-generated, see below)
worker/worker.js Cloudflare Worker + Durable Object (relay, SSE, weather, badges)
worker/wrangler.toml Worker config
scripts/gzip_assets.py PlatformIO pre-build step that gzips data/*.html
platformio.ini Build config + cppcheck flags
.github/workflows/ CI: PlatformIO build, CodeQL, Lighthouse
.github/dependabot.yml Dependency update automation (GitHub Actions)
.github/ISSUE_TEMPLATE/ Issue chooser (security policy + discussions)
.github/assets/ README hero image
SECURITY.md Security disclosure policy
HTML files under data/ are pre-gzipped before upload. scripts/gzip_assets.py
runs automatically on every pio run (wired via extra_scripts in
platformio.ini) and produces .html.gz companions next to each source.
The firmware's beginResponseGzipOrRaw() helper checks for a .gz version on
SD and serves it with Content-Encoding: gzip when the client accepts it.
Falls back transparently to the uncompressed file if no .gz exists.
Why this matters: a 90+ KB uncompressed index.html served directly
over LAN can saturate LWIP's pbuf pool mid-stream. Under that pressure,
colliding writes on the outbound WebSocket to Cloudflare fail with EAGAIN,
and DNS lookups (which share the same pool) also start failing. Gzipping
drops wire size ~4× and keeps the pool clear.
When you edit an .html file, pio run regenerates the .gz automatically.
Upload both versions to the SD card (the file manager in the admin panel
handles this fine).
- ESPAsyncWebServer
- AsyncTCP
- Adafruit BME280
- Adafruit CCS811
- Adafruit SSD1306 + GFX
- Adafruit RTClib
- Uptime Library
MIT. See LICENSE.
