Wisp
A fast, lightweight Nostr relay written in Zig.
Wisp is a single-binary Nostr relay built for speed and simplicity. One command runs your personal relay, automatically syncing notes from the people you follow.
Why Wisp?
- Fast — 2x higher throughput than strfry, 10x lower latency.
- Small — single 1.2MB binary, ~15MB RAM at idle.
- Simple — one command to run your personal relay with your feed.
- Spider Mode — automatically syncs events from people you follow.
Features
- NIPs: 1, 2, 9, 11, 13, 16, 33, 40, 42, 45, 50, 65, 70, 77, 86
- LMDB storage (no external database)
- Spider mode for syncing events from external relays
- Import/export to JSONL
Where to start
- Installation — Docker, build from source, and import/export.
- Configuration —
wisp.tomlandWISP_-prefixed environment variables. - Deployment — exposing your relay publicly over
wss://with Caddy. - Benchmarks — throughput and latency versus strfry.
- Contributing — reporting issues and submitting pull requests.
Project links
- Source: https://github.com/privkeyio/wisp
- License: MIT
Installation
Quickstart
Docker
docker run -d --restart unless-stopped -p 127.0.0.1:7777:7777 -v wisp-data:/data ghcr.io/privkeyio/wisp --spider-admin npub1yourkey...
Build from source
Download the latest release or build from source:
# 1. Install dependencies (requires Zig 0.15+)
sudo apt install -y liblmdb-dev libsecp256k1-dev libssl-dev
# 2. Build
git clone https://github.com/privkeyio/wisp && cd wisp
zig build -Doptimize=ReleaseFast
# 3. Run (replace with your npub)
./zig-out/bin/wisp --spider-admin npub1yourkey...
That’s it. Wisp will fetch your follow list, sync notes from popular relays, and serve them
at ws://localhost:7777. Add this relay URL to your Nostr client.
For more options, see Configuration.
Import / Export
Wisp reads and writes events as JSONL, so backups and migrations are a single command:
./zig-out/bin/wisp export > backup.jsonl
./zig-out/bin/wisp import < backup.jsonl
Configuration
Wisp reads configuration from several sources, applied in order, each overriding the last:
- Built-in defaults (shown in the tables below).
- A TOML file, if you pass one:
wisp relay wisp.toml. - Environment variables (
WISP_*), which override the file. - A few CLI flags (
--spider-admin,--db), which override everything for the settings they touch.
So an environment variable always wins over the same setting in the TOML file. Copy
wisp.toml.example to
wisp.toml to start from an annotated template.
WISP_PORT=8080 ./zig-out/bin/wisp
Command line
wisp [command] [config.toml]
Commands:
relay [config] Start the relay server (default if omitted)
import Import events from stdin (JSONL)
export Export all events to stdout (JSONL)
help Show help
Flags:
--spider-admin <npub|hex> Enable spider mode and follow this pubkey's contacts
--db <path> Database path for import/export (default ./data)
The relay’s storage path comes from [storage] path / WISP_STORAGE_PATH. --db applies
to the import and export commands only. --spider-admin accepts an npub (decoded to
hex), unlike the file/env spider settings which expect raw hex.
Settings
Each setting lists its TOML key (under its [section]), its environment variable, type, and
default. Settings with no environment variable are configurable only via the TOML file.
Booleans accept true/false, 1/0, yes/no and on/off, case-insensitively.
Invalid values are treated differently depending on where they come from. In the TOML file they fail the load, naming the file, line, section and key, since the file is authored deliberately. In an environment variable they log a warning and the previous value is kept, because these usually come from an orchestrator where refusing to boot turns a typo into a crash loop. Either way the value is never discarded silently, so check the log after changing a limit if you want to be sure it took effect.
[server]
| TOML | Env | Type | Default | Description |
|---|---|---|---|---|
host | WISP_HOST | string | 127.0.0.1 | Bind address. Use 0.0.0.0 only behind a reverse proxy. |
port | WISP_PORT | u16 | 7777 | Listen port. HTTP (NIP-11, NIP-86, /metrics) and WebSocket share this one port. |
[relay] (NIP-11 metadata)
| TOML | Env | Type | Default | Description |
|---|---|---|---|---|
name | WISP_RELAY_NAME | string | Wisp | Relay name advertised in NIP-11. |
description | — | string | A lightweight Nostr relay | NIP-11 description. |
pubkey | — | hex | (unset) | Operator pubkey in NIP-11. |
contact | — | string | (unset) | Operator contact in NIP-11. |
[storage]
| TOML | Env | Type | Default | Description |
|---|---|---|---|---|
path | WISP_STORAGE_PATH | string | ./data | LMDB data directory. Point at /dev/shm/wisp/data (tmpfs) for lowest latency; data is then lost on reboot (see warning). |
map_size_mb | — | u32 | 10240 | LMDB maximum map size in MB. The hard upper bound on database size. |
sync | WISP_STORAGE_SYNC | enum | meta | Write durability: none, meta, or full. See Durability. |
Warning:
/dev/shmis volatile tmpfs — all data is lost on reboot. Use it only for benchmarks or disposable caches. For production, pointpathat persistent storage and/or configure backups.
Durability
sync controls how aggressively LMDB flushes to disk on each commit, trading throughput for
crash safety:
| Mode | Behavior | On crash / power loss |
|---|---|---|
none | MDB_NOSYNC + MDB_NOMETASYNC: no flush on commit. Fastest. | Recent commits can be lost, and the database can be corrupted. |
meta (default) | Flush data on every commit, defer only the metapage fsync. | The last transaction may roll back, but the database stays consistent. |
full | Fsync on every commit. Durable. | No acknowledged write is lost. |
In the durable modes (meta/full) writes go through a group-commit writer
thread: events that arrive close together are committed in one transaction, so a
batch pays a single fsync rather than one per event. Throughput therefore scales
with how many clients publish concurrently.
The default is meta: it never corrupts the database and at worst loses the last
transaction on a crash. Use full when no acknowledged write may ever be lost, or
none for maximum throughput on a relay where the data is disposable (a cache or
benchmark) and a crash may corrupt the database. none uses a faster synchronous
write path, since there is no fsync to amortize.
[limits]
| TOML | Env | Type | Default | Description |
|---|---|---|---|---|
max_connections | WISP_MAX_CONNECTIONS | u32 | 1000 | Maximum concurrent connections. |
workers | WISP_WORKERS | u16 | 0 | Epoll worker threads. 0 = auto (min(CPU, 4)). Set 1 on a personal or memory-constrained relay to shed per-worker buffers and threads. |
max_conn | WISP_MAX_CONN | u16 | 0 | Live connections per worker before that worker pauses accepting. 0 keeps the built-in default (8192). Distinct from max_connections, which is the relay-wide cap. Accepted connections are limited by max_conn × the effective worker count (with workers = 0 that is min(CPU, 4), not 0), while max_connections independently caps registered relay connections; whichever binds first applies. |
max_subscriptions | — | u32 | 20 | Maximum open REQ subscriptions per connection. |
max_filters | — | u32 | 10 | Maximum filters per REQ. |
max_message_size | — | u32 | 65536 | Maximum inbound WebSocket message size, in bytes. |
max_event_tags | — | u32 | 2000 | Maximum tags per event. |
max_content_length | — | u32 | 102400 | Maximum event content length, in bytes. |
query_limit_default | — | u32 | 500 | Events returned per REQ when the client sets no limit. |
query_limit_max | — | u32 | 5000 | Hard cap on events returned per REQ. |
query_scan_multiplier | WISP_QUERY_SCAN_MULTIPLIER | u32 | 20 | Caps entries scanned per query at limit × multiplier before stopping, so a selective filter cannot page-fault the whole event DB. 0 disables the cap. |
max_event_age | — | i64 (seconds) | 94608000 | Reject events whose created_at is older than this (default 3 years). |
max_future_seconds | — | i64 (seconds) | 900 | Reject events dated more than this far in the future (default 15 minutes). |
min_pow_difficulty | WISP_MIN_POW_DIFFICULTY | u8 | 0 | Required NIP-13 proof-of-work leading-zero bits. 0 disables. |
[watchdog]
Detects a wedged accept loop: a state where the kernel completes TCP handshakes
but the relay never services them, leaving the process alive at near-zero CPU
while every connection hangs. A background thread opens a loopback connection to
the relay’s own listener and expects any HTTP reply. After failures
consecutive stalls it terminates the process so a supervisor restarts it.
This means the relay can exit on its own. It only does so when it has
answered at least one probe successfully (so a slow start or an environment where
loopback probing cannot work never triggers it), and never when the relay
accepted a connection since the previous probe. Connection refusals and local
descriptor exhaustion are logged but never counted, since a restart fixes
neither. Set enabled = false to opt out entirely.
The accept check is what makes this specifically an accept-loop watchdog. httpz
counts an accept before any handler runs, so a probe that is accepted and then
goes unanswered vetoes itself: a relay whose handlers are slow or whose thread
pool is saturated is degraded rather than wedged, and restarting it would turn a
slowdown into an outage. Only a relay that never accepts the probe at all can
reach the exit path. httpz_connections on /metrics is that same accept
counter, so comparing it against wisp_connections_total (completed WebSocket
upgrades) shows whether the accept loop is running while work backs up.
Because the process exits deliberately, run wisp under something that restarts
it (--restart unless-stopped for Docker, Restart=always for systemd).
Without a supervisor, a wedge that used to leave a degraded relay running will
instead leave it stopped.
| TOML | Env | Type | Default | Description |
|---|---|---|---|---|
enabled | WISP_WATCHDOG_ENABLED | bool | true | Enable the self-probe. |
interval_seconds | WISP_WATCHDOG_INTERVAL_SECONDS | u32 | 10 | Seconds between probes. Values below 1 are clamped to 1. |
timeout_ms | WISP_WATCHDOG_TIMEOUT_MS | u32 | 2000 | Deadline for a single probe, covering connect, send and reply. Clamped to 100–5000; the upper bound also caps how long a shutdown can wait on a probe already in flight. |
failures | WISP_WATCHDOG_FAILURES | u32 | 3 | Consecutive stalled probes before exiting. Raised automatically when interval_seconds × failures would be short enough to fire before the relay reaps stalled connections, so that connection pressure cannot force a restart. At interval_seconds = 1 the effective value becomes 26; the raise is logged at startup. The shipped defaults are already sufficient and are left untouched. |
[rate_limits]
| TOML | Env | Type | Default | Description |
|---|---|---|---|---|
events_per_minute | WISP_EVENTS_PER_MINUTE | u32 | 120 | Per-IP event publish rate (token bucket). 0 disables. |
queries_per_minute | WISP_QUERIES_PER_MINUTE | u32 | 300 | Per-IP limit on expensive query messages (REQ / COUNT / NEG_OPEN). 0 disables. |
[timeouts]
| TOML | Env | Type | Default | Description |
|---|---|---|---|---|
idle_seconds | WISP_IDLE_SECONDS | u32 | 300 | Close a connection after this many seconds with no activity. |
[auth] (NIP-42)
| TOML | Env | Type | Default | Description |
|---|---|---|---|---|
required | WISP_AUTH_REQUIRED | bool | false | Require NIP-42 AUTH before any read or write. |
to_write | WISP_AUTH_TO_WRITE | bool | false | Require NIP-42 AUTH before publishing events. |
relay_url | WISP_RELAY_URL | string | (empty) | Canonical relay URL bound into the AUTH challenge. Set this to your public wss:// URL when auth is enabled. |
[security]
| TOML | Env | Type | Default | Description |
|---|---|---|---|---|
trust_proxy | WISP_TRUST_PROXY | bool | false | Honor X-Forwarded-For / X-Real-IP for the client IP. Enable only behind a reverse proxy whose backend port is not directly reachable, or clients can spoof their IP. |
trusted_proxies | WISP_TRUSTED_PROXIES | csv | (empty) | IPs/prefixes of proxies whose forwarded headers are trusted. Empty with trust_proxy=true trusts any peer. |
max_connections_per_ip | WISP_MAX_CONNECTIONS_PER_IP | u32 | 10 | Per-IP concurrent connection cap, applied at the WebSocket upgrade. A connection that never sends a request does not reach it; those are closed by the 10s request timeout instead. To cap concurrent TCP connections per source, see Limiting connections per source. |
ip_whitelist | WISP_IP_WHITELIST | csv | (empty) | If set, only these IPs/prefixes may connect. |
ip_blacklist | WISP_IP_BLACKLIST | csv | (empty) | These IPs/prefixes are refused. |
IP list entries match exactly unless they end in . (IPv4 prefix) or : (IPv6 prefix), e.g.
10.0.0. matches 10.0.0.0–10.0.0.255. CIDR notation and * wildcards are not supported: an
entry like 10.0.0.0/8 or 192.168.* is rejected at startup with a warning naming it, rather
than being accepted and then matching nothing. The same applies to trusted_proxies.
[spider]
| TOML | Env | Type | Default | Description |
|---|---|---|---|---|
enabled | WISP_SPIDER_ENABLED | bool | false | Enable spider sync. |
relays | WISP_SPIDER_RELAYS | csv | (empty) | Upstream relay URLs to pull from. |
admin | WISP_SPIDER_ADMIN | hex | (empty) | Pubkey whose contact list seeds the follow set. |
pubkeys | WISP_SPIDER_PUBKEYS | csv | (empty) | Additional hex pubkeys to follow. |
sync_interval | WISP_SPIDER_SYNC_INTERVAL | u32 (seconds) | 300 | Seconds between sync passes. |
[negentropy] (NIP-77)
| TOML | Env | Type | Default | Description |
|---|---|---|---|---|
enabled | WISP_NEGENTROPY_ENABLED | bool | true | Enable NIP-77 set reconciliation. |
max_sync_events | WISP_NEGENTROPY_MAX_SYNC_EVENTS | u32 | 1000000 | Maximum event IDs buffered per reconciliation session. |
max_sessions | WISP_NEGENTROPY_MAX_SESSIONS | u32 | 4 | Concurrent reconciliation sessions per connection. Each can buffer up to max_sync_events IDs, so keep this small. |
[management] (NIP-86)
| TOML | Env | Type | Default | Description |
|---|---|---|---|---|
admin_pubkeys | WISP_ADMIN_PUBKEYS | csv | (empty) | Hex pubkeys allowed to run NIP-86 relay-management commands (ban/allow pubkeys and IPs, etc.). |
Spider mode
Spider mode keeps your relay populated by syncing events from the people you follow. Set
admin to your hex pubkey (or pass --spider-admin npub1... on the CLI) and Wisp fetches
your contact list and mirrors their notes from the configured relays:
[spider]
enabled = true
relays = "wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band"
sync_interval = 300
admin = "" # your hex pubkey
Monitoring
Operational metrics are served in Prometheus format at GET /metrics on the relay port:
connection counts, events stored/rejected/broadcast, REQ totals, and rate-limit counters. The
endpoint honors ip_whitelist/ip_blacklist, so restrict it there or at your reverse
proxy/firewall if it should not be public.
The httpz_* series come from the embedded HTTP server. The useful one is
httpz_connections, which counts accepted TCP connections before any handler runs. Read
against wisp_connections_total (completed WebSocket upgrades) it separates two states that
look identical from outside: if accepts climb while upgrades stall, the accept loop is running
and work is backing up; if accepts stop climbing while clients are still connecting, the accept
loop itself is stuck. httpz_timeout_request and httpz_timeout_keepalive count connections
reaped for holding a slot without completing a request.
Deployment
To make your relay publicly accessible with TLS, run Wisp behind Caddy, which provisions and renews certificates automatically.
# Run wisp
docker run -d --restart always -p 127.0.0.1:7777:7777 -v wisp-data:/data \
ghcr.io/privkeyio/wisp --spider-admin npub1yourkey...
# Install Caddy for automatic TLS
sudo apt install -y caddy
Create /etc/caddy/Caddyfile:
relay.yourdomain.com {
reverse_proxy localhost:7777
}
Reload Caddy:
sudo systemctl restart caddy
Your relay is now live at wss://relay.yourdomain.com.
Behind a proxy: set
trust_proxy = truein the[security]section ofwisp.tomlso per-IP connection limits and IP allow/deny lists see the real client IP from Caddy’sX-Forwarded-Forheader instead of127.0.0.1.
Limiting connections per source
max_connections_per_ip is applied during the WebSocket upgrade, because that is the first
point at which wisp knows the client IP. A connection that completes the TCP handshake and
then sends nothing never reaches it. Those connections are bounded — they are closed after
10s if no request arrives — but until then they occupy a worker slot, so a single source can
hold max_conn slots per worker by reconnecting.
Capping concurrent connections per source address closes that, and it belongs at the edge,
where connections arrive. Apply it wherever the public port is, not on the relay port.
Behind a reverse proxy every connection reaches wisp from 127.0.0.1, so a per-IP rule on the
relay port would either do nothing or throttle the proxy itself.
Exposed directly — limit on the relay port:
# nftables
nft add rule inet filter input tcp dport 7777 ct count over 10 reject
# iptables
iptables -A INPUT -p tcp --dport 7777 \
-m connlimit --connlimit-above 10 --connlimit-mask 32 -j REJECT
Behind a proxy — limit on the public port instead:
iptables -A INPUT -p tcp --dport 443 \
-m connlimit --connlimit-above 10 --connlimit-mask 32 -j REJECT
Caddy has no built-in per-connection limiter, so with the Caddyfile above the firewall rule is the control. If you front wisp with nginx instead, it can do this itself:
limit_conn_zone $binary_remote_addr zone=perip:10m;
server {
listen 443 ssl;
location / {
limit_conn perip 10;
proxy_pass http://127.0.0.1:7777;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
Set the limit above max_connections_per_ip (default 10) so the edge only rejects what wisp
would have refused anyway. --connlimit-mask 32 counts per address; lower it to limit per
subnet. Note that any of these only constrain a single source: they do not help against a
flood distributed across many addresses, which is what max_conn and the request timeout
bound.
Benchmarks
Peak write throughput, all relays at 100% delivery:
| Relay | Events/sec | p99 Latency | Memory (RSS) |
|---|---|---|---|
| Wisp | 25,600 | 0.28 ms | 11 MB |
| nostr-rs-relay | 5,400 | 5.9 ms | 20 MB |
| strfry | 2,800 | 2.9 ms | 3 MB |
Measured with nostr-bench: 5,000 events x 4
workers at peak rate (--rate 0), native release builds on a 16-core Linux host, with the
event-rate limit raised. RssAnon sampled during the run; figures are representative of three
stable iterations.
This is peak write capability with Wisp in sync = none (non-durable). Wisp’s default is
sync = meta (durable, never corrupts), which trades raw throughput for crash safety; in
the durable modes throughput scales with publisher concurrency because writes are
group-committed (see Configuration). strfry and nostr-rs-relay
write durably by default, so a fair durable-vs-durable comparison runs Wisp in meta/full.
On throughput and p99 latency Wisp leads; strfry stays smallest in memory.
Contributing
Contributions are welcome. Wisp’s guiding principles are fast, lightweight, and simple — feature requests and changes should align with those goals.
See CONTRIBUTING.md for the
full guidelines, including the issue policy, branch naming, commit conventions, the NIP
implementation policy, and development setup.
Development setup
# Install dependencies (Ubuntu/Debian)
sudo apt install -y liblmdb-dev libsecp256k1-dev libssl-dev
# Clone and build
git clone https://github.com/privkeyio/wisp && cd wisp
zig build
# Run tests
zig build test
# Build optimized release
zig build -Doptimize=ReleaseFast
# Format code
zig fmt src/*.zig