Zero-config, fast, scriptable io_uring HTTPS server.
zeroserve serves a website packaged as a single tarball, runs sandboxed eBPF
request scripts JIT-compiled to native code, and can
compile and serve a Caddy config directly. It hot
reloads on SIGHUP, leaves no temporary files on disk, and hardens itself with
Linux namespaces and capability dropping.
io_uringend to end. All network and disk I/O runs on themonoioruntime, with a per-core worker thread andSO_REUSEPORTlisteners.- Single-file sites. A site is just a tarball. It is indexed at load time (path → byte range) and served directly via byte-range reads — no extraction, no temp files.
- eBPF request scripting. Inspect and rewrite requests, generate responses,
reverse-proxy, rate-limit, do crypto, sign AWS SigV4 requests, and gate the
site behind OAuth2/OIDC login - all from small eBPF scripts (written in C, or if you prefer, Rust)
JIT-compiled to native code on load and executed per request.
It's preferred to feed zeroserve tarballs with precompiled eBPF
.ofiles, but it also accepts raw.cfiles - zeroserve has a built-in tinycc with eBPF backend patch so it can compile C to eBPF on the fly. - Caddy compatibility. Adapt a Caddyfile (or Caddy JSON) to a zeroserve
script and serve it in one command. See
CADDY_COMPAT.md. - Modern TLS. TLS 1.3 via BoringSSL, SNI certificate selection from a directory, and Encrypted Client Hello (ECH) with key rotation and transparent relay fallback.
- Hardened runtime. Linux namespace isolation, capability dropping, and an
explicit
--expose-filesystemopt-in for any host filesystem access.
Docker:
docker run --rm -p 8080:8080 -v "$PWD/site.tar:/srv/site.tar:ro" \
ghcr.io/losfair/zeroserve:0.2.11 --addr 0.0.0.0:8080 /srv/site.tarImages are published to ghcr.io/losfair/zeroserve (multi-arch: amd64,
arm64).
Prebuilt binary from GitHub releases:
curl -fsSL "https://github.com/losfair/zeroserve/releases/download/v0.2.11/zeroserve-$(uname -m)-linux" \
-o zeroserve && chmod +x zeroserveFrom source (requires a recent stable Rust toolchain; Linux only):
cargo build --release --locked
# binary at target/release/zeroservezeroserve is Linux-only because it relies on
io_uring.
# Pack the current directory into a site tarball
zeroserve --pack ./public > site.tar
# Serve it over HTTP on :8080
zeroserve --addr 0.0.0.0:8080 site.tar--pack walks the directory, adds every regular file, and compiles any
.zeroserve/scripts/*.c request script to an eBPF .o (the .c source is
omitted from the tarball).
# HTTP only (default address is 0.0.0.0:8080)
zeroserve site.tar
# HTTP on :8080 and HTTPS on :8443
zeroserve --tls-addr 0.0.0.0:8443 --cert certificate.pem --key key.pem site.tar
# HTTPS with SNI certificate selection from a directory of PEMs
zeroserve --tls-addr 0.0.0.0:8443 --cert-dir /etc/zeroserve/certs site.tar
# Fall back to <path>.html when a request path is missing
zeroserve --try-html site.tar
# Honor PROXY protocol v1 (e.g. behind a TCP load balancer)
zeroserve --enable-proxy-protocol site.tar
# Reject requests whose Host/SNI is not in the allow-list (otherwise 421)
zeroserve --validate-hostnames example.com,www.example.com site.tar
# Run standalone eBPF scripts with no static files
zeroserve auth.c
# Run plugin scripts before the site's own scripts
zeroserve --plugin auth.c,metrics.o site.tar
zeroserve --plugin-dir ./plugins/auth site.tar
# Inherit a pre-bound socket (socket activation)
zeroserve --addr fd:3 site.tar
# Hot-reload tarball, certificates, and scripts in place
killall -SIGHUP zeroserveRun zeroserve --manual to print the full embedded user manual (also in
docs/user_manual.md), and zeroserve --help for the
complete flag reference.
Scripts live under .zeroserve/scripts/ in the site directory and run in
filename order. Each is a C file using the SDK header
(sdk/zeroserve.h, also available via
zeroserve --dump-sdk):
#include <zeroserve.h>
ZS_ENTRY
zs_u64 entry(void) {
char path[64];
zs_req_path(path, sizeof(path));
if (zs_strcmp(path, "/health") == 0) {
zs_meta_set(ZS_STR("zs.response.header.content-type"), ZS_STR("application/json"));
zs_respond(200, ZS_STR("{\"status\":\"ok\"}\n"));
}
return 0;
}Scripts are compiled to eBPF and JIT-executed per request inside a memory- and
time-bounded sandbox. The helper API covers request/response inspection and
mutation, response templating, logging and time, crypto and encoding
(SHA-256, HMAC, base64, hex, random), AWS SigV4 signing, rate limiting,
reverse proxy, OAuth2/OIDC login with sealed cookies, and strongSwan VICI
lookups. See the "Request scripting" section of the user manual for the full
helper reference, and examples/ for runnable scripts.
Generating scripts is easiest via the
zeroserve-script-createworkflow, or by hand againstsdk/zeroserve.h.
Request scripts are compiled to eBPF and JIT-executed in-process, in the same address space as the server. The pointer cage — provided by the async-ebpf runtime — is the memory isolation boundary that makes this safe: a buggy or malicious script cannot read or write any memory outside its own sandbox, no matter how it computes a pointer.
It works by confining every guest memory access to a fixed, power-of-two-sized window of virtual memory:
- Caged layout. Each program gets one anonymous mapping, initially
PROT_NONE, carved into a read/write stack region (per-invocation) and a read-only data region (the linked program image), separated and surrounded by guard regions. The guard sizes are randomized per program (ASLR-style), and the whole window is padded to a power of two with a one-page margin on each side to absorb the maximum load/store displacement. - Branchless pointer masking. The JIT (a patched uBPF)
rewrites every load and store address to
(address & mask) + offsetbefore the native access, wheremask = window_size - 1andoffsetis the window base. Because the window is a power of two, any pointer — however the script arithmetic produced it — is forced back inside the cage. The transform is branchless, so there is no mis-speculatable path for a transient out-of-bounds read (Spectre-v1). - Guard pages catch escapes. A masked pointer that lands in a guard region
hits
PROT_NONEmemory and faults. ASIGSEGVhandler translates the faulting native address back to a guest offset and turns it into a clean, contained program fault instead of a server crash or an escape. - Immutable code and data. After linking, the data/code region is frozen to
read-only (
mprotect), and the JIT confines all stores to the stack region regardless of analysis hints — so a script can never modify its own code or the shared data region.
Static region analysis is layered on top as a performance optimization (it lets confidently-classified loads skip one of two region probes), but it is explicitly not a security boundary — the JIT always retains a single-region bounds check, so the cage holds even if the analysis is imprecise.
zeroserve can adapt and compile the HTTP-routing portion of a Caddy config — either a native Caddyfile or Caddy JSON, auto-detected by content:
# Adapt → compile → in-memory site → serve, all in one shot
zeroserve --caddy Caddyfile --addr 0.0.0.0:8080
# Compile a Caddy config to a zeroserve script you can pack into a site
zeroserve --caddy-compile Caddyfile > .zeroserve/scripts/50-caddy.c
# Just adapt a Caddyfile to Caddy JSON and inspect it
zeroserve --adapt-caddyfile CaddyfileSupported directives include respond/error/abort, redir,
header/request_header, rewrite/uri/method,
handle/handle_path/route/handle_errors, root/fs/vars/map,
basic_auth, request_body, file_server, and single-upstream
reverse_proxy, with matchers, route groups, response hooks, and
tls.client_auth policies. The full supported surface and known non-goals are
documented in CADDY_COMPAT.md.
# Generate an ECH keypair + ECHConfig (PEM to stdout, DNS guidance to stderr)
zeroserve --gen-ech-key --ech-public-name ech.example.com > ech.pem
# Serve with ECH enabled (TLS must be configured)
zeroserve --tls-addr 0.0.0.0:8443 --cert-dir /etc/zeroserve/certs \
--ech-key /etc/zeroserve/ech site.tar--ech-key accepts a single PEM bundle or a directory of key files for rotation.
See the "Encrypted Client Hello" section of the user manual for the rotation,
rejection, and transparent-relay-fallback behaviors.
Send SIGHUP (or point --reload-signal-file at a file whose contents change)
to reload the site tarball, TLS certificates, and scripts atomically. The last
known-good runtime state is preserved if a reload fails.
cargo fmt --all --check # verify formatting
cargo build --release --locked
cargo test --locked # Rust unit tests
cd testing && deno test -A --parallel # end-to-end tests (TypeScript/Deno)The e2e suite launches target/release/zeroserve, so build the release binary
first. Scripting tests use the built-in tinycc backend by default; pass
--ebpf-compiler clang (and have clang/llc on PATH) to exercise the
clang path. Caddy comparison tests require a caddy binary (exposed via
CADDY_BIN).
MIT — see LICENSE.