Ngate-strfry-basic
| Please Select a Licence from the LICENCE_HEADERS page |
And place at top of your page |
If no Licence is Selected/Appended, Default will be CC0 Default Licence IF there is no Licence placed below this notice!
When you edit this page, you agree to release your contribution under the CC0 Licence LICENCE:
More information about the cc0 licence can be found here: You can copy, modify, distribute and perform the work, even for commercial purposes, all without asking permission. Licence: Statement of Purpose The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an "owner") of an original work of authorship and/or a database (each, a "Work"). Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works ("Commons") that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others. For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the "Affirmer"), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights. 1. Copyright and Related Rights. A Work made available under CC0 may be protected by copyright and related or neighboring rights ("Copyright and Related Rights"). Copyright and Related Rights include, but are not limited to, the following: the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work; moral rights retained by the original author(s) and/or performer(s); publicity and privacy rights pertaining to a person's image or likeness depicted in a Work; rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(a), below; rights protecting the extraction, dissemination, use and reuse of data in a Work; database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof. 2. Waiver. To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer's Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer's heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer's express Statement of Purpose. 3. Public License Fallback. Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer's express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer's Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "License"). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer's express Statement of Purpose. 4. Limitations and Disclaimers. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document. Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law. Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person's Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work. |
|
LICENCE: When you edit this page, you agree to release your contribution under the MIT Licence LICENCE Copyright <YEAR> <COPYRIGHT HOLDER> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
strfry + nGate — A Hot-Reloading Whitelisted Nostr Relay on Ubuntu 24.04
From CompleteNoobs
This guide deploys a strfry Nostr relay at nostr.v4call.com on a fresh Ubuntu 24.04 server, behind TLS, with nGate driving a live, hot-reloading whitelist — the relay's allowed-publisher list is updated without ever restarting the relay.
This is the "Stage 4" path described in CLAUDE.md / STATUS.md. It is an alternative to the nostr-rs-relay stage-1 deploy, not a replacement you must take. Read the comparison below before deciding.
This is a "noob doing Nostr by doing" walkthrough in the same style as the Nostr Relay With a 3-Key Whitelist guide. Every command can be copy-pasted exactly as shown. You do not need to know C++, Go, or Rust.
Why strfry instead of nostr-rs-relay?
Both work. The difference is how the whitelist is updated.
| nostr-rs-relay (stage 1) | strfry (this guide) | |
|---|---|---|
| Whitelist lives in | config.toml pubkey_whitelist = [...] |
a separate plain file the policy plugin reads |
| To change the whitelist | rewrite config.toml + restart the container |
rewrite the whitelist file — no restart |
| nGate apply phase | writes config.toml between BEGIN/END markers, runs docker restart |
writes whitelist.json atomically; relay picks it up on the next event
|
| Restart cap / sanity bound | needed (a restart loop would flap the relay) | restart cap becomes irrelevant — there is no restart |
| Downtime on whitelist change | ~1–3 s reconnect blip for every connected client | none — open connections stay up |
| Config format | TOML | strfry's own libconfig-style strfry.conf (NOT TOML)
|
The headline: a strfry write-policy plugin is a small program strfry runs and pipes every incoming event to. The plugin decides accept/reject per event. Because the plugin re-reads the whitelist file on each event (cheap — it is tiny), the moment nGate rewrites that file the very next event is judged against the new list. No restart, no dropped client connections.
The tradeoff (be honest):
- The plugin runs once per inbound write event. It must be fast. Reading a small JSON file per event is fine; we add an mtime cache so it only re-parses when the file actually changes.
- strfry's config file is not TOML. If you already run nostr-rs-relay you cannot reuse
config.toml— this is a parallel setup. - nGate's
scan → verify → gatephases are unchanged. Only theapplyphase swaps backend (writes a whitelist file instead of config.toml + restart). This guide ships a smallngate-strfry-apply.shfor that; the 3-strike-miss / state machinery fromngate-apply.shis a later port (see What's deferred).
Answering the question "can strfry be a Docker image?" — Yes. strfry ships an official multi-stage Dockerfile in its source repo. You docker build it once on the box (Step 7) and run it from docker compose like any other service. There are also community-published images on registries; we build from source instead so you are running code you fetched from the official repo at a tag you chose (trust tradeoff — a prebuilt image is faster but you are trusting whoever pushed it).
Contents
- 1 What You Need
- 2 Step 1: Create and Point the Server
- 3 Step 2: Log In and Harden
- 4 Step 3: Install Docker
- 5 Step 4: Open the Firewall
- 6 Step 5: Project Folder Layout
- 7 Step 6: Get Your Whitelisted Keys in Hex
- 8 Step 7: Build the strfry Docker Image
- 9 Step 8: Write strfry.conf
- 10 Step 9: The Write-Policy Plugin
- 11 Step 10: Seed the Whitelist
- 12 Step 11: Caddy for TLS
- 13 Step 12: docker-compose.yml
- 14 Step 13: Start It Up
- 15 Step 14: Prove the Whitelist Works
- 16 Step 15: Prove Hot-Reload Works (the whole point)
- 17 Step 16: Wire in nGate
- 18 Step 17: Automate It with cron
- 19 Step 18: Optional — Per-User Whitelisting via
nostr-announce - 20 Common Problems and Fixes
- 21 What's deferred
- 22 Tips for Noobs
What You Need
- A VPS running fresh Ubuntu 24.04, 1 GB RAM minimum (2 GB comfortable — the strfry build is C++ and wants RAM to compile). Vultr, Hetzner, DigitalOcean, etc. If using Vultr, our Vultr referral link helps cover server costs.
- A domain/subdomain with DNS access — this guide uses
nostr.v4call.com. Substitute your own everywhere you see it. - Your whitelisted Nostr keys in hex (Step 6 shows conversion). At minimum: the operator's own key, so you don't lock yourself out.
- A terminal + SSH and about 45–60 minutes (the strfry compile is the slow part — 5–15 min depending on the box).
- The nGate repo if you want the automated whitelist (Step 16). Without nGate this is still a perfectly good manually-managed hot-reload relay.
Step 1: Create and Point the Server
- Create the Ubuntu 24.04 VPS in your provider's panel. Note its public IPv4 address.
- In your DNS provider, add an A record:
nostr.v4call.com → <your VPS IP>. TTL 300 is fine. - Wait for DNS to propagate. Check from your laptop:
dig +short nostr.v4call.com
When that prints your VPS IP, continue. (Caddy in Step 11 will not be able to get a TLS certificate until DNS resolves correctly, so don't skip this.)
Step 2: Log In and Harden
ssh root@nostr.v4call.com
Basic hardening (same as the stage-1 guide — skip if you already have your own baseline):
apt update && apt upgrade -y adduser noob usermod -aG sudo noob rsync --archive --chown=noob:noob ~/.ssh /home/noob # then log out and back in as: ssh noob@nostr.v4call.com
From here on, run commands as the noob user with sudo where shown.
Step 3: Install Docker
sudo apt update sudo apt install -y ca-certificates curl git sudo install -m 0755 -d /etc/apt/keyrings sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc sudo chmod a+r /etc/apt/keyrings/docker.asc echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null sudo apt update sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin sudo usermod -aG docker $USER newgrp docker
Verify:
docker --version docker compose version
Step 4: Open the Firewall
sudo ufw allow OpenSSH sudo ufw allow 80/tcp sudo ufw allow 443/tcp sudo ufw --force enable sudo ufw status
Only 22 (SSH), 80 and 443 are exposed. strfry's own port (7777) stays internal to the Docker network — Caddy is the only thing the public talks to.
Step 5: Project Folder Layout
sudo mkdir -p /opt/nostr-relay sudo chown -R $USER:$USER /opt/nostr-relay cd /opt/nostr-relay mkdir -p strfry-db policy caddy_data caddy_config
You will end up with:
/opt/nostr-relay/ ├── docker-compose.yml (Step 12) ├── strfry.conf (Step 8) ├── Caddyfile (Step 11) ├── strfry/ (the cloned strfry source, for the image build — Step 7) ├── policy/ │ ├── whitelist-policy.sh (the write-policy plugin — Step 9) │ └── whitelist.json (the live whitelist nGate rewrites — Step 10) ├── seed.toml (operator's always-allowed keys — Step 10) ├── strfry-db/ (LMDB event database — created by strfry) ├── caddy_data/ caddy_config/ (Caddy TLS state) └── (nGate bits added in Step 16)
Step 6: Get Your Whitelisted Keys in Hex
Nostr relays compare the hex form of a public key, not the bech32 npub1... form. Same key, different encoding. The whitelist file holds hex.
To convert an npub to hex, the easiest no-install option: open public/nostr-gen.html from the v4call repo in any browser — it has an npub↔hex converter and never sends anything over the network. Or with the nak CLI:
nak decode npub1yourkeyhere
Write down each whitelisted key's 64-character hex string. You need at least the operator's own key before first start, or you lock yourself out (same rule as nGate's seed.toml — see CLAUDE.md decision #6).
Step 7: Build the strfry Docker Image
We build from the official source at a pinned tag so you know exactly what you are running.
Useful info for low-powered VPS builds — read this BEFORE you run docker build
The strfry compile is C++ (g++ -std=c++20 -O3) and it is the single most resource-hungry thing in this whole guide. On a small VPS it can make the box go completely unresponsive — you can't even open a second SSH session. Here is what is actually happening and how to survive it.
It's RAM, not "CPU at 100%". Linux schedules CPU fairly, so a pegged CPU still lets sshd in (just slowly). What truly freezes a tiny box is memory exhaustion: each g++ process on strfry can need 1–1.5 GB, and strfry's Dockerfile compiles several files in parallel. On a 1 GB VPS with no swap you hit the OOM killer / heavy swapping, and that is what blocks new logins. The build looks frozen but usually isn't — a single main.cpp object can take minutes with no output. Don't Ctrl-C too early (the way you almost did).
There is no "limit build to 70% CPU" flag. This guide's build uses BuildKit (you'll see [+] Building with docker:default). BuildKit ignores --cpu-quota / --cpus / --cpuset-cpus during docker build — those flags only apply to docker run (runtime containers), not to the build. nice docker build ... also does nothing useful, because the compiler processes are children of dockerd/containerd, not of your docker CLI, so the renice doesn't propagate. The real levers are parallelism and swap, not a CPU percentage.
Lever 1 — lower the compile parallelism (biggest win). strfry's Dockerfile contains a line like:
RUN --mount=type=cache,target=/build/.cache \
make -j4
-j4 = up to 4 compilers at once = up to ~4–6 GB peak RAM. Edit it down before building:
make -j1— serial. Slowest, but a 1 GB box survives it. Safest on the smallest VPS.make -j2— a sane middle ground if you have ≥2 GB RAM.
This one edit is what turns "box OOM-kills the compiler / freezes" into "slow but it finishes".
Lever 2 — add swap BEFORE you build. Even 2 GB of swap turns "unresponsive / OOM" into "sluggish but still loggable, and the build completes". The swap commands are right after this box — run them first.
sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile
Lever 3 — never lose your session. Start the build inside tmux (or screen) so a dropped connection or a lock-up doesn't kill the build or lock you out:
tmux new -s build # then run the build inside it # Ctrl-b then d to detach; tmux attach -t build to come back
Also open your second SSH session before starting the build, not after — once the box is under memory pressure you may not be able to log in fresh.
Watch the right thing. In the second session run htop (sudo apt install -y htop) and watch the Mem / Swp bars and swap in/out, not just CPU%. Swap churning = it's RAM = the fix is lower -jN + more swap, exactly as above.
TL;DR for a small box: add swap → edit make -j4 down to -j1 (or -j2 if ≥2 GB) → run the build inside tmux → watch htop's memory line → be patient, a quiet build is normal.
cd /opt/nostr-relay git clone https://github.com/hoytech/strfry.git strfry cd strfry git submodule update --init # Pin to a release tag (check https://github.com/hoytech/strfry/releases for the latest) git checkout 1.0.4 docker build -t strfry:1.0.4 . cd /opt/nostr-relay
This compiles strfry inside Docker. It is the slow step — 5–15 minutes and a chunk of RAM. If the build is killed (OOM) on a 1 GB box, add swap first:
sudo fallocate -l 2G /swapfile sudo chmod 600 /swapfile sudo mkswap /swapfile sudo swapon /swapfile
then re-run docker build.
Now build the nGate-flavoured image (REQUIRED — do not skip). The official strfry image is built FROM alpine, which ships busybox sh only — no bash, no jq. The write-policy plugin in Step 9 needs both. If you run the stock strfry:1.0.4 image, the relay starts and serves reads fine, but every publish fails with error: internal error because the plugin can't execute (you'd waste an hour finding that out — we did). Bake the two tools in with a three-line wrapper image. Create /opt/nostr-relay/Dockerfile.relay:
FROM strfry:1.0.4 RUN apk add --no-cache bash jq
Build it:
cd /opt/nostr-relay docker build -t strfry-ngate:1.0.4 -f Dockerfile.relay .
From here on the relay image you run is strfry-ngate:1.0.4 (the compose file in Step 12 uses it). The plain strfry:1.0.4 image only exists as the base for this wrapper. (busybox stat -c %Y — which the plugin also uses — is supported by Alpine's busybox, so once bash + jq are present the Step 9 plugin runs unmodified.)
Shortcut (optional, trust tradeoff): community-prebuilt images exist on container registries. Using one skips the compile but means trusting whoever pushed it. For a relay that gates who can write, building from the official repo yourself is the safer default. If you do use a prebuilt image, pin it by digest, not :latest.
Step 8: Write strfry.conf
strfry's config is not TOML — it is a libconfig-style format. Create /opt/nostr-relay/strfry.conf:
# strfry.conf — nostr.v4call.com
db = "/app/strfry-db/"
dbParams {
maxreaders = 256
mapsize = 10995116277760
}
relay {
bind = "0.0.0.0"
port = 7777
# strfry tries to raise its open-file limit to 1,000,000 by default.
# Most VPS hosts cap well below that, so strfry aborts on startup
# ("Unable to set NOFILES limit ...") and crash-loops. 0 = "don't
# attempt to set the limit, inherit the container's" — host-independent
# and the right default here. THIS KEY MUST BE INSIDE relay { } —
# at the top level strfry silently ignores it (see Common Problems).
nofiles = 0
# We are behind Caddy; Caddy sets X-Forwarded-For. realIpHeader lets
# strfry log the true client IP instead of the docker network IP.
realIpHeader = "x-forwarded-for"
info {
name = "nostr.v4call.com"
description = "Whitelisted relay — write-gated by nGate, public read."
pubkey = ""
contact = ""
}
maxWebsocketPayloadSize = 131072
autoPingSeconds = 55
# ── The bit that matters ──────────────────────────────────────────
# writePolicy.plugin points at an executable strfry pipes every
# inbound EVENT to (one JSON object per line on stdin; the plugin
# replies with one JSON object per line on stdout). The plugin reads
# the live whitelist file on each event, so rewriting that file
# changes the policy WITHOUT restarting strfry.
writePolicy {
plugin = "/app/policy/whitelist-policy.sh"
}
}
Notes:
db, plugin path etc. are container-internal paths (/app/...). The compose file in Step 12 bind-mounts host folders onto them.- Changing
strfry.confitself (e.g. the plugin path) does need a strfry restart — but the whitelist data the plugin reads does not. That distinction is the whole point: config path = static, whitelist data = live.
Step 9: The Write-Policy Plugin
Create /opt/nostr-relay/policy/whitelist-policy.sh. This is the program strfry runs. It reads one JSON line per event on stdin and must print one JSON line per event on stdout: {"id":"<event id>","action":"accept"|"reject","msg":"..."}.
It reads whitelist.json (a JSON array of hex pubkeys) fresh — but only re-parses when the file's mtime changes, so the hot path is a cheap stat, not a re-read, per event.
Prerequisite: this script needs bash and jq inside the relay container. Stock strfry (Alpine) has neither — that is exactly why Step 7 builds the strfry-ngate:1.0.4 wrapper image. If publishes fail with error: internal error, you skipped that. Two filenames that are easy to confuse: whitelist-policy.sh is this script (the program strfry runs); whitelist.json is the data file it reads (Step 10, rewritten by nGate). There is no whitelist-policy.json — if you find one it's a stray, ignore/delete it.
#!/usr/bin/env bash
# strfry write-policy plugin — whitelist gate with mtime-cached reload.
# Protocol: https://github.com/hoytech/strfry/blob/master/docs/plugins.md
# stdin : one JSON per line {"type":"new","event":{"id":..,"pubkey":..},...}
# stdout: one JSON per line {"id":"<event id>","action":"accept"|"reject","msg":".."}
set -eo pipefail
WL="${STRFRY_WHITELIST:-/app/policy/whitelist.json}"
cached_mtime=""
declare -A allow # hex pubkey -> 1
reload_if_changed() {
local m
m=$(stat -c %Y "$WL" 2>/dev/null || echo "")
[[ "$m" == "$cached_mtime" ]] && return 0
cached_mtime="$m"
allow=()
if [[ -s "$WL" ]]; then
while IFS= read -r hex; do
[[ -n "$hex" ]] && allow["${hex,,}"]=1
done < <(jq -r '.[]? // empty' "$WL" 2>/dev/null || true)
fi
# Stderr is strfry's plugin log — handy for "did the reload fire?"
echo "[whitelist-policy] reloaded: ${#allow[@]} key(s) from $WL" >&2
}
# Prime once so the first event isn't slowed by a cold load.
reload_if_changed
while IFS= read -r line; do
[[ -z "$line" ]] && continue
reload_if_changed
id=$(echo "$line" | jq -r '.event.id // empty')
pubkey=$(echo "$line" | jq -r '.event.pubkey // empty')
pubkey="${pubkey,,}"
if [[ -n "$pubkey" && -n "${allow[$pubkey]:-}" ]]; then
printf '{"id":"%s","action":"accept"}\n' "$id"
else
printf '{"id":"%s","action":"reject","msg":"blocked: not on relay whitelist"}\n' "$id"
fi
done
Make it executable:
chmod +x /opt/nostr-relay/policy/whitelist-policy.sh
Why bash + jq? Zero extra runtime, and jq is already an nGate dependency so the mental model stays consistent across the project. For a very high-throughput public relay you would rewrite this in Go/Rust; for an nGated federation-discovery relay the event rate is low and bash is plenty. (If you find the per-event jq spawn too heavy at scale, that's the upgrade trigger — note it and move on; don't pre-optimise.)
Step 10: Seed the Whitelist
Two files:
seed.toml — operator-managed, never auto-removed (same concept and filename as nGate stage 1, CLAUDE.md decision #6). One hex pubkey per line, # for comments:
# /opt/nostr-relay/seed.toml — operator's always-allowed keys # Put YOUR key here before first start or you lock yourself out. abc123...your64charhexkey...def
policy/whitelist.json — the live file the plugin reads. nGate rewrites this in Step 16. Until then, hand-seed it from your seed keys. A JSON array of hex strings:
[ "abc123...your64charhexkey...def" ]
For now they overlap by hand. Once nGate is wired (Step 16) it produces whitelist.json as (seed.toml ∪ verified+gated discovered keys) — the seed is always merged in so the operator can never be auto-evicted.
Step 11: Caddy for TLS
Create /opt/nostr-relay/Caddyfile:
nostr.v4call.com {
reverse_proxy nostr-relay:7777
}
Caddy auto-obtains and auto-renews a Let's Encrypt certificate for the domain and reverse-proxies WebSocket traffic to strfry. (This is identical in spirit to the stage-1 nostr-rs-relay Caddyfile — only the upstream port differs: strfry uses 7777, nostr-rs-relay used 8080.)
Step 12: docker-compose.yml
Create /opt/nostr-relay/docker-compose.yml:
services:
nostr-relay:
image: strfry-ngate:1.0.4 # bash+jq wrapper from Step 7, NOT plain strfry:1.0.4
container_name: nostr-relay
restart: unless-stopped
command: relay
volumes:
- ./strfry.conf:/etc/strfry.conf:ro
- ./strfry-db:/app/strfry-db
- ./policy:/app/policy # plugin + whitelist.json (RW: nGate writes here)
expose:
- "7777"
networks:
- relaynet
caddy:
image: caddy:2-alpine
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy_data:/data
- ./caddy_config:/config
depends_on:
- nostr-relay
networks:
- relaynet
networks:
relaynet:
driver: bridge
Notes:
- strfry's default Dockerfile reads
/etc/strfry.confand the entrypoint takes a subcommand (relayto run the relay). If your pinned strfry tag uses a different default config path, checkdocker run --rm strfry:1.0.4 --helpand adjust the bind-mount target — this is the #1 version-drift gotcha. - The
./policymount is read-write (no:ro) because nGate rewriteswhitelist.jsoninside it. The plugin script itself is operator-trusted code. - No Docker socket is mounted and there is no restart command anywhere — that whole class of nGate machinery (restart cap, sanity bound, BEGIN/END markers) is simply not needed here.
Step 13: Start It Up
cd /opt/nostr-relay docker compose up -d docker compose ps docker compose logs -f caddy # watch the cert get issued (Ctrl-C to stop tailing) docker compose logs -f nostr-relay # watch strfry start + the plugin's reload line
You should see, in the relay log, the plugin's [whitelist-policy] reloaded: N key(s) line on the first event (or at prime time). If Caddy logs a certificate error, re-check DNS (Step 1) — it cannot issue a cert until nostr.v4call.com resolves to this box.
Step 14: Prove the Whitelist Works
Install the nak CLI on your laptop (or any machine):
go install github.com/fiatjaf/nak@latest
Accepted — publish with a key that IS in whitelist.json:
nak event -k 1 -c "hello from a whitelisted key" --sec <nsec-of-a-whitelisted-key> wss://nostr.v4call.com
You should get an OK / accepted response.
Rejected — publish with a fresh random key that is NOT whitelisted:
nak event -k 1 -c "i should be blocked" --sec $(nak key generate) wss://nostr.v4call.com
You should get a rejection carrying blocked: not on relay whitelist (the msg from the plugin).
Public read still works for everyone (whitelist gates writes only):
nak req -k 1 wss://nostr.v4call.com
Step 15: Prove Hot-Reload Works (the whole point)
This is the demo that justifies the whole strfry switch.
- Pick a fresh key and confirm it is rejected (it's not whitelisted yet):
NSEC=$(nak key generate); echo "$NSEC"nak event -k 1 -c "before" --sec "$NSEC" wss://nostr.v4call.com→ rejected.
- Get that key's hex:
nak decode <the npub nak printed>(or it prints hex withnak key publicflows). - On the server, add the hex to the whitelist file with no restart:
cd /opt/nostr-relay/policyjq '. + ["<newhex>"]' whitelist.json > whitelist.json.tmp && mv whitelist.json.tmp whitelist.json- (the atomic
tmp+mvis deliberate — never write the live file in place)
- Immediately re-publish with the same key:
nak event -k 1 -c "after" --sec "$NSEC" wss://nostr.v4call.com→ now accepted.
No docker restart, no reconnect blip, open client connections never dropped. That is the entire reason this guide exists. The relay log shows a fresh [whitelist-policy] reloaded: N key(s) line the moment the file's mtime changed.
Step 16: Wire in nGate
nGate's first three phases — ngate-scan.sh → ngate-verify.sh → ngate-gate.sh — are unchanged from the stage-3 walkthrough (nGate-auto-whitelist). They read Hive v4call-server posts, verify the Hive signature + Nostr attestation, apply the HP/token economic gate, and emit verified+gated NDJSON. Only the final apply phase differs: instead of rewriting config.toml and restarting (ngate-apply.sh), we atomically write whitelist.json and stop there.
Get nGate onto the box. Clone the repo into the project folder and install its Node crypto-helper dependencies (dhive + nostr-tools — the phase scripts shell out to them for Hive ECDSA / Nostr schnorr verification):
cd /opt/nostr-relay git clone https://github.com/completenoobs/ngate.git nGate sudo apt install -y npm cd /opt/nostr-relay/nGate/scripts npm install
(npm install reads scripts/package.json and populates scripts/node_modules/. Skip it and ngate-verify.sh dies the first time it verifies a signature — "cannot find module '@hiveio/dhive'".)
The strfry apply backend ngate-strfry-apply.sh ships in nGate/scripts/ alongside the other phase scripts — you do not need to hand-create it. It is reproduced here for reference so you can see exactly what it does (seed-merge, atomic write, no restart):
#!/usr/bin/env bash
# ngate-strfry-apply.sh — Stage-4 apply backend for strfry.
# Reads verified+gated NDJSON on stdin (same format ngate-apply.sh consumes),
# merges in seed.toml, atomically rewrites whitelist.json. NO restart needed —
# strfry's write-policy plugin re-reads the file live.
#
# ... | ngate-gate.sh | ./ngate-strfry-apply.sh --apply
#
# Defaults to --dry-run (nGate convention: write-side scripts need explicit --apply).
set -eo pipefail
DRY_RUN=true
[[ "${1:-}" == "--apply" ]] && DRY_RUN=false
WL="${NGATE_WHITELIST_PATH:-/opt/nostr-relay/policy/whitelist.json}"
SEED="${NGATE_SEED_PATH:-/opt/nostr-relay/seed.toml}"
declare -A keep
# 1. seed.toml — always merged, never auto-removed (CLAUDE.md decision #6)
if [[ -f "$SEED" ]]; then
while IFS= read -r raw; do
line="${raw%%#*}"; line="$(echo "$line" | tr -d '[:space:]')"
[[ -n "$line" ]] && keep["${line,,}"]=1
done < "$SEED"
fi
# 2. verified+gated discovered keys from stdin NDJSON
while IFS= read -r ev; do
[[ -z "$ev" ]] && continue
hex=$(echo "$ev" | jq -r '.nostr_pubkey_hex // empty' 2>/dev/null || true)
[[ -n "$hex" ]] && keep["${hex,,}"]=1
done
# 3. render sorted JSON array
new_json=$(printf '%s\n' "${!keep[@]}" | sort | jq -R . | jq -s .)
if [[ "$DRY_RUN" == "true" ]]; then
echo "DRY RUN — would write $(echo "$new_json" | jq 'length') key(s) to $WL" >&2
echo "$new_json"
exit 0
fi
# atomic write — strfry's plugin re-reads on mtime change, no restart
tmp="$(mktemp)"
echo "$new_json" > "$tmp"
mv "$tmp" "$WL"
echo "✓ wrote $(echo "$new_json" | jq 'length') key(s) to $WL (no restart)" >&2
"The scripts are already executable from the clone. Run the chain manually first — nGate's safe default is dry-run, so this changes nothing yet:
cd /opt/nostr-relay/nGate/scripts ./ngate-scan.sh \ | ./ngate-verify.sh \ | NGATE_GATE_ACCOUNT=hive_account NGATE_MIN_TOKEN_SYMBOL=CNOOBS NGATE_MIN_TOKEN_AMOUNT=3 ./ngate-gate.sh \ | ./ngate-strfry-apply.sh
Read the output. ngate-verify prints one line per discovered server (OK / REJECT / skip); ngate-gate prints who passed the economic gate; the apply line says DRY RUN — would write N key(s). The REJECT … no nostr_attestation and skip … no nostr_pubkey_hex lines for old / pre-Nostr announces are expected and correct (CLAUDE.md decision #1 — attestation required, no legacy compat), not errors.
When the dry-run looks right, apply for real (this is the exact command verified on nostr.hive-book.com — substitute your own gate thresholds):
cd /opt/nostr-relay/nGate/scripts ./ngate-scan.sh \ | ./ngate-verify.sh \ | NGATE_GATE_ACCOUNT=hive_account NGATE_MIN_TOKEN_SYMBOL=CNOOBS NGATE_MIN_TOKEN_AMOUNT=3 ./ngate-gate.sh \ | NGATE_MAX_CONSECUTIVE_MISSES=1 ./ngate-strfry-apply.sh --apply
Success looks like ✓ wrote N key(s) to /opt/nostr-relay/policy/whitelist.json (no restart). Confirm:
cat /opt/nostr-relay/policy/whitelist.json
It should hold your seed key (from seed.toml, always merged even though it is not on Hive — that is the point of the seed) plus one hex per verified+gated server. The relay picks it up on the very next event — no restart — and logs [whitelist-policy] reloaded: N key(s).
(Note the two flags shown: --apply turns off dry-run; add --allow-removals only when you want keys that have dropped off Hive to be pruned. Without --allow-removals the apply is ADD-only — safe against a transient Hive outage nuking your whitelist. NGATE_MAX_CONSECUTIVE_MISSES is carried for forward-compat with the deferred miss-counter — see What's deferred.)
Step 17: Automate It with cron
Manual runs prove it works; cron keeps it current. Every run recomputes the whitelist from Hive and atomically rewrites whitelist.json — no relay restart, ever — so running it often is cheap and safe.
Recommended: the explicit-env one-liner (this is the tested path). Edit your crontab:
crontab -e
Add (runs every 30 minutes; adjust the schedule — see the cheatsheet below):
SHELL=/bin/bash PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin */30 * * * * cd /opt/nostr-relay/nGate/scripts && flock -n /tmp/ngate.lock ./ngate-scan.sh | ./ngate-verify.sh | NGATE_GATE_ACCOUNT=hive_account NGATE_MIN_TOKEN_SYMBOL=CNOOBS NGATE_MIN_TOKEN_AMOUNT=3 ./ngate-gate.sh | NGATE_MAX_CONSECUTIVE_MISSES=1 ./ngate-strfry-apply.sh --apply --allow-removals >> /opt/nostr-relay/ngate-cron.log 2>&1
Schedule cheatsheet (the five fields are minute hour day-of-month month day-of-week):
| Cron expression | Runs |
|---|---|
*/15 * * * * |
every 15 minutes |
*/30 * * * * |
every 30 minutes |
0 * * * * |
every hour, on the hour |
0 */6 * * * |
every 6 hours |
0 3 * * * |
once a day at 03:00 |
Three cron gotchas that bite everyone (noob-tested, in this guide's spirit):
- cron has almost no
PATH. It does not inherit your login shell's environment, sonode,jq,git,flockmay not be found and the run silently does nothing. ThePATH=line at the top of the crontab fixes this; ifnodelives somewhere odd (which nodeto check), add its directory to thatPATH=. - Always redirect to a log (
>> ... 2>&1). Without it cron emails output into a local mailbox you'll never read, and a broken run is invisible.tail -f /opt/nostr-relay/ngate-cron.logafter the first scheduled fire to confirm it ran clean. flock -nprevents overlap. A slow Hive scan that runs past the next tick would otherwise start a second copy on top of the first.flock -n /tmp/ngate.lockmakes the new run bail if one is already going. (flockis fromutil-linux, present on Ubuntu by default.)
Alternative: drive the gate config from ngate.yaml instead of inline env vars. nGate ships ngate.yaml.example and a loop wrapper ngate-sync.sh that reads the YAML and translates it into the same NGATE_* env vars. Caveat — be honest about this: the stock ngate-sync.sh is hardwired to the stage-1 ngate-apply.sh (config.toml + restart), not the strfry backend. Until a strfry-aware sync wrapper lands (tracked under What's deferred), the clean way to get YAML-driven config on a strfry box is a tiny shim that reads the gate keys out of ngate.yaml with yq and feeds the strfry pipeline:
#!/usr/bin/env bash
# /opt/nostr-relay/nGate/scripts/ngate-strfry-cron.sh
# YAML-driven one-shot for the strfry backend. Point cron at THIS instead of
# the long inline pipeline if you'd rather keep gate config in ngate.yaml.
set -eo pipefail
cd "$(dirname "$0")"
Y="${NGATE_YAML:-/opt/nostr-relay/ngate.yaml}"
GA=$(yq -r '.gate.account // "hive_account"' "$Y")
TS=$(yq -r '.gate.min_token_symbol // ""' "$Y")
TA=$(yq -r '.gate.min_token_amount // 0' "$Y")
MM=$(yq -r '.max_consecutive_failures // 1' "$Y")
./ngate-scan.sh \
| ./ngate-verify.sh \
| NGATE_GATE_ACCOUNT="$GA" NGATE_MIN_TOKEN_SYMBOL="$TS" NGATE_MIN_TOKEN_AMOUNT="$TA" ./ngate-gate.sh \
| NGATE_MAX_CONSECUTIVE_MISSES="$MM" ./ngate-strfry-apply.sh --apply --allow-removals
chmod +x /opt/nostr-relay/nGate/scripts/ngate-strfry-cron.sh
Then the crontab line collapses to:
*/30 * * * * flock -n /tmp/ngate.lock /opt/nostr-relay/nGate/scripts/ngate-strfry-cron.sh >> /opt/nostr-relay/ngate-cron.log 2>&1
This shim is not tested end-to-end the way the explicit-env pipeline above is — review it and dry-run it once (drop --apply) before trusting it on a schedule. The explicit-env crontab line is the path this guide has actually run in production; the YAML shim is offered as the tidier option once you've verified it on your box. (yq here is mikefarah's Go yq or kislyuk's Python yq — both handle these .field // default queries; sudo apt install -y yq or grab the mikefarah release binary.)
Alternative: a compose sidecar instead of cron. If you'd rather not use cron, add an nGate sidecar service to docker-compose.yml the same way stage 1 did (a long-running container that loops the pipeline), but its volume mount is just ./policy (read-write, so it can write whitelist.json) and ./seed.toml — no Docker socket, because nGate no longer needs to restart any container. That removes the single biggest security caveat of the stage-1 sidecar (mounting /var/run/docker.sock gave the sidecar effective host root).
Step 18: Optional — Per-User Whitelisting via nostr-announce
Everything up to Step 17 whitelists servers (one Hive post per v4call server, tagged v4call-server, scanned by ngate-scan.sh). This optional step adds a second parallel pipeline that whitelists individual users who publish a per-user Nostr-identity binding to Hive (one post per user, tagged nostr-announce, scanned by ngate-user-scan.sh). Both pipelines write to the same policy/whitelist.json — the strfry plugin doesn't care which pipeline added a key.
Skip this step entirely if you only want server-tier whitelisting. The federation-discovery relay works perfectly without per-user gating; this is a separate use case (e.g. "anyone holding ≥3 CNOOBS can publish to my relay"), not a v4call-fed requirement.
The user-facing tool that produces these posts ships in the v4call repo: public/nostr-announce.html (source on GitHub, normally served at https://your-v4call-server.com/nostr-announce.html). A user opens the page, fills in their Hive account, pastes their Nostr nsec (which never leaves their browser), clicks Sign attestation — the page builds a kind-30078 Nostr event with the Hive account in a tag, signs it with the nsec entirely in-browser, embeds it (base64) in a Hive post titled nostr-announce, and broadcasts the post via Hive Keychain or a pasted Hive posting key. The nsec is wiped on a successful broadcast. The result is a Hive post whose body contains a [V4CALL-NOSTR-BINDING-V1] block — that's what nGate's user-side pipeline reads.
What this pipeline does
Per scheduled run (defaults: every 30 min via cron, exactly like the server pipeline):
ngate-user-scan.shhits Hive RPC for the latest posts taggednostr-announce, parses each[V4CALL-NOSTR-BINDING-V1]block, emits one NDJSON candidate per line. Read-only.ngate-user-verify.shbase64-decodes each candidate's attestation, cryptographically verifies the kind-30078 Nostr event (samelib/ngate-verify-nostr-event.mjshelper used by the server pipeline), and asserts:event.pubkeymatches the declared hex;d-tag isv4call-nostr-binding;v4call_hive_accounttag matches both the post author and the body'sHIVE-ACCOUNTfield. Only passing rows go downstream. Read-only.ngate-gate.sh(existing, unchanged) reads the rows on stdin, looks up eachhive_account's HP and/or token balance via Hive RPC + Hive-Engine, and lets through only those that meet the configured thresholds. Read-only.ngate-strfry-apply.sh(existing, unchanged) merges the surviving hexes withseed.toml, atomically rewritespolicy/whitelist.json, no restart. The plugin re-reads on mtime change at the very next event.
The two scan/verify scripts mirror the existing ngate-scan.sh / ngate-verify.sh in style, dependencies, NDJSON shape, and exit codes. The downstream ngate-gate.sh and ngate-strfry-apply.sh are completely untouched — zero risk to your existing v4call-server pipeline from adding this step.
Verify the scripts exist (after the next nGate pull)
cd /opt/nostr-relay/nGate git pull ls scripts/ngate-user-scan.sh scripts/ngate-user-verify.sh
If they're missing, your local nGate clone is behind main. git pull in /opt/nostr-relay/nGate updates them. No docker rebuild needed — the nGate scripts run on the host, not inside the relay container.
Smoke-test (read-only, no whitelist write)
See what's currently discoverable without touching the whitelist:
cd /opt/nostr-relay/nGate/scripts ./ngate-user-scan.sh | ./ngate-user-verify.sh | jq -c .
Expected: zero or more NDJSON lines on stdout (each a verified per-user binding) plus chatty stderr from the scan and verify phases (OK @user/permlink — … or REJECT … with a clear reason).
Dry-run the full pipeline with a real gate
Add the gate you want — example below is "must hold ≥3 CNOOBS on the Hive account that signed the post":
cd /opt/nostr-relay/nGate/scripts
./ngate-user-scan.sh | ./ngate-user-verify.sh | \
NGATE_GATE_ACCOUNT=hive_account NGATE_MIN_TOKEN_SYMBOL=CNOOBS NGATE_MIN_TOKEN_AMOUNT=3 \
./ngate-gate.sh | \
./ngate-strfry-apply.sh # dry-run (default; nGate convention)
The last line ends with DRY-RUN: would write N key(s) to /opt/nostr-relay/policy/whitelist.json. Read the gate log lines on stderr to confirm the right accounts passed/failed before you go live.
Schedule it (test relay first)
Add to crontab -e, same box, separate lock file and log so it never blocks the existing v4call-server cron and so diagnostics stay clean:
# nostr-announce per-user pipeline — runs every 30 min, parallel to the v4call-server one */30 * * * * cd /opt/nostr-relay/nGate/scripts && flock -n /tmp/ngate-user.lock bash -c './ngate-user-scan.sh | ./ngate-user-verify.sh | NGATE_GATE_ACCOUNT=hive_account NGATE_MIN_TOKEN_SYMBOL=CNOOBS NGATE_MIN_TOKEN_AMOUNT=3 ./ngate-gate.sh | NGATE_MAX_CONSECUTIVE_MISSES=1 ./ngate-strfry-apply.sh --apply --allow-removals' >> /opt/nostr-relay/ngate-user-cron.log 2>&1
Two new things in this line vs the v4call-server cron:
/tmp/ngate-user.lock(not/tmp/ngate.lock) — separate flock so the user-scan can run alongside the server-scan; one slow Hive node won't make the other pipeline skip its turn.ngate-user-cron.log— separate log file makes "did the user gate prune anyone today?" a one-linegrepinstead of digging through interleaved server-side noise.
--allow-removals tells the apply step it's OK to remove keys that no longer pass the gate (e.g. a user dropped below 3 CNOOBS). Without it, the whitelist is ADD-only — that's deliberately the safer default but you almost certainly want removals on for a per-user economic gate. Pair with NGATE_MAX_CONSECUTIVE_MISSES=1 so a single transient Hive-RPC error doesn't auto-evict — the row has to be genuinely missing on two consecutive runs to drop.
Test relay → production rollout discipline
Same posture as the v4call-server pipeline: don't add this cron line to a production relay until the test relay has run it cleanly for at least a few cycles. Watch tail -f /opt/nostr-relay/ngate-user-cron.log, watch the strfry log for the plugin's [whitelist-policy] reloaded: N key(s) lines, and confirm new keys show up in cat /opt/nostr-relay/policy/whitelist.json only when you expect them to.
What this step does NOT do
Honest scope:
- Does not change the strfry policy plugin. The plugin still only checks "is this pubkey in the whitelist?" Per-user and per-server keys are indistinguishable to it. If you want different gating rules per source you'd need a smarter plugin and a second whitelist file — not done here.
- Does not enforce kind restrictions. A whitelisted user can publish kind 1, kind 30078, kind anything to your relay. Gating happens at identity, not content. If you want a kind allowlist on top, that's a plugin-level change, not a whitelist-data change.
- Does not edit any of the existing pipeline scripts. Step 16's
ngate-scan.sh/ngate-verify.sh/ngate-gate.sh/ngate-strfry-apply.share byte-identical to before. This is purely additive. - Does not run inside the relay container. The nGate scripts run on the host via cron, write the whitelist file via the bind-mounted
./policydirectory. The strfry container only reads — no Docker socket needed, no relay-restart needed, no image rebuild needed.
Common Problems and Fixes
(Several of these were hit and fixed during the first real deploy of this guide to nostr.hive-book.com — they are reproducible first-boot traps, not edge cases. If you're following along on a fresh box you will probably meet the first three.)
- strfry crash-loops with
Unable to set NOFILES limit to 1000000, exceeds max of 524288 - strfry tries to raise its open-file limit to 1,000,000 on startup; almost every VPS caps lower, so the
setrlimitfails, you seeatexit, and the container restarts forever. The relay log will sayCONFIG: successfully installedright before the error — that is a red herring; the config parsed, the limit call is what failed. Fix: setnofiles = 0inside therelay { }block ofstrfry.conf(Step 8 already does this in the current guide).0= "inherit the container's limit, don't try to raise it" — host-independent. - The subtle part:
nofilesis only read insiderelay { }. If you put it at the top level ofstrfry.conf(a natural mistake), strfry's tolerant parser still printsCONFIG: successfully installedbut silently ignores the key and keeps the built-in 1,000,000 default — so 0, 524288, and anything in between all behave identically (still crashing). If you tried several values and "nothing changed", this is why: wrong scope, not wrong value. Verify the running container's view withdocker compose run --rm --entrypoint sh nostr-relay -c 'grep -n nofiles /etc/strfry.conf'and confirm the line sits underrelay {.
- Edited
strfry.confbut the old behaviour persists / logs show old timestamps - strfry reads its config once, at process start — it does not watch the file. A bind-mounted config change is not picked up by a still-running (or merely
restarted) container, and a crash-looping container may have read the old file before you edited it. Always apply config changes withdocker compose up -d --force-recreate nostr-relay, then read fresh log lines:docker compose logs --since 2m -f nostr-relay. Check the timestamps are newer than your edit — debugging against stale log output wastes a lot of time. (This is the opposite of the whitelist file, which the plugin re-reads live — see "Whitelist change didn't take effect" below. Config = static-at-start; whitelist data = live.)
nak ... status 502/ clients can't connect, "failed to connect to any of the given relays"- A
502 Bad Gatewayfrom your domain means Caddy is up but its upstream (the strfry container) is not listening — almost always because strfry is crash-looping (see the NOFILES entry above). Fix strfry first; the 502 disappears on its own. Don't debug the 502 as a separate problem untildocker compose psshowsnostr-relayas a stableUp(not restarting). Only if strfry is confirmedUpand you still get 502 is it a real proxy issue — then check the Caddyfile upstream name/port (nostr-relay:7777) and that both services share the compose network.
nak eventsucceeds to send but the relay repliesmsg: error: internal error(reads work, writes fail)- The write-policy plugin can't execute. The official strfry image is
FROM alpine— busyboxshonly, nobash, nojq. The Step 9 plugin is bash (associative arrays,${var,,}, process substitution) and callsjq, so inside stock strfry the kernel can't even exec it and strfry returns its genericinternal errorfor every write. Reads work because the plugin is only invoked on writes. Confirm:docker compose exec nostr-relay sh -lc 'which bash; which jq'— both empty. Fix: build and run thestrfry-ngate:1.0.4wrapper image from Step 7 (apk add --no-cache bash jq) and setimage: strfry-ngate:1.0.4in compose (Step 12), thendocker compose up -d --force-recreate nostr-relay. Re-test: a whitelisted key now publishessuccess., a non-whitelisted key getsblocked: not on relay whitelist(the plugin's own message — proof it's running).
- "Nothing changed in the whitelist" but the apply log said it wrote keys
- Almost always a filename mix-up.
ngate-strfry-apply.shwriteswhitelist.json. The plugin script iswhitelist-policy.sh. There is nowhitelist-policy.json— if youcatthat you're reading a stray/old file (delete it). Trust the apply script's own last line:✓ wrote N key(s) to /opt/nostr-relay/policy/whitelist.json—catthat exact path.
- Doubled log lines in
docker compose logs - Usually a stray leftover container from an earlier
docker compose run --rmone-off running alongside the real service.docker compose ps -a; if you see a...-run-...container,docker compose down && docker compose up -dto clean. If only one container shows and lines are still doubled, it's just loguru writing to stderr that compose echoes — harmless.
- Caddy can't get a cert
- DNS for
nostr.v4call.comisn't resolving to this box yet.dig +short nostr.v4call.commust return the VPS IP. Wait, re-check, restart Caddy.
- Every event is rejected, even whitelisted keys
- (a)
whitelist.jsonholds npub, not hex — the plugin compares hex. Convert (Step 6). (b) Hex case mismatch — the plugin lowercases both sides, so this shouldn't bite, but double-check the file has no stray quotes/commas (jq . whitelist.jsonmust parse). (c) The./policymount path inside the container doesn't matchstrfry.conf'swritePolicy.pluginpath.
- strfry won't start / "config file not found"
- Your pinned strfry tag expects the config at a different path than
/etc/strfry.conf. Rundocker run --rm strfry:1.0.4 --help, find the expected path, fix the bind-mount target in compose. Pin a known tag; don't trackmaster.
- Plugin "permission denied" in the relay log
chmod +x /opt/nostr-relay/policy/whitelist-policy.sh. Also confirm it has a valid shebang and Unix line endings (file whitelist-policy.shshould not say "CRLF").
- LMDB / database permission errors
- Same class of issue as the stage-1 nostr-rs-relay SQLite UID gotcha. strfry's container user must be able to write
/app/strfry-db. Quick fix on a single-purpose box of public events:chmod -R 777 /opt/nostr-relay/strfry-db. Cleaner fix:chownto the container's UID.
- Whitelist change didn't take effect
- The plugin reloads on mtime change. If you edited in place with an editor that preserves mtime (rare) or wrote without changing the file, touch it:
touch /opt/nostr-relay/policy/whitelist.json. Always use the atomictmp+mvpattern (asngate-strfry-apply.shdoes) — it guarantees a new mtime and never exposes a half-written file to the plugin.
- Build killed (OOM) on a small VPS
- Add swap (Step 7) and rebuild. The C++ compile is the only memory-hungry part; once the image exists, runtime RAM is small.
ngate-user-scan.shfinds posts butngate-user-verify.shrejects every one with "attestation v4call_hive_account tag ≠ post author"- The post was authored by one Hive account but its body's attestation tags name a different Hive account. Usually means somebody copy-pasted the body of someone else's
nostr-announcepost into their own draft and re-posted under their own account — the Nostr signature is still valid (the original signer's nsec produced it) but the tag no longer matches the new author, so the bind is broken on purpose. This is the system working — a real binding requires the original signer to publish under their own Hive account. Tell the user to re-sign innostr-announce.htmlusing their own Hive account + their own nsec, then re-post.
ngate-user-verify.shrejects a post with "attestation v4call_hive_account tag ≠ body HIVE-ACCOUNT field"- The post body's
HIVE-ACCOUNT:line saysalicebut the signed attestation inside it namesbob. Same class as above — usually a copy-paste mistake or an attempt to fool a casual reader who only skims the body. The verify rejects on the cryptographic tag, not the human-readable body line, so this never reaches the whitelist. Re-sign innostr-announce.htmlwith the correct Hive account in the form field, the page generates a fresh attestation, post again.
ngate-user-verify.shsays "missing nostr_pubkey_hex or nostr_attestation_b64"- The post body is missing one of the required fields in the
[V4CALL-NOSTR-BINDING-V1]block. Almost always because the user posted manually via peakd.com / hive.blog instead of usingnostr-announce.htmland forgot a line. Easiest fix: use the tool — it builds the block correctly every time.
- User gets
Post failed: unknown errorinnostr-announce.html - Almost never a v4call/nGate bug. Two Hive-side causes: (a) Hive enforces a ~5 minute gap between posts from the same account — if the user posted anything (this binding, a regular post, anything) less than 5 min ago, the broadcast fails. Wait, retry. (b) Low Resource Credits, especially on fresh accounts with no staked Hive Power — check at
https://hivestats.io/@username. Browser DevTools Console shows the full Hive response which usually carries the actual reason.
What's deferred
Honest scope notes, so nobody is surprised later:
- 3-strike miss tolerance + state.json —
ngate-apply.sh(stage 1) tolerates a key being briefly missing from Hive for N cycles before removing it (CLAUDE.md decision #4), tracked instate.json. The minimalngate-strfry-apply.shabove is recompute-fresh each run (seed ∪ current verified+gated set). That is safe and matches nGate's "Hive is source of truth, ADD-only on partial failure" philosophy only if you keep nGate's existing ADD-only guard inngate-sync.sh(don't pass--allow-removalson a partial upstream failure). Porting the full miss-counter/state machinery to the strfry backend is a follow-up, not done here. - Restart cap / sanity bound — intentionally dropped, not ported. There is no restart, so a restart loop is impossible by construction. The sanity bound (refuse to empty the whitelist on garbage upstream) is still worth porting later as a guard against a bad scan nuking the file; for now the seed-merge means the operator's own key always survives.
- High-throughput plugin — bash+jq per event is fine for a low-rate federation-discovery relay. If this relay ever becomes a busy public one, rewrite the plugin in Go/Rust. Note it; don't pre-optimise.
These are tracked against Stage 4 in STATUS.md.
Tips for Noobs
- The plugin is just a program that answers yes/no per event. If you can write a shell script, you can write a strfry policy. Test it by hand:
echo '{"type":"new","event":{"id":"x","pubkey":"abc"}}' | ./policy/whitelist-policy.shshould print an accept/reject line. - Atomic writes always. Any time something rewrites
whitelist.json— nGate, you by hand, a cron — write to a temp file thenmv.mvon the same filesystem is atomic; the plugin never sees a half-file, and mtime always bumps so the reload fires. - seed.toml is your seatbelt. Put your own key there before first start. Even if every scan fails forever, you can still publish.
- Read is public, write is gated. That's the model. Anyone can read your relay's events; only whitelisted keys can write. If you want private reads too, that's a different (and more complex) NIP-42 auth setup — out of scope here.
- strfry ≠ nostr-rs-relay config. Don't copy a
config.tomlacross. Different daemon, different config language. The only thing that carries over conceptually is "a list of allowed hex pubkeys" — and now that list is a live file, not a restart-on-change config block. - Keep the strfry tag pinned.
strfry:1.0.4, notstrfry:latest. When you choose to upgrade, do it deliberately: bump the tag,git checkoutthe new release, rebuild, test Step 14 + 15 again.
Part of the v4call / nGate documentation set. See also: Nostr Relay With a 3-Key Whitelist (stage 1, nostr-rs-relay), nGate-auto-whitelist (stage 3, the scan→verify→gate→apply pipeline), V4call (the platform this federation-discovery layer serves).