# 13 — Relay Operations for an App Team

*When an app team should run its own Nostr relay, which implementation to run, how to bridge one moderation policy across backends, and the archive/monitoring/backup/spam disciplines none of these ship with by default.*

**Verified: 2026-07-10**

Confidence overview: High for strfry, khatru, and ditto-relay mechanics — all fetched directly from their own GitLab/GitHub READMEs this pass. High for the arXiv economics findings — the paper was fetched directly rather than trusted secondhand, and one correction is logged below: the companion audit's "median zap income ≈$67/6mo" framing understates the problem; the paper's actual figure is a 90th-percentile number among the minority of relays that receive zaps at all. High for NIP-13, NIP-66, and NIP-77 (spec text fetched directly). High for the `@nostrify/strfry` bridge mechanism (fetched directly); Medium for "the same policy runs on Ditto directly," which is architectural inference (Ditto is Nostrify-native — ch. 11) rather than a demonstrated example. Medium for ditto-relay's CAUTION verdict — real product, real docs, but "no known external production use" is an absence-of-evidence finding. Medium-Low for PlebOne's self-reported "zero spam" claim — no independent audit found. Medium for backup guidance, which is generic infrastructure discipline applied into a confirmed documentation gap, not a Nostr-specific practice.

---

## Why Run Your Own Relay At All

The short version: because on the public network, nobody else is contractually keeping your users' data.

Wei and Tyson's empirical study of the Nostr relay ecosystem [1], measuring 712 relays over a six-month window (2023-07-01 to 2023-12-31), fetched directly for this chapter:

- **95% of free-to-use relays cannot cover their operating costs from zap income alone** [1].
- The zap-income picture is worse than a median framing suggests. **64% of relays received zero zap transactions in the entire window.** Among the roughly one-third that received *any* zaps, the **90th percentile totaled only ~150,000 sats (~$67) over six months** [1]. There is effectively no meaningful "median zap income" to quote — most relays' zap income is zero, and even the strong performers among the rest are not making a living.
- **Most paid relays in the 50-1,000-user "medium" tier can cover their operating costs** [1] — the inverse finding, and the basis for the budget note near the end of this chapter.
- No relay or Blossom media host anywhere offers a contractual SLA. This is not from the paper — it is an exhaustive-search finding from the maintainers' companion production-audit notes (§2 fact 1): zero found, searched exhaustively, across every relay and Blossom host list.

Put together: **public relays are a cache, not a database.** They are mostly run by enthusiasts, funded by donations that mostly never arrive, with no contractual promise to keep an event past today. The NIP-65 outbox model (ch. 06) already assumes multiple write relays for redundancy — but redundancy across relays that all share the same economic exposure is not durability, it is the same failure mode running in parallel.

**The rule this chapter follows:** if your app has any user whose data needs to outlive the current session, run one relay whose only job is to be the source of truth. Every public relay your app also writes to per NIP-65 is then correctly understood as reach and redundancy — not as the place you depend on for durability.

**Archive relay now; public-facing community relay later.** These are two different decisions with two different risk profiles, and this chapter is scoped to the first one. An archive relay that only pulls copies of your own app's events and never accepts public writes carries the durability benefit above with almost none of the downside — it isn't "mere hosting" for anyone else's content. A public-facing relay that accepts writes from strangers is a heavier legal class: Section 230 does not cover federal criminal CSAM liability, and EU DSA liability attaches on actual notice plus failure to act (ch. 15). Stand up the archive relay now; defer opening it to public writes until a moderation posture — report queues, a takedown process, a human in the loop — actually exists.

## Implementation Menu

