A Pi-hole-style DNS ad-blocker that runs on a $2 ESP32-C3 β no PSRAM required.
π° Featured on Tom's Hardware, XDA Developers, and Korben.
The trick everyone misses: you don't need to keep the blocklist in RAM. Store the domains as sorted 40-bit hashes in flash and binary-search them. 140,000+ domains fit in ~0.7 MB of flash and are matched in ~10 ms, using ~50 KB of RAM.
query in βββΆ extract domain βββΆ FNV-1a hash (+ parent suffixes)
βββΆ binary-search the flash hash table
ββ hit βββΆ answer 0.0.0.0 (sinkholed)
ββ miss βββΆ forward to upstream resolver, relay the reply
Most ESP32 DNS sinkholes load the blocklist (domain strings) into RAM, so they demand PSRAM. This project stores fixed 5-byte (40-bit) hashes in flash instead:
| string-in-RAM approach | this (hash-in-flash) | |
|---|---|---|
| Hardware | ESP32 + PSRAM (~$8) | ESP32-C3, no PSRAM (~$2) |
| 141k domains | ~2.5 MB of RAM | 0.67 MB of flash |
| RAM used | most of it | ~50 KB |
| Lookup | string compare | ~18 flash reads (~10 ms incl. WiFi RTT) |
| Collisions | n/a | 0 at 141k (1 at 537k) |
Why 40 bits? It's the sweet spot for this flash budget. Collisions follow the birthday bound β at 141k domains you get ~0, at 537k about 1 (i.e. one unlucky domain gets over-blocked). Dropping to 32 bits would save 20% of the flash but cost ~7 collisions at 250k; going to 64 bits wastes 3 bytes per domain to solve a problem you don't have.
The same trick works on bigger chips β it isn't a C3 workaround. On a 16 MB ESP32-S3 these hashes hold ~2.7M domains vs ~466k for strings in 8 MB of PSRAM. Hashes in flash beat strings in PSRAM basically everywhere; the C3 just makes it undeniable.
- Any ESP32-C3 board (tested on a C3 SuperMini), 4 MB flash, no PSRAM needed
- Power it from a stable USB source (a phone charger or your router's USB port). Cheap/loose USB-CβA adapters can brown out the radio during WiFi transmit.
- A USB-A β USB-C dongle lets it plug straight into the spare USB port on the back of most routers β no power supply, no extra box.
A printable case for the C3 SuperMini: hardware/esp32-c3-supermini-enclosure.stl
Printing notes:
- No supports needed; 0.2 mm layers, ~15% infill is plenty.
- Keep the antenna end clear. The C3's PCB antenna is the zig-zag trace on the short edge opposite the USB-C port β don't bury it in solid plastic or put metal near it, or your RSSI will suffer.
- Leave the vents open: the board idles around 45β55 Β°C.
One USB flash to get going β after that, firmware and blocklist both update over WiFi (see below).
β οΈ Use a current PlatformIO β the VSCode PlatformIO extension's bundled core, orpip install -U platformioin a venv. The distro/aptplatformiopackage (e.g. 4.3.4) is too old and fails withAttributeError: ... 'resultcallback'(issue #4). A one-click browser installer is on the way (hosting TBD).
# 1. (optional) set WiFi creds at compile time β or skip this and use the
# on-device setup portal (below). secrets.h is gitignored, stays local.
cp src/secrets.example.h src/secrets.h
# then edit src/secrets.h -> WIFI_SSID / WIFI_PASS
# 2. build the blocklist hash table (default = StevenBlack base + Hagezi Light,
# ~140k domains, WhatsApp/social safe)
python3 tools/build_blocklist.py data/blocklist.bin
# 3. flash firmware + the blocklist filesystem (the one and only USB flash)
pio run -t upload
pio run -t uploadfs
# 4. watch it boot, note the IP / open the dashboard
pio device monitor # -> http://c3adblock.localIf it can't connect (or you never set secrets.h), it starts an open access point
C3-AdBlock-XXXX with a captive portal β join it from a phone, pick your network,
type the password, done. To move it to a new network later: open http://c3adblock.local/forgetwifi,
or hold the BOOT button while powering on, and the setup portal comes back.
The dashboard at http://c3adblock.local does it all:
- Blocklist β drop a freshly built
blocklist.bininto Blocklist β Upload, or set a URL under Remote auto-update and the device pulls a prebuiltblocklist.binon a schedule (e.g. a GitHub release asset β update it once, every device fetches it). - Firmware β upload
.pio/build/c3/firmware.binunder Firmware β OTA update; the device verifies it and reboots into the new image. Or push over WiFi from the CLI:pio run -t upload --upload-port c3adblock.local --upload-protocol espota
4 MB flash tradeoff: firmware OTA needs two app slots, which leaves ~1.3 MB for the
blocklist (~250k domains max). The aggressive 537k "ultimate" list only fits the
single-app partition table (no firmware OTA). Pick your tradeoff in partitions.csv.
Point a device's DNS at the C3's IP, or add it as a secondary resolver behind your main DNS. Test:
dig @<c3-ip> doubleclick.net # -> 0.0.0.0 (blocked)
dig @<c3-ip> github.com # -> real IP (forwarded)- ModemManager (default on Fedora/Ubuntu) grabs
/dev/ttyACM0and toggles DTR/RTS, which resets the C3 and blocks serial. Fix:sudo systemctl stop ModemManager echo 'ATTRS{idVendor}=="303a", ENV{ID_MM_DEVICE_IGNORE}="1"' | sudo tee /etc/udev/rules.d/99-esp-no-modemmanager.rules sudo udevadm control --reload-rules && sudo udevadm trigger
- The C3's USB-Serial-JTAG console can swallow early boot output until the host
connects (
while(!Serial)helps). - DNS clients add an EDNS OPT record; a blocked reply must contain only the question + answer (ANCOUNT=1, NSCOUNT=ARCOUNT=0) or it's malformed.
- β Web dashboard β per-client block/allow counts, ban a client, add custom domains
- β
mDNS (
c3adblock.local) for discovery - β OTA β firmware + blocklist update over WiFi, plus scheduled remote blocklist pulls
- β Captive-portal WiFi setup (no hardcoded creds) + one-click browser web-installer
- β¬ Bucketed prefix index β ~18 flash reads/lookup β ~1β2 (issue #3), the throughput win
- β¬ Act as the DHCP server (hand itself out as DNS) for true plug-and-play
Inspired by s60sc/ESP32_AdBlocker β the "answer 0.0.0.0 for blocklisted domains" idea. This is an independent from-scratch implementation focused on the hash-in-flash optimization for PSRAM-less chips.
MIT β see LICENSE.