zeroserve: a zero-config web server you can script with eBPF
zeroserve: a zero-config web server you can script with eBPF<br>#tech#zeroserve
Disclaimer: This article is co-authored with GPT-5.5 and Claude Opus 4.8.
zeroserve is a small, fast, zero-config HTTPS server. You hand it a tarball of a website and it serves it - over HTTP/2 and TLS 1.3, with hot reload and a tiny resident footprint. The twist is that you can drop eBPF programs into the tarball and they run on every request, in userspace, as sandboxed middleware - rewriting, authenticating, and rate-limiting requests, or reverse-proxying them to a backend when you want it to act as a gateway in front of your app.
In short:
Fast : on one core it beats nginx across most workloads - small and large static files, scripted middleware, and small-response proxying, all over HTTPS.
Efficient eBPF scripting : scripts are JIT-compiled to native code and sandboxed in userspace, cheap enough to run on every request.
Program-as-configuration : your eBPF program is the whole configuration, deciding what happens to each request.
io_uring throughout : every network and disk operation is submitted through io_uring.
Modern TLS in the box : TLS 1.3, HTTP/2, Encrypted Client Hello, SNI certificate selection, and JA4 fingerprinting.
Simple to operate : serve a whole site from one tarball and hot-reload it (and the TLS material) with a SIGHUP.
It's meant to be an alternative to nginx and Caddy, and the design bet is about configuration. Those servers give you a declarative config language - location blocks, rewrite rules, map directives, try_files - and then, once the declarative language hits its limits, an optional scripting runtime bolted on the side (Lua, or Caddy's plugins). Behavior ends up split across two layers: directives that quietly grow their own control flow, plus scripts that run somewhere in the request lifecycle you have to keep in your head.
zeroserve collapses that into one thing. There is no config file. The eBPF program is the configuration - a single, ordinary, sandboxed program that sees every request and decides what happens: routing, headers, auth, rate limiting, proxying. I want the whole request path in one program I can read top to bottom.
One tarball, served in place
The whole site is a single tar file. zeroserve indexes it on load - building a path -> byte-range map - and then serves files by issuing byte-range reads against the tarball itself. Nothing is ever unpacked to disk. The site lives entirely in that one file, so there's no document root for a stray location rule to expose, and a deploy is a single atomic file swap. To package a directory:
zeroserve --pack ./public > site.tar<br>zeroserve --addr 0.0.0.0:8080 site.tar
Deploying a new version is "replace the tarball and send SIGHUP". The reload swaps the site, the scripts, and the TLS material atomically, in the same process, with no dropped connections:
killall -SIGHUP zeroserve
All network and disk I/O goes through io_uring (via the monoio runtime). Each instance is a single-threaded event loop. That sounds like a limitation, and per-process it is - but it's the right shape when your scaling unit is "more processes", and it's why many of them coexist happily on one box.
Scripting with eBPF, in userspace
This is the part I find most fun. Any .c file you put under .zeroserve/scripts/ gets compiled to an eBPF object at pack time (with clang and llc) and runs on every request. The eBPF runs entirely in userspace: zeroserve loads the bytecode into a runtime (async-ebpf) inside its own ordinary, unprivileged process, so the kernel's BPF subsystem and CAP_BPF stay out of it. async-ebpf JIT-compiles the bytecode to native machine code (it vendors uBPF), so your "config" runs as native x86-64.
A pointer cage does the job the kernel verifier normally would, keeping the program from reading or writing memory it shouldn't: every memory access in the JIT-compiled code is masked into the program's own arena, so a stray access stays confined to the script's own memory.
The script runs directly on zeroserve's single event loop. To keep one slow script from stalling every other connection, the runtime is fully preemptible: a timer can interrupt JIT-compiled native code mid-execution and hand control back to the event loop.
The programming model is a chain of scripts, run in sorted filename order, sharing a per-request metadata map. If a script calls zs_respond or zs_reverse_proxy, the chain short-circuits. Here's a script that runs first and enriches every request:
#include
ZS_ENTRY<br>zs_u64 entry(void) {<br>char peer[64];<br>if (zs_req_peer(peer, sizeof(peer))<br>The metadata it sets does two things. Keys under zs.response.header.* become response headers on everything. And other keys feed a tiny template pass: a visitor placeholder in an HTML file gets substituted on the way out. So you get dynamic-ish static pages without a template engine.
The helper surface a script can call is...