| Relay | Stack | Best for | Maturity / adoption | Operational notes |
|---|---|---|---|---|
| **strfry** | C++ / LMDB, no external DB | Your archive relay; raw throughput | Mature — runs on **~23% of reachable relays**, the most-deployed single implementation after nostr-rs-relay [16] | Originated the negentropy sync protocol now used ecosystem-wide [4]; `strfry export --fried` / `strfry import` moves JSONL; `strfry sync <url>` (up/down/both) reconciles against a remote relay; first-party Prometheus `/metrics` [4] |
| **khatru** | Go framework, not a turnkey relay | Bespoke relay logic — custom accept/reject policies, custom AUTH, custom storage | Framework-maturity, judged by what's built on it — GRASP's reference implementation `ngit-relay` is a khatru relay [6] | Hook-based: `StoreEvent` / `QueryEvents` / `DeleteEvent` / `RejectEvent` callback arrays [5]; pairs with the `eventstore` module for SQLite/Postgres/LMDB-backed storage [5] |
| **Ditto's built-in `/relay`** | Deno + PostgreSQL | Teams already self-hosting Ditto for community ops — the relay ships free inside the server | Mature, ~2 years of public Ditto releases (ch. 02) | Recommended VPS: 4 cores / 8GB RAM / 100GB disk [13]; NIP-50 full-text search via Postgres; full self-host runbook already lives in [ch. 02](02-community-ops.md) — not repeated here |
| **ditto-relay** (separate product) | Bun runtime + OpenSearch | Apps that need NIP-50 search with `language:` / `sentiment:` / `media:` filters plus a trending engine feeding NIP-85 signed assertions | **CAUTION** — born 2026-02-08, ~5 months old; 99.3% single-author; no known external production deployment | NIPs: 01/09/11/40/42/45/50/62/70/77/85 [2]; worker pool keeps signature verification, language detection, sentiment analysis, and media detection off the hot path [2]; stateless, scales horizontally behind a load balancer [2]; Prometheus `/metrics` [2]; **`NOSTR_NSEC` is a required plain env var** — flag this now, full treatment in ch. 15 (secrets) |

## The @nostrify/strfry Policy Bridge

Nostrify's moderation model is `NPolicy` — a class with one method that looks at an event and returns accept or reject (ch. 11). Nineteen ship in `@nostrify/policies`, from `WoTPolicy` to `PowPolicy` to an OpenAI-backed content-scoring policy. The problem this section solves: strfry doesn't speak TypeScript, so how does a Deno/TS moderation policy reach a C++ relay?

`@nostrify/strfry` is the bridge. strfry's own plugin interface reads accept/reject decisions over stdin/stdout; the package wraps that protocol so any composed `NPolicy` can sit behind it [3]. Its own example composes six policies through `PipePolicy` — `FiltersPolicy` (kind allow-listing), `KeywordPolicy` (banned phrases, e.g. Telegram-link spam), `RegexPolicy`, `PubkeyBanPolicy`, `HellthreadPolicy` (reply-storm limiting), and `AntiDuplicationPolicy` (Deno KV-backed) — then a single call connects it to strfry [3]. In shape:

```ts
const policy = new PipePolicy([
  new FiltersPolicy([/* allowed kinds */]),
  new KeywordPolicy([/* banned phrases */]),
  new PubkeyBanPolicy([/* banned pubkeys */]),
  // WoTPolicy / PowPolicy from the same @nostrify/policies library slot in the same way
]);
await strfry(policy); // wires the composed policy to strfry's stdin/stdout plugin interface
```

The same policy object also runs directly inside Ditto, since Ditto is Nostrify-native (ch. 11) — write the moderation set once, point it at either backend. That is the whole value of the bridge: abuse and spam rules stop being a strfry-specific plugin script or a Ditto-specific admin toggle and become one portable TypeScript module.

## The Archive Pattern

Your archive relay's job is to hold a durable copy of every event your app or its users have written — not to serve production traffic.

