Reviewed-on: #1
nostermd
A minimal NIP-29 managed-group relay for
Nosterm, built on
khatru (part of the fiatjaf.com/nostr monorepo),
plus the Nosterm capability handshake — a features array in its NIP-11 document so a
Nosterm client can feature-gate its UI and fall back gracefully on plain relays.
This is the "home relay" in Nosterm's hybrid model: the client connects to this relay for its own communities while still connecting to public relays for general Nostr content.
Mission
nostermd aims to be the premier self-hosted home relay for the Nosterm Nostr Relay Chat (NRC) client — a complete, standards-based NIP-29 group-chat backend anyone can run. We believe private communication is a right, not a feature, so the relay is built to advance anonymity, end-to-end encryption, and censorship resistance by default rather than as opt-in extras.
While we build features specifically to support the Nosterm client — the Nosterm capability handshake being the first — we are committed to standards over lock-in. We are more than happy to fold our developments into existing NIPs (or propose new ones) wherever the protocol can absorb them, so that what we build stays interoperable with the wider Nostr ecosystem rather than fragmenting into a Nosterm-only dialect.
Three principles guide every decision:
- Small footprint. A single pure-Go static binary with no cgo, running on a
scratch/distroless image. It should stay auditable, fast to start, and cheap to self-host — no telemetry, no lock-in, no heavyweight dependencies. - Easy to deploy, anywhere. One prebuilt image, configured at runtime, that drops cleanly onto different overlay network protocols (Tor, I2P, Yggdrasil, Nym, and WireGuard-style meshes) so operators can run it wherever they trust the transport.
- Privacy as the default. Anonymity, encryption, and anti-censorship are the baseline behavior. The relay should make the private path the easy path for the client that talks to it.
Roadmap
The roadmap turns the mission into concrete direction. Items are aspirational and unordered — contributions that move any of them forward are welcome.
- Full NIP-29 implementation — grow beyond the MVP subset into complete relay-based groups: private/closed groups, roles, invites, the 39001/39002 admin/member lists, and richer moderation so a self-hosted relay is a complete NRC backend.
- Overlay-network first — first-class, documented deployment recipes for Tor hidden services, I2P, and other overlay transports, keeping the same single runtime-configured image.
- Encryption everywhere — support modern encrypted group and direct messaging (e.g. NIP-17 / gift-wrapped events) end to end, with no plaintext fallbacks that surprise operators or their users.
- Anonymity by design — minimize metadata retained and leaked, support ephemeral and disposable identities, and avoid any relay behavior that fingerprints or phones home.
- Censorship resistance — deepen federation so a channel survives any single relay disappearing and can be distributed across operators, with graceful reconnection and replay.
- Client-driven, standards-first — build the features the Nosterm client needs, but upstream them: contribute our extensions back into existing NIPs (or propose new ones) rather than growing a Nosterm-only dialect, keeping the relay useful to any Nostr client.
- Stay small — hold the line on binary size, dependency count, and container footprint as features grow.
Why a separate repository
The Nosterm client and the nostermd relay ship as two independent repositories — and two independent container images — on purpose. Nostr is a client-side protocol: the browser talks directly to relays over WebSocket, so there is no shared backend binding the two together. Keeping them separate keeps that boundary honest and the architecture modular:
- Mix and match. The client is just a Nostr client — it works against any relay, not only this one. The relay is just a Nostr relay — it serves any NIP-29 client, not only Nosterm. Neither depends on the other's internals, so you can run one without the other.
- Deploy each where it belongs. A static SPA served by Caddy and a stateful Go relay
with a BoltDB store have different runtime, scaling, and persistence needs. Separate
images let operators place, scale, and secure each independently — a shared home relay
behind one client, one relay per client, many clients against a public relay, or the
same-origin
/relayproxy setup. - Standards over coupling. Because the split forces everything across the boundary to travel as plain Nostr events, it keeps us honest about being standards-first (see Mission) — the relay can never quietly grow a private, client-only channel that a non-Nosterm client couldn't use.
- Independent lifecycles. Each repo has its own issues, releases, and contribution flow. A relay change ships without rebuilding the client and vice versa, so the two can evolve (and be audited) at their own pace.
If you want the batteries-included experience, the client's Caddy service can proxy /relay
to this relay on a shared Docker network — modular pieces, one deployment. But that's a
composition choice at deploy time, not a coupling baked into the code.
What it does
- Serves NIP-01/11/42 via khatru, with pure-Go BoltDB
storage (no cgo → a fully static binary that runs on
scratch/distroless). - Enforces a focused subset of NIP-29 managed groups:
Kind Event Behavior 9007 create group registers a group 9021 join request adds the sender as a member (auto-creates) 9022 leave request removes the sender 9000 put user (admin) admin adds tagged members 9001 remove user admin removes tagged members 9/10 chat / reply accepted for members (open groups: anyone) 39000 group metadata (re)published + signed by the relay - Advertises
software: "nostermd"andfeatures: ["channels"]in NIP-11 (adds"federation"when any federation peer is configured). - Seeds a default channel on boot so a fresh relay is never empty — a client always has
at least one open room (
#generalby default) to join. Configure withRELAY_DEFAULT_CHANNELS. - Optional federation — mirror/ingest chat with peer relays (see below).
MVP scope. Groups are auto-created as open on first join so the client flow works without an invite system. Membership is authoritative in-memory; richer moderation, private/closed groups, roles, and the 39001/39002 admin/member lists are future work.
Project layout
Standard Go layout — the command is a thin composition root over focused, independently-testable packages:
nostermd/
cmd/nostermd/ # main: env config, key loading, khatru wiring, NIP-11 serving
internal/
group/ # NIP-29 managed-group state machine (membership, admin, metadata)
federation/ # optional chat mirroring/ingest with peer relays
retention/ # background pruning of old chat messages
relayinfo/ # Nosterm capability handshake (NIP-11 features array)
Dockerfile # static binary → distroless nonroot image
docker-compose.yml
Run it
# Local (Go 1.25+)
go run ./cmd/nostermd # listens on :3334, ephemeral identity + ./data
# Configurable via env
RELAY_ADDR=:3334 \
RELAY_DATA_DIR=./data \
RELAY_NAME="My Relay" \
RELAY_SECRET_KEY=<64-char-hex> \ # stable identity (signs group metadata)
go run ./cmd/nostermd
# Docker (single image)
docker build -t nostermd .
docker run -p 3334:3334 -v nostermd-data:/data nostermd
# Or pull the prebuilt public image (no auth):
docker run -p 3334:3334 -v nostermd-data:/data public.ecr.aws/k3k1z1x5/nostermd:latest
Merges to main build and publish that image to ECR Public; v* tags publish a
matching versioned image. This repo builds the image only — it does not deploy.
Docker Compose (recommended)
cp .env.example .env
# For production, generate a stable signing identity and set RELAY_ENV:
echo "RELAY_SECRET_KEY=$(openssl rand -hex 32)" >> .env
echo "RELAY_ENV=production" >> .env
docker compose up -d --build
The relay then listens on ws://localhost:3334 and persists its BoltDB event store to a
named volume so rooms/messages survive restarts.
Point a Nosterm client at it via PUBLIC_DEFAULT_RELAYS — see the
nosterm-client repository.
Production TLS
The relay speaks plain WebSocket on :3334. For public wss:// access, put it behind a
TLS-terminating reverse proxy (e.g. Caddy, or the
nosterm-client Caddy service, which
can proxy /relay to this relay on a shared Docker network).
Environment
| Variable | Default | Meaning |
|---|---|---|
RELAY_ADDR |
:3334 |
Listen address |
RELAY_DATA_DIR |
./data |
BoltDB storage directory |
RELAY_NAME |
Nosterm Home Relay |
NIP-11 name |
RELAY_DESCRIPTION |
(default text) | NIP-11 description |
RELAY_CONTACT |
relay@nosterm.com |
NIP-11 contact (NIP-05-style operator address) |
RELAY_MOTD |
(nerdworks.io banner) | Message-of-the-day shown in the client's relay server window (defaults to the built-in ascii banner) |
RELAY_ENV |
(dev) | Set to production to require a stable key (fail-fast if unset) |
RELAY_DEFAULT_CHANNELS |
general |
Comma-separated channel ids seeded as open groups on boot (leading # ok) |
RELAY_SECRET_KEY |
(dev: persisted) | 64-char hex identity that signs group metadata/rosters |
RELAY_RETENTION_DAYS |
0 |
Prune chat messages older than N days (0 = unlimited) |
RELAY_RETENTION_MAX_MESSAGES |
0 |
Keep at most N chat messages, newest first (0 = unlimited) |
RELAY_RETENTION_INTERVAL_MINUTES |
60 |
How often the retention sweep runs |
RELAY_FEDERATION_PEERS |
(empty: disabled) | Peer relays to mirror/ingest chat with (see Federation) |
Relay identity (RELAY_SECRET_KEY)
The relay's key signs the group metadata/admins/members events (39000–39002). A stable identity is required so those signatures stay valid across restarts:
- Production (
RELAY_ENV=production):RELAY_SECRET_KEYis required — the relay refuses to start without it. Generate one withopenssl rand -hex 32. - Development: if unset, a key is generated and persisted to
$RELAY_DATA_DIR/relay.key(mode0600) and reused on every restart, so even local dev keeps one identity.
Retention
By default nothing is pruned. Set RELAY_RETENTION_DAYS and/or
RELAY_RETENTION_MAX_MESSAGES to cap growth. Retention only deletes chat messages
(kinds 9/10); group-management and metadata events (9007/9000/9001/9021/9022, 39000–39002)
are never pruned so membership always survives and can be replayed on boot.
Federation (optional)
Federation lets a channel live on more than one relay, so it survives any single relay going down and can be distributed across operators. It is off by default and chat-only by design: only kind-9/10 messages cross the relay boundary. Membership, admins, bans, and metadata stay local to each relay — every operator moderates their own instance, which sidesteps cross-relay moderation conflicts entirely.
Configure peers with RELAY_FEDERATION_PEERS. Entries are separated by ;; each is:
<wss-url>[|<mode>:<channels>]
- mode —
mirror(bidirectional: chat posted on either relay is forwarded to the other) oringest(read-only: pull chat from the peer, never push back). Defaults tomirror. - channels — a comma-separated list of group ids, or
*for all. Defaults to*. A leading#is tolerated.
Examples:
# Mirror two channels with peer A, ingest everything from peer B (read-only):
RELAY_FEDERATION_PEERS="wss://a.example|mirror:general,dev;wss://b.example|ingest:*"
# Read-only announcement feed pulled from an upstream relay:
RELAY_FEDERATION_PEERS="wss://announce.example|ingest:announcements"
# Simplest: fully mirror every channel with one peer:
RELAY_FEDERATION_PEERS="wss://peer.example"
How it works. The relay opens a client subscription to each peer for the federated chat
kinds and injects received events through its own add pipeline (OnEvent → store →
OnEventSaved), then broadcasts them to local subscribers. Locally-saved chat is forwarded
to mirror peers from the OnEventSaved hook. Incoming events are re-verified (a peer
can't inject forged messages) and scoped to the configured channels.
Loop prevention is structural, not tag-based. A re-received event is a duplicate in the
eventstore, and AddEvent stops on ErrDupEvent before the egress hook runs — so an event
is never forwarded twice and A↔B↔A cycles die on the second sight. There is no "seen" set to
tune or get wrong. Peers reconnect with capped backoff; on reconnect the peer replays stored
events so nothing missed during an outage is lost (deduped on arrival).
Trust model. Federate only with relays you trust to authorize their own posters — a federated channel behaves as open on every participating relay (federated chat bypasses local membership because the author is a member on the peer that accepted it). Federation is strictly chat; it never imports another relay's membership, admin, or ban state.
Tests
go test ./...
Tests live beside the package they cover:
internal/group— the group state machine: join → chat allowed, chat-before-join rejected, missingh-tag rejected, multi-user administrator moderation in a closed group (creator is admin; non-admins cannot add/remove users; admin-approved members can post; removed members are blocked; open groups allow anyone), and state survives a rebuild from the event store.internal/retention— prunes old/surplus chat by age and count but never management/metadata events.internal/federation— config parsing (modes, channel scoping, invalid-URL/empty-list rejection), ingress signature + scope enforcement, and the loop-prevention contract.cmd/nostermd— integration over a real in-process khatru server: relay-key handling (explicit key used verbatim; dev key persisted and stable across restarts), a cross-user WebSocket round-trip (one client joins and publishes, another receives via subscription), default-channel seeding (a fresh relay seeds its configured open channels, is idempotent, and never reopens a pre-existing closed channel), and two end-to-end federation two-relay tests (a message ingested across relays, and a mirrored post delivered exactly once despite the echo path).
Run without
-race: go-nostr'sunsafe-based JSON serializer trips the race detector'scheckptrduring any event signing (an upstream library issue, not a relay data race). The relay's own group-state locking is race-clean — verify with the state-machine tests that don't sign events:go test ./internal/group/ -race -run 'TestAdminModerationInClosedGroup|TestOpenGroup'.