Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.toml and WISP_-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.

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:

  1. Built-in defaults (shown in the tables below).
  2. A TOML file, if you pass one: wisp relay wisp.toml.
  3. Environment variables (WISP_*), which override the file.
  4. 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]

TOMLEnvTypeDefaultDescription
hostWISP_HOSTstring127.0.0.1Bind address. Use 0.0.0.0 only behind a reverse proxy.
portWISP_PORTu167777Listen port. HTTP (NIP-11, NIP-86, /metrics) and WebSocket share this one port.

[relay] (NIP-11 metadata)

TOMLEnvTypeDefaultDescription
nameWISP_RELAY_NAMEstringWispRelay name advertised in NIP-11.
description—stringA lightweight Nostr relayNIP-11 description.
pubkey—hex(unset)Operator pubkey in NIP-11.
contact—string(unset)Operator contact in NIP-11.

[storage]

TOMLEnvTypeDefaultDescription
pathWISP_STORAGE_PATHstring./dataLMDB data directory. Point at /dev/shm/wisp/data (tmpfs) for lowest latency; data is then lost on reboot (see warning).
map_size_mb—u3210240LMDB maximum map size in MB. The hard upper bound on database size.
syncWISP_STORAGE_SYNCenummetaWrite durability: none, meta, or full. See Durability.

Warning: /dev/shm is volatile tmpfs — all data is lost on reboot. Use it only for benchmarks or disposable caches. For production, point path at persistent storage and/or configure backups.

Durability

sync controls how aggressively LMDB flushes to disk on each commit, trading throughput for crash safety:

ModeBehaviorOn crash / power loss
noneMDB_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.
fullFsync 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]

TOMLEnvTypeDefaultDescription
max_connectionsWISP_MAX_CONNECTIONSu321000Maximum concurrent connections.
workersWISP_WORKERSu160Epoll worker threads. 0 = auto (min(CPU, 4)). Set 1 on a personal or memory-constrained relay to shed per-worker buffers and threads.
max_connWISP_MAX_CONNu160Live 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—u3220Maximum open REQ subscriptions per connection.
max_filters—u3210Maximum filters per REQ.
max_message_size—u3265536Maximum inbound WebSocket message size, in bytes.
max_event_tags—u322000Maximum tags per event.
max_content_length—u32102400Maximum event content length, in bytes.
query_limit_default—u32500Events returned per REQ when the client sets no limit.
query_limit_max—u325000Hard cap on events returned per REQ.
query_scan_multiplierWISP_QUERY_SCAN_MULTIPLIERu3220Caps 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)94608000Reject events whose created_at is older than this (default 3 years).
max_future_seconds—i64 (seconds)900Reject events dated more than this far in the future (default 15 minutes).
min_pow_difficultyWISP_MIN_POW_DIFFICULTYu80Required 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.

TOMLEnvTypeDefaultDescription
enabledWISP_WATCHDOG_ENABLEDbooltrueEnable the self-probe.
interval_secondsWISP_WATCHDOG_INTERVAL_SECONDSu3210Seconds between probes. Values below 1 are clamped to 1.
timeout_msWISP_WATCHDOG_TIMEOUT_MSu322000Deadline 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.
failuresWISP_WATCHDOG_FAILURESu323Consecutive 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]

TOMLEnvTypeDefaultDescription
events_per_minuteWISP_EVENTS_PER_MINUTEu32120Per-IP event publish rate (token bucket). 0 disables.
queries_per_minuteWISP_QUERIES_PER_MINUTEu32300Per-IP limit on expensive query messages (REQ / COUNT / NEG_OPEN). 0 disables.

[timeouts]

TOMLEnvTypeDefaultDescription
idle_secondsWISP_IDLE_SECONDSu32300Close a connection after this many seconds with no activity.

[auth] (NIP-42)

TOMLEnvTypeDefaultDescription
requiredWISP_AUTH_REQUIREDboolfalseRequire NIP-42 AUTH before any read or write.
to_writeWISP_AUTH_TO_WRITEboolfalseRequire NIP-42 AUTH before publishing events.
relay_urlWISP_RELAY_URLstring(empty)Canonical relay URL bound into the AUTH challenge. Set this to your public wss:// URL when auth is enabled.

[security]

TOMLEnvTypeDefaultDescription
trust_proxyWISP_TRUST_PROXYboolfalseHonor 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_proxiesWISP_TRUSTED_PROXIEScsv(empty)IPs/prefixes of proxies whose forwarded headers are trusted. Empty with trust_proxy=true trusts any peer.
max_connections_per_ipWISP_MAX_CONNECTIONS_PER_IPu3210Per-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_whitelistWISP_IP_WHITELISTcsv(empty)If set, only these IPs/prefixes may connect.
ip_blacklistWISP_IP_BLACKLISTcsv(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]

TOMLEnvTypeDefaultDescription
enabledWISP_SPIDER_ENABLEDboolfalseEnable spider sync.
relaysWISP_SPIDER_RELAYScsv(empty)Upstream relay URLs to pull from.
adminWISP_SPIDER_ADMINhex(empty)Pubkey whose contact list seeds the follow set.
pubkeysWISP_SPIDER_PUBKEYScsv(empty)Additional hex pubkeys to follow.
sync_intervalWISP_SPIDER_SYNC_INTERVALu32 (seconds)300Seconds between sync passes.

[negentropy] (NIP-77)

TOMLEnvTypeDefaultDescription
enabledWISP_NEGENTROPY_ENABLEDbooltrueEnable NIP-77 set reconciliation.
max_sync_eventsWISP_NEGENTROPY_MAX_SYNC_EVENTSu321000000Maximum event IDs buffered per reconciliation session.
max_sessionsWISP_NEGENTROPY_MAX_SESSIONSu324Concurrent reconciliation sessions per connection. Each can buffer up to max_sync_events IDs, so keep this small.

[management] (NIP-86)

TOMLEnvTypeDefaultDescription
admin_pubkeysWISP_ADMIN_PUBKEYScsv(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 = true in the [security] section of wisp.toml so per-IP connection limits and IP allow/deny lists see the real client IP from Caddy’s X-Forwarded-For header instead of 127.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:

RelayEvents/secp99 LatencyMemory (RSS)
Wisp25,6000.28 ms11 MB
nostr-rs-relay5,4005.9 ms20 MB
strfry2,8002.9 ms3 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