```mermaid
flowchart TB
    subgraph App["App team"]
        A["App / client<br/>NIP-65 outbox: 2-4 write relays"]
    end
    subgraph Public["Public write relays — cache, not source of truth"]
        R1["Relay A<br/>free or paid, no SLA"]
        R2["Relay B<br/>free or paid, no SLA"]
        R3["Relay N ..."]
    end
    subgraph Own["Own archive relay — source of truth"]
        S["strfry<br/>C++ / LMDB"]
    end
    CS[("Cold storage<br/>object store / offsite disk")]
    A -->|publish| R1
    A -->|publish| R2
    A -->|publish| R3
    R1 -.strfry sync, pull.-> S
    R2 -.strfry sync, pull.-> S
    R3 -.strfry sync, pull.-> S
    S -->|"cron: export --fried (JSONL)"| CS
```

Three moving parts:

1. **Sync in.** `strfry sync <relay-url> --dir down` pulls everything from each relay your app writes to, using negentropy set-reconciliation so only the delta transfers [4]. Run this against every relay in your app's NIP-65 write set, on a schedule.
2. **Export out.** `strfry export --fried` writes JSONL with precomputed `fried` elements to stdout; cron it to cold storage — object store or offsite disk — on whatever cadence matches your data's value [4].
3. **The negentropy caveat.** NIP-77, the spec negentropy implements, is merged into the NIPs repo as **draft, optional** [15]. "Merged" describes the spec's repo status, not relay adoption — most public relays you sync from will not support it, `strfry sync` falls back to slower reconciliation against those, and your schedule should assume the slow path is the common path, not the exception.

**Minimum viable archive setup:**

1. Provision a small VPS — strfry's LMDB footprint is modest, and there is no separate database process to run [4].
2. Install strfry, point its config at local storage, start it as a service [4].
3. For each relay in your app's NIP-65 write set, run `strfry sync <relay-url> --dir down` once to seed the archive from history [4].
4. Cron the same sync command going forward — hourly or daily depending on write volume.
5. Cron `strfry export --fried > backup-$(date +%F).jsonl` and ship the file to cold storage [4].
6. Wire `/metrics` into whatever Prometheus instance you already run (Monitoring, below) [4].
7. Do not open the relay to public writes — see the archive-vs-community-relay distinction above.

## Monitoring

| Surface | What it gives you | Coverage | Notes |
|---|---|---|---|
| NIP-66 / nostr.watch | Liveness + capability observations published as ordinary Nostr events — kind `30166` discovery events, kind `10166` monitor announcements [7] | **8 automated monitoring stations across 6 continents** [8] | Decentralized by design — anyone can run a competing monitor; no single authority owns the data [8] |
| First-party Prometheus | `/metrics` scraped directly off your own relay process | Ships in strfry [4], nostr-rs-relay [14], and ditto-relay [2]; Ditto (Deno+Postgres) documents metrics too [12] | No first-party alerting rules ship with any of them |
| Community Grafana dashboard | Pre-built panels for Ditto's Prometheus output | Dashboard ID **21844** [11]; a second, independent community project (`Ditto-Dash`) also exists | Neither is a Soapbox first-party artifact |

No alerting norms exist anywhere in this stack — no shipped Alertmanager rules, no documented paging thresholds, nothing Nostr-specific. Inherit generic Prometheus/Alertmanager discipline (disk-full, process-down, sync-lag, queue-depth) the same way you would for any other self-hosted service; nothing about Nostr changes what a relay alert should watch for.

## Backup

| Component | Backup mechanism | Status |
|---|---|---|
| Ditto (Postgres) | Generic `pg_dump` / pgBackRest discipline | No first-party Ditto backup runbook exists — confirmed absent across two research passes; you own this |
| strfry (LMDB) | `strfry export --fried` to JSONL, already covered under Archive Pattern above | Doubles as both your sync mechanism and your backup/restore mechanism |
| ditto-relay (OpenSearch) | OpenSearch's own snapshot/restore API | Generic OpenSearch discipline — the product itself is too young to have documented backup guidance |

The pattern across this whole stack: newer, thinner products document search and moderation in detail and say nothing about disaster recovery. Treat that absence as the default, not the exception, and provision backup discipline yourself regardless of which relay you run.

## Spam and Abuse Controls

| Control | Mechanism | Precedent |
|---|---|---|
| Proof of work | NIP-13: difficulty = leading zero bits of the event id; a relay advertises its floor via NIP-11's `min_pow_difficulty` and rejects anything below it [9] | `PowPolicy` ships in `@nostrify/policies` (ch. 11) — same bridge as above |
| Relay-level auth | NIP-42: gate `REQ`/`EVENT` behind an authenticated pubkey before accepting writes or serving reads (ch. 06 — stable, gates paid and DM-sensitive traffic) | khatru exposes custom AUTH handlers [5]; Ditto and strfry both support NIP-42 |
| Web-of-trust filtering | Accept events only from pubkeys within N hops of a trusted set | **PlebOne** (`relay.pleb.one`): a free, public WoT relay self-reporting **zero spam** — no payment required, the trust graph does the filtering instead [10] |

`WoTPolicy` ships alongside `PowPolicy` in the same `@nostrify/policies` library (ch. 11) — both the PlebOne pattern and the PoW pattern above are one `PipePolicy` line away, on either backend.

## Budget

The economics in the first section converge on one number: **at least one relay in your budget should be paid.** Free relays fail the durability test 95% of the time; medium-scale paid relays (50-1,000 users) mostly clear their costs [1]. This does not require a commercial paid relay service specifically — a VPS you pay for and control is functionally a paid relay in the paper's own terms: money in, durability out. What it rules out is treating an entirely free relay stack as your durability plan. Line-item this before scoping the app budget, not after.

## TresPies Hooks

What follows is the maintainers' own integration notes, published as a worked example of where relay-ops decisions intersect a real firm's other priorities — adapt the shape, swap the specifics for your own.

Two hooks worth flagging, not resolving here. First: ditto-relay's NIP-50 `language:<code>` search filter [2] means the maintainers' own bilingual thesis is not only a UI/content decision — it is a data-layer one. A relay or search index that filters and ranks `language:es` results natively is infrastructure for their Spanish-language product surface, not a nice-to-have bolted on later — swap in whatever locale your own product targets. Second, a concrete note from the maintainers' own backlog: a relay-provisioning task is already queued on their operator side — this chapter does not act on it; the general lesson is to run any queued infrastructure task through your own team's intake process before touching it.

## Open Questions

- Whether `@nostrify/strfry`'s `PipePolicy` composition runs byte-identical inside Ditto's own moderation config, or needs a thin Ditto-specific wrapper — asserted as architectural inference from Ditto being Nostrify-native (ch. 11), not confirmed against a working Ditto deployment this pass.
- Whether ditto-relay's trending engine (followers/engagers/comment-count scoring feeding signed NIP-85 assertions [2]) has been independently verified to produce sane rankings at any real scale — the README documents the mechanism, not a production trace.
- The exact `strfry sync` behavior against a relay that only partially supports negentropy (advertises it but times out) is not documented in the fetched README [4] — worth a smoke test before depending on the archive pattern's schedule.
- PlebOne's "zero spam" claim [10] is the relay operator's own framing; no independent measurement of its web-of-trust filter's false-negative rate was found.

## Sources

1. Yiluo Wei and Gareth Tyson, "An Empirical Analysis of the Nostr Social Network: Decentralization, Availability, and Replication Overhead," [arXiv:2402.05709v2](https://arxiv.org/abs/2402.05709v2). Accessed 2026-07-10. Supports: 95% free-relay cost-coverage failure, 64% zero-zap relays, 90th-percentile zap income (~150k sats / ~$67 over 6 months), medium-scale paid-relay cost coverage.
2. [gitlab.com/soapbox-pub/ditto-relay/-/raw/main/README.md](https://gitlab.com/soapbox-pub/ditto-relay/-/raw/main/README.md). Accessed 2026-07-10. Supports: ditto-relay stack (Bun + OpenSearch), NIP list, NIP-50 `language:`/`sentiment:`/`media:` filters, trending engine feeding NIP-85 publishing, worker-pool/stateless/horizontal-scaling architecture, Prometheus `/metrics`, `NOSTR_NSEC` env var.
3. [gitlab.com/soapbox-pub/nostrify/-/raw/main/packages/strfry/README.md](https://gitlab.com/soapbox-pub/nostrify/-/raw/main/packages/strfry/README.md). Accessed 2026-07-10. Supports: `@nostrify/strfry` bridge mechanism, stdin/stdout connection to strfry's plugin interface, `PipePolicy` composition example.
4. [github.com/hoytech/strfry](https://github.com/hoytech/strfry) (README). Accessed 2026-07-10. Supports: strfry stack (C++/LMDB), negentropy sync origin, `export --fried` / `import` JSONL, `strfry sync` command, Prometheus `/metrics`.
5. [github.com/fiatjaf/khatru](https://github.com/fiatjaf/khatru) (README). Accessed 2026-07-10. Supports: khatru as a Go framework (not turnkey), hook-based architecture (`StoreEvent`/`QueryEvents`/`DeleteEvent`/`RejectEvent`), `eventstore`-backed storage options, custom AUTH handlers.
6. [ngit.dev/grasp/](https://ngit.dev/grasp/) — "Grasp: Git Repositories Authorized via Signed-Nostr Proofs." Accessed 2026-07-10. Supports: GRASP's reference implementation `ngit-relay` runs on khatru.
7. [nips.nostr.com/66](https://nips.nostr.com/66) — NIP-66, Relay Discovery and Liveness Monitoring. Accessed 2026-07-10. Supports: kind `30166` (discovery) / `10166` (monitor announcement) event definitions.
8. Search-index results on [github.com/sandwichfarm/nostr-watch](https://github.com/sandwichfarm/nostr-watch) and nostr.watch. Accessed 2026-07-10. Supports: nostr.watch's 8 automated monitoring stations across 6 continents, decentralized-monitor design.
9. [nips.nostr.com/13](https://nips.nostr.com/13) — NIP-13, Proof of Work. Accessed 2026-07-10. Supports: difficulty = leading zero bits of event id; PoW as spam deterrence; delegated PoW for constrained devices.
10. [github.com/PlebOne/relay.pleb.one](https://github.com/PlebOne/relay.pleb.one). Accessed 2026-07-10. Supports: free public web-of-trust relay self-reporting zero spam without payment.
11. [grafana.com/grafana/dashboards/21844-ditto/](https://grafana.com/grafana/dashboards/21844-ditto/). Accessed 2026-07-10. Supports: community Grafana dashboard ID 21844 for Ditto's Prometheus metrics.
12. `docs.soapbox.pub/ditto/metrics` (301 redirect to `about.ditto.pub`). Accessed 2026-07-10. Supports: Ditto's own metrics documentation is mid-migration (consistent with ch. 02's finding); Ditto exposes Prometheus metrics.
13. Nostr note (nevent), recommended Ditto VPS specs, surfaced via search index on nostr.com. Accessed 2026-07-10. Supports: 4 cores / 8GB RAM / 100GB disk recommended for self-hosting Ditto.
14. nostr-rs-relay community references ([nutcroft.mataroa.blog](https://nutcroft.mataroa.blog/blog/how-to-run-your-own-nostr-relay/); [github.com/scsibug/nostr-rs-relay](https://github.com/scsibug/nostr-rs-relay)), via search index. Accessed 2026-07-10. Supports: nostr-rs-relay exposes a first-party `/metrics` endpoint for Prometheus.
15. [nips.nostr.com/77](https://nips.nostr.com/77) — NIP-77, Negentropy Syncing. Accessed 2026-07-10. Supports: range-based set reconciliation; spec status "draft, optional" — merged into the NIPs repo, not a relay-adoption guarantee.
16. nostr.watch relay-software statistics, via search index. Accessed 2026-07-10. Supports: strfry runs on 283 of 1,341 reachable relays (~23.1%), the software-share figure used in the Implementation Menu.
