Nostr-relay-with-whitelist
| 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. |
Nostr Relay With a 3-Key Whitelist — Two Servers on Ubuntu 24.04 with Docker
From CompleteNoobs
This guide walks through deploying two private Nostr relays from scratch on fresh Vultr Ubuntu 24.04 VPS boxes — one at nostr.hive-book.com, one at nostr.v4call.com — locked down so that only 3 specific npub keys can publish events. The relays will serve over secure WebSocket (wss://) with auto-renewing Let's Encrypt certificates.
This is a "noob doing Nostr by doing" walkthrough. If you have never run a Nostr relay before, you are in the right place. The same steps work for one relay or fifty — repeat Steps 1–14 for each box, just with a different domain name.
End result:
wss://nostr.hive-book.com— accepts events only from your 3 whitelisted keyswss://nostr.v4call.com— accepts events only from your 3 whitelisted keys- Anyone in the world can read events from these relays; only your 3 keys can write to them
- TLS certificates auto-renew, no manual fiddling
Quick Background — What is a Nostr Relay?
If you are brand new to Nostr, here is the 60-second version:
- Nostr = "Notes and Other Stuff Transmitted by Relays". A simple decentralised protocol — no blockchain, no accounts to register, no central server.
- Identity is just a public key (an "npub", e.g.
npub1abc...xyz). You sign your messages ("events") with your private key ("nsec"). Anyone can verify the signature with your npub. - Relays are dumb servers that store and forward signed events. They do not own your identity. If one relay bans you, you publish to a different one. Your followers' clients query multiple relays and merge the results.
- Why run your own relay? Independence. Censorship-resistance. Speed. And — for this guide — the ability to run a private relay where only you (and your 2 friends, business partners, family — whoever holds the 3 whitelisted keys) can publish.
A "whitelisted-write, public-read" relay is a great starter project. Outsiders can't spam it; you and your trusted keys can post freely; the world can still read your events if they know the relay URL. It's also useful infrastructure for v4call's federation discovery layer (see V4call).
Contents
- 1 What You Need
- 2 Step 1: Get Your 3 Whitelisted npubs Ready
- 3 Step 2: Convert npub to hex
- 4 Step 3: Create Your Vultr VPS (x2)
- 5 Step 4: Point Your Domains at the VPS Boxes
- 6 Step 5: Log into Your VPS
- 7 Step 6: Update and Secure the Server
- 8 Step 7: Install Docker
- 9 Step 8: Open the Firewall
- 10 Step 9: Create the Relay Project Folder
- 11 Step 10: Write the Relay Config (config.toml)
- 12 Step 11: Write the Caddy Config (Caddyfile)
- 13 Step 12: Write the docker-compose.yml
- 14 Step 13: Start It Up
- 15 Step 14: Test the Relay
- 16 Step 15: Repeat for the Second Server
- 17 Updating the Whitelist
- 18 Common Problems and Fixes
- 19 Tips for Nostr Noobs
What You Need
- A Vultr account — sign up at vultr.com. Please use our Vultr Referral link to help cover server costs.
- Two domain names (or subdomains) with DNS access — for this guide:
nostr.hive-book.comandnostr.v4call.com. Substitute your own. - Three Nostr key pairs — the 3 npubs that will be allowed to publish. If you don't have them yet, see Step 1.
- A terminal — Mac: Terminal. Windows: PowerShell or PuTTY. Linux: whatever you've got.
- Around 30–45 minutes per server — most of which is waiting for DNS, apt updates, and Docker pulls.
You do not need to know Rust, Go, or any programming. Every command can be copy-pasted exactly as shown.
Step 1: Get Your 3 Whitelisted npubs Ready
You need 3 Nostr public keys (npubs). These are the only keys that will be allowed to publish events to your relays.
If you don't have them yet, generate them. Pick whichever method you like:
- Option A — Browser-only generator (recommended, no install)
- If you have v4call cloned, open
public/nostr-gen.htmlin any browser. It generates an npub + nsec entirely in the browser usingwindow.crypto.subtle. The keys never touch a network. - Save the npub (public, fine to share) and the nsec (private, NEVER share, treat like a posting key).
- Option B — Use a Nostr client you already use
- Damus, Amethyst, Iris, nos2x, Alby, etc. all let you export your npub. Settings → Profile → Public key.
- Option C — Command-line with
nak - Install nak:
go install github.com/fiatjaf/nak@latest - Then run
nak key generateto make a new pair.
⚠ Do this 3 times if you need 3 fresh pairs. Or use existing keys you already trust — the 3 don't have to be new.
Write down the 3 npubs in a notes file. Example:
npub1aaaa... (your daily key) npub1bbbb... (a partner's key) npub1cccc... (your v4call server's identity key)
Step 2: Convert npub to hex
Important: Nostr relays internally use the hex form of public keys, not the bech32 npub form. Same key, different encoding. nostr-rs-relay's whitelist needs the hex.
- Easiest converter — nostr-gen.html
- If you generated keys via
nostr-gen.html, the page already shows both forms. Copy the hex.
- Web converter
- nostrcheck.me/converter — paste npub, get hex. (Use any reputable converter; the math is public.)
- Command-line
- With nak:
nak decode npub1aaaa...— prints the hex pubkey. - With Python:
pip install bech32, then a 3-line script. (Out of scope here.)
You should end up with 3 hex strings, each 64 characters long (lowercase, 0-9 and a-f only). Example:
npub1aaaa... → abc123def456... (64 hex chars) npub1bbbb... → fff789abc012... (64 hex chars) npub1cccc... → 111aaa222bbb... (64 hex chars)
Save these. You will paste them into config.toml in Step 10.
💡 Tip: If the converter spits out anything other than 64 hex chars, you pasted the wrong thing (probably an nsec — that's the private key, never paste it into a converter you don't fully trust). Double-check the input started with npub1, not nsec1.
Step 3: Create Your Vultr VPS (x2)
You need two VPS boxes — one for each relay. Repeat this step twice. Name them differently (e.g. nostr-hivebook and nostr-v4call) so you don't confuse them later.
- Log into my.vultr.com
- Click Deploy New Server
- Choose Cloud Compute — Shared CPU
- Choose a location close to where most of your readers are
- Choose Ubuntu 24.04 LTS x64
- Choose the $6/month plan (1 CPU, 1GB RAM, 25GB SSD) — plenty for a private relay with 3 publishers
- Set Server Hostname to something like
nostr-hivebook - Click Deploy Now
Wait ~60 seconds for it to start. Click the server to find:
- IP Address — looks like
123.456.789.012— write it down - Password — click the eye icon — write it down
Repeat for the second server. Now you should have:
nostr-hivebook → IP_A password_A nostr-v4call → IP_B password_B
💡 Tip: $6/month per relay is fine for low-traffic private relays. If your 3 keys post infrequently and the world reads occasionally, you'll never hit any limits. If you want to be cheap, $3.50/month boxes also work (512MB RAM) but pulls/builds are slower.
Step 4: Point Your Domains at the VPS Boxes
Log into your DNS provider for each domain and add an A record:
- For
nostr.hive-book.com
Field Value Type A Name nostrValue IP_A (the hive-book VPS) TTL 300
- For
nostr.v4call.com - Same setup, but Name =
nostr, Value = IP_B (the v4call VPS).
DNS takes a few minutes to propagate. From your computer:
nslookup nostr.hive-book.com nslookup nostr.v4call.com
Each must show the correct VPS IP before Step 13 (Caddy needs the DNS to resolve to fetch a certificate). You can continue with the other steps while waiting.
Step 5: Log into Your VPS
Open a terminal:
ssh root@IP_A
Type yes at the fingerprint prompt, then paste the password from Vultr (right-click to paste in most terminals).
If Vultr forces a password change on first login, set a strong new one and write it down.
Repeat the rest of this guide on both boxes. We'll do them one at a time. Steps 6–14 are identical on both — only the domain name in Step 11 (Caddyfile) changes.
Step 6: Update and Secure the Server
Always start with a system update:
apt update && apt upgrade -y
This may take a few minutes. If it asks about restarting services, hit Enter to accept defaults.
Optional but recommended — make a non-root user (you can skip if you're the only person ever touching this box and you trust your password):
adduser nostr usermod -aG sudo nostr
Then later you can ssh nostr@IP_A and sudo for privileged commands. For this guide we'll just stay as root for simplicity — fine for a single-purpose box.
Step 7: Install Docker
Install Docker the official way:
apt install -y ca-certificates curl install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc 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" > /etc/apt/sources.list.d/docker.list apt update apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Verify:
docker --version docker compose version
You should see version numbers for both. If not, re-run the apt install line.
💡 Tip: docker compose (with a space) is the new way. The old docker-compose (with a hyphen) is deprecated. This guide uses the new form.
Step 8: Open the Firewall
Vultr boxes start with no firewall, but it's good hygiene to enable one. We need ports 22 (SSH), 80 (HTTP, for Let's Encrypt), and 443 (HTTPS/WSS).
ufw allow 22/tcp ufw allow 80/tcp ufw allow 443/tcp ufw --force enable ufw status
You should see all three ports listed as ALLOW.
⚠ Important: do NOT skip port 80. Caddy uses HTTP-01 ACME challenges to get the TLS certificate, which needs port 80 reachable from the internet. Closing it = no certificate = no wss://.
Step 9: Create the Relay Project Folder
mkdir -p /opt/nostr-relay cd /opt/nostr-relay mkdir -p data caddy_data caddy_config
You'll create three files in /opt/nostr-relay:
config.toml— relay configuration (whitelist lives here)Caddyfile— TLS reverse proxy config (domain name lives here)docker-compose.yml— orchestration
Step 10: Write the Relay Config (config.toml)
We'll use nostr-rs-relay — a battle-tested Rust relay with a simple TOML config and a built-in pubkey whitelist (no plugins, no extra daemon).
Create the config file:
nano /opt/nostr-relay/config.toml
Paste this — replace the three hex strings in pubkey_whitelist with the 3 hex pubkeys from Step 2:
[info] relay_url = "wss://nostr.hive-book.com/" name = "hive-book private relay" description = "Private write-whitelisted relay. Public read." # pubkey of the relay operator (hex). Optional but nice. # pubkey = "your-operator-hex-pubkey" # contact = "mailto:you@example.com" [database] data_directory = "/usr/src/app/db" [network] # Bind inside the container; Caddy will reverse-proxy from the public 443. address = "0.0.0.0" port = 8080 [limits] messages_per_sec = 10 subscriptions_per_min = 30 max_event_bytes = 131072 # 128 KB per event — plenty for normal Nostr use max_ws_message_bytes = 131072 max_ws_frame_bytes = 131072 broadcast_buffer = 1024 event_persist_buffer = 16 [authorization] # ONLY these hex pubkeys can publish (write) events. # Reading is open to anyone in the world. pubkey_whitelist = [ "REPLACE_WITH_HEX_OF_NPUB_1", "REPLACE_WITH_HEX_OF_NPUB_2", "REPLACE_WITH_HEX_OF_NPUB_3", ] [verified_users] mode = "disabled" [retention] # Keep everything forever for a private relay. Tweak if you want. # max_events = 0 # max_bytes = 0
Save with Ctrl+O, Enter, then Ctrl+X to exit nano.
⚠ Important — read this twice, it's the #1 mistake:
- The whitelist is the HEX form of the pubkey, NEVER the npub form. Lowercase, exactly 64 characters, only digits 0-9 and letters a-f. Pasting
npub1abc...values here is a silent disaster — the relay starts fine and accepts connections fine, but every single EVENT you publish gets rejected withOK accepted=false reason="blocked: pubkey is not allowed to publish to this relay", because no pubkey in the world will ever match those bech32 strings. You won't see this until you try to publish, and even then it's a confusing error that looks like you did something wrong. - If you skipped Step 2, go back and do it now. Step 2 is specifically about converting npub → hex for this exact purpose. The conversion is deterministic — same key, just different encoding.
nak decode npub1...on your laptop prints the hex; or usenostr-gen.htmlin the v4call repo (the public hex pubkey is shown right under the npub, with a copy button). - Sanity check before saving: every entry between the quotes should be exactly 64 characters, all lowercase, all hex. Count if you have to.
npub1...values are roughly 63 characters and contain non-hex letters likez,p,j— instant red flag. - If you typo a hex character (63 chars, or a stray
g-z), the relay refuses to start. Checkdocker compose logs nostr-relayfor the parse error. But pasting valid-looking npubs starts cleanly and silently rejects every publish — that's the bigger trap. - For the second server, change
relay_urlandnameto the v4call values, but the whitelist stays the same.
💡 Tip: nostr-rs-relay also supports paid-relay mode and NIP-42 AUTH gating. For "just whitelist 3 keys", pubkey_whitelist is the simplest mechanism. The relay will reject any incoming EVENT whose pubkey field isn't in this list, with a clear NOTICE back to the client.
Step 11: Write the Caddy Config (Caddyfile)
Caddy is a reverse proxy that auto-fetches and auto-renews Let's Encrypt certificates. It's much simpler than nginx + certbot for a single-purpose box.
Create the Caddyfile — use the correct domain for this server (hive-book.com on the first box, v4call.com on the second):
nano /opt/nostr-relay/Caddyfile
Paste (for the first server):
nostr.hive-book.com {
# WebSocket reverse proxy to the relay container on port 8080.
reverse_proxy nostr-relay:8080
# Friendly response if someone hits the URL in a normal browser.
@browser {
not header Connection *Upgrade*
not path /
}
# Optional: log access. Comment out to disable.
log {
output file /data/access.log
format console
}
}
Save and exit.
For the second box, the only change is the first line:
nostr.v4call.com {
reverse_proxy nostr-relay:8080
...
}
Caddy will see this file, fetch a Let's Encrypt cert for the domain on first start, and auto-renew it forever. Zero manual cert work.
💡 Tip: Caddy stores its cert state in /data inside the container — we mount that to ./caddy_data on the host so the cert survives container restarts. Don't delete that folder unless you want Caddy to re-issue from scratch.
Step 12: Write the docker-compose.yml
nano /opt/nostr-relay/docker-compose.yml
Paste:
services:
nostr-relay:
image: scsibug/nostr-rs-relay:latest
container_name: nostr-relay
restart: unless-stopped
volumes:
- ./config.toml:/usr/src/app/config.toml:ro
- ./data:/usr/src/app/db
expose:
- "8080"
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
Save and exit.
What this does:
- nostr-relay container runs the Rust relay, reads
config.toml, persists its SQLite DB to./dataon the host. Not exposed to the public — only Caddy talks to it on the internal Docker network. - caddy container holds the public ports (80, 443), terminates TLS, and reverse-proxies WebSocket traffic to the relay. Persists certificates to
./caddy_data.
Step 13: Start It Up
From /opt/nostr-relay:
docker compose pull docker compose up -d
Watch the logs for a minute:
docker compose logs -f
You should see:
- nostr-relay:
relay starting on 0.0.0.0:8080(or similar) - caddy: a line about obtaining a certificate for
nostr.hive-book.comvia the ACME challenge, then a successfulcertificate obtainedline.
Press Ctrl+C to stop tailing logs. The containers keep running.
⚠ If you see ERROR r2d2: unable to open database file: /usr/src/app/db/nostr.db (looping every few seconds in the nostr-relay logs):
This is the most common first-run snag. The scsibug/nostr-rs-relay container runs as a non-root user (UID 100) inside the container, but your ./data directory on the host was created by root in Step 9 — so the container user can't write to it. SQLite refuses to create the DB file, the relay loops, and Caddy proxies start returning 502.
Quick fix (just unblock me) — works on any image, any version, fine for a single-purpose box where the data dir holds only public Nostr events:
cd /opt/nostr-relay docker compose down chmod -R 777 /opt/nostr-relay/data docker compose up -d docker compose logs -f nostr-relay
You should now see:
INFO nostr_rs_relay::repo::sqlite: Built a connection pool "writer" (min=0, max=2) INFO nostr_rs_relay::repo::sqlite: Built a connection pool "reader" (min=0, max=8)
…and no more r2d2: unable to open database file errors. Press Ctrl+C to stop tailing.
Tighter fix (chown to the correct UID) — recommended once you've confirmed the relay works. The image uses an appuser created by the base image, but the exact UID can change between image releases — don't trust a hardcoded number from a guide, ask the image:
# Find out what UID the container actually runs as: docker run --rm scsibug/nostr-rs-relay:latest id # Example output: uid=1000(appuser) gid=1000(appuser) groups=1000(appuser)
Take the UID and GID from that output (let's say both are 1000) and apply them to the data directory:
cd /opt/nostr-relay docker compose down chmod -R 755 /opt/nostr-relay/data # back to sensible perms chown -R 1000:1000 /opt/nostr-relay/data # use YOUR numbers from above docker compose up -d docker compose logs -f nostr-relay
If logs are clean, the chown stuck and you're now running with proper UID alignment instead of world-writable.
💡 Why does this happen? The relay's image creates a non-root appuser and switches to it before running — good security hygiene (the container can't run as root even if exploited). The cost is that any host-mounted volume needs to be writable by that exact UID. The host's ./data folder was created by root in Step 9, so by default the container user can't write to it. Same pattern v4call uses (UID 1000 there) — Docker bind-mounts always need host-side permissions matching the container-side user.
💡 Will this break on image updates? Usually not — the UID is baked into the image and stays stable across patch releases. But if you ever docker compose pull a new image and the relay starts erroring on the DB again, re-run docker run --rm scsibug/nostr-rs-relay:latest id to check whether the UID changed, and re-chown if it did.
💡 Why not just always use chmod 777? For a private 3-key relay storing public events, it's genuinely fine — the data is signed-and-public by design and the box is single-purpose. The chown approach is just hygiene that pays off if the box ever ends up serving more than one thing.
💡 Ignore the 502 lines from random IPs (e.g. monitor-telegram-clone-realtime/1.0, IPs you don't recognise). Within minutes of a fresh domain getting a Let's Encrypt cert, internet-wide scanners hit it. They get a 502 while the relay is broken and a normal HTTP response (or WebSocket upgrade) once it's working. Not an attack, not anything to fix — just background noise of the public internet. ufw + the whitelist + Caddy's TLS handshake are doing their jobs.
⚠ If Caddy can't get a certificate:
- DNS for the domain isn't pointing to this VPS yet → wait, run
nslookup nostr.hive-book.comfrom a different machine, confirm it resolves to your IP. - Port 80 is closed → re-check Step 8.
- Caddy logs will tell you the exact ACME failure reason. Read them.
Step 14: Test the Relay
- Test 1 — Is the WebSocket reachable?
- You need a tiny WebSocket client on your laptop. Pick whichever is easiest for your OS:
- Option 1A — websocat prebuilt binary (any Linux, no compilation)
- Skip
cargo install websocaton Ubuntu 24.04 — its apt-shipped Rust 1.75 is too old to compile current websocat, and you'll waste 10 minutes downloading half of crates.io before hitting anedition2024error. Grab the prebuilt static binary instead: sudo wget -O /usr/local/bin/websocat https://github.com/vi/websocat/releases/latest/download/websocat.x86_64-unknown-linux-muslsudo chmod +x /usr/local/bin/websocatwebsocat --version
- Option 1B — wscat (Node.js, simpler if you already have npm)
sudo apt install -y npm && sudo npm install -g wscat- Then use
wscat -c wss://...instead ofwebsocat wss://...in the steps below. wscat prints a "Connected" banner so it's friendlier than websocat for first-time testing.
- Option 1C — nak (Go, the Nostr-native option, also handy for Steps 1 + 2)
- Install Go from apt and use it to build nak:
sudo apt install -y golang-gogo install github.com/fiatjaf/nak@latestecho 'export PATH=$PATH:$HOME/go/bin' >> ~/.bashrc && source ~/.bashrcnak --help# confirm it's on PATH- Then skip websocat/wscat entirely:
nak req -k 1 wss://nostr.hive-book.com- Prints any matching events and exits. Done. nak also does npub/hex conversion (
nak decode npub1...) and signing (nak event --sec ...) — see Step 1 and the bonus test at the end of Step 14. - 💡 Tip: if
nak --helpsays "command not found" after install, your shell hasn't reloaded PATH. Either open a new terminal or runexport PATH=$PATH:$HOME/go/binin the current one.
- Option 1D — Mac users
brew install websocatjust works.
Once you have one of the above, connect:
websocat -v wss://nostr.hive-book.com/ # or: wscat -c wss://nostr.hive-book.com/
⚠ websocat's silent connection trips up first-time users. Without the -v flag, websocat opens the WebSocket and just sits there with a blank cursor — no "Connected!" banner, no prompt, nothing. That's not a hang, that's a working connection waiting for you to type. The -v flag in the command above prints lifecycle messages like get_ws_client_peer so you can see it actually connected. (wscat is friendlier — it prints "Connected (press CTRL+C to quit)".)
Once connected (silent or banner, doesn't matter), paste this exact line:
["REQ","test",{"kinds":[1],"limit":1}]
Press Enter. The relay will respond with ["EOSE","test"] (end of stored events) — meaning it's alive and there's nothing stored yet. Press Ctrl+C to disconnect.
💡 If you got nothing back after typing the REQ — there's actually a stored event somewhere on this relay (so the response would be a JSON event line then EOSE), or the relay is alive but rejecting your REQ for some reason. Run with nak req -k 1 wss://... instead to get a clearer view of what's happening. Or check docker compose logs nostr-relay on the server side for what it received.
💡 ["NOTICE","could not parse command"] is also a "working" signal. If you accidentally hit Enter on a blank line, type plain English, or paste anything that isn't valid Nostr JSON, the relay replies with a NOTICE rejection. That's good news — it proves the relay parsed your input and pushed back. A truly broken relay would either disconnect or stay silent. So if you mistype the REQ command and see a NOTICE come back, just paste the correct JSON and try again.
💡 You don't actually have to type anything to confirm Test 1 passed. The verbose output from websocat -v already tells you everything you need:
[INFO websocat::net_peer] Connected to TCP <vps-ip>:443
[INFO websocat::ws_client_peer] Connected to ws, response headers: Headers { ...
Server: Caddy
Upgrade: websocket
}
The TCP-on-443 line + the Upgrade: websocket header + Server: Caddy together prove: DNS resolved, TLS handshake completed, Caddy accepted the connection, the WebSocket upgrade succeeded, the relay is on the other end. If you see those, you can Ctrl+C right there — Test 1 is already a pass. Typing the REQ is just an extra "and it speaks Nostr" sanity check on top.
💡 Tip: you can also test purely from a browser. Open your client of choice (e.g. nostrudel.ninja) and add wss://nostr.hive-book.com as a custom relay. If it connects without TLS errors, the relay is reachable. The browser console (F12) will show the WebSocket open/close lifecycle.
- Test 2 — Whitelist actually rejects strangers
- From any Nostr client logged in with a key that is NOT in your whitelist, try posting a note and add
wss://nostr.hive-book.com/as a relay. The relay should respond with an OK=false NOTICE explaining the pubkey isn't allowed. Look atdocker compose logs nostr-relay— you'll see the rejection logged.
- Test 3 — Whitelisted key CAN publish
- Use a Nostr client logged in with one of your 3 whitelisted keys. Add the relay. Post a short note. Run Test 1's REQ command again — this time you should see the event come back.
If all three tests pass, your first relay is live. 🎉
- Bonus test with
nak - If you have nak installed, you can publish straight from the command line:
nak event --sec YOUR_NSEC_HEX -c "hello private relay" wss://nostr.hive-book.com- And query:
nak req -k 1 wss://nostr.hive-book.com
Step 15: Repeat for the Second Server
Log out, ssh into the v4call box (ssh root@IP_B), and repeat Steps 6 through 14 with these tweaks:
- Step 10 —
relay_urlandnameinconfig.tomluse the v4call domain. - Step 11 — first line of the Caddyfile is
nostr.v4call.com {.
The 3-key whitelist stays identical on both boxes — that's the whole point: same 3 keys can publish to either relay, and you have geographic / provider redundancy in case one box has an issue.
When you're done, both wss://nostr.hive-book.com and wss://nostr.v4call.com answer to the same 3 keys.
Updating the Whitelist
To add or remove a key:
- SSH into the box.
nano /opt/nostr-relay/config.tomland edit thepubkey_whitelistarray.cd /opt/nostr-relay && docker compose restart nostr-relay
The relay restarts in a couple of seconds. Caddy keeps running — no certificate work needed for a config tweak.
Repeat on the second box if you want both relays in sync.
💡 Tip: make this a habit — keep the 3 hex pubkeys in a notes file outside the server too. When you rotate a key, update the notes file and both relays.
Common Problems and Fixes
- Caddy: "no domains qualify for managed certificates"
- DNS not pointing to the box yet, or pointing to the wrong box. Run
nslookup yourdomainfrom a third location. If it shows the wrong IP, fix DNS and wait 5 minutes.
- Caddy: "context deadline exceeded" / "connection refused" on ACME challenge
- Port 80 is firewalled. Re-check
ufw status. Also make sure no other process is on port 80 (ss -tlnp | grep ':80').
- Relay logs: "invalid pubkey in whitelist"
- Your hex string isn't 64 lowercase hex characters. Common mistake: pasting the npub instead of the hex; or leaving in a stray space; or accidentally pasting an nsec. Reconvert from npub → hex (Step 2) and paste again.
- Every publish gets
OK accepted=false reason="blocked: pubkey is not allowed to publish to this relay"even though I added my key - 99% of the time: you pasted
npub1...values intopubkey_whitelistinstead of the 64-char hex form. The relay starts cleanly with npubs (it's just a string list to TOML) but no real pubkey ever matches a bech32 string, so every publish silently rejects. - Fix: convert each npub to hex on your laptop —
nak decode npub1abc... | jq -r .pubkey- or open
nostr-gen.htmlfrom the v4call repo (the hex pubkey is now displayed under the npub with a copy button). Replace every entry inpubkey_whitelistwith the hex form, then: cd /opt/nostr-relay && docker compose restart nostr-relay- Watch
docker compose logs -f nostr-relaywhile you re-publish from your client — the rejection line shows exactly which hex pubkey the relay is checking against; you can compare it character-by-character toconfig.toml. If they don't match, the wrong key got loaded into your client (or the wrong hex into the config). - Edge cases that cause the same error: case mismatch (whitelist must be lowercase), a stray trailing space inside the quotes, or you edited
config.tomlon one box but not the other. Always check both.
- Whitelisted client gets rejected anyway
- Almost always: the client signed the event with a different key than you put on the whitelist (e.g. you converted the wrong npub, or you have multiple identities in your client). In the client, verify which key is actively signing, get its npub, convert again, compare to
config.toml.
websocat wss://...says "TLS handshake error"- Caddy hasn't finished provisioning the certificate yet. Wait 30 seconds and try again. If it persists for >5 minutes, check
docker compose logs caddy.
cargo install websocatfails withfeature 'edition2024' is required- Ubuntu 24.04's apt cargo (1.75) is too old to compile current websocat. Don't try to upgrade Rust just for this — grab the prebuilt static binary instead (Test 1, Option 1A in Step 14). Or use
wscatvia npm. Or skip ahead tonak.
websocat: command not foundafter a failed install- The cargo install errored before it could place the binary. Use Option 1A (prebuilt binary to
/usr/local/bin) instead.
r2d2: unable to open database filelooping in nostr-relay logs (and Caddy returning 502)- UID mismatch on the bind-mounted data directory. The container runs as a non-root
appuser; your./datafolder is owned by root. Quick fix:docker compose down && chmod -R 777 /opt/nostr-relay/data && docker compose up -d(fine for a single-purpose box). Tighter fix: find the image's actual UID viadocker run --rm scsibug/nostr-rs-relay:latest id, thenchown -R UID:GID /opt/nostr-relay/data. See the inline note at the end of Step 13 for the full explanation.
- Container keeps restarting
docker compose logs --tail=50 nostr-relay— almost always a config.toml syntax error (missing quote, unclosed bracket). Fix anddocker compose up -d.
- Disk filling up over time
- Check
du -sh /opt/nostr-relay/data. If you're storing kind 1 notes from 3 active users and never deleting, it grows slowly (megabytes per year). If it grows fast, something is wrong (a whitelisted key is firehosing) — investigate the events table.
Tips for Nostr Noobs
- npub vs nsec: npub starts with
npub1, is your public identity, share freely. nsec starts withnsec1, is your private signing key, never paste it anywhere except your own client (and ideally only via a hardware-isolated tool like a browser extension or a hardware signer). If you ever paste an nsec into a website you don't fully trust, treat that key as compromised and rotate. - There is no "delete" on Nostr — events are signed objects relays store. NIP-09 has a "delete request" event, but relays may or may not honour it, and other relays will still have copies. Treat anything you publish as permanent.
- Public read, private write is a useful pattern — what you've built. People can see what your 3 keys publish (great for credibility, transparency, archive value) but can't pollute your relay with their own stuff.
- Federation note for v4call operators: the v4call NOSTR-DESIGN-NOTES.md (in the v4call repo) describes a server-level identity (per-server keypair +
kind:30078announce events). One natural pattern: the third whitelisted key on your relay is your v4call server's Nostr identity, while the other two are your personal + a partner's. The relay then doubles as v4call federation discovery infrastructure. - Backups: back up
/opt/nostr-relay/data(the SQLite DB). The Caddy cert volume can be re-issued from Let's Encrypt at any time, so it's less critical, but no harm in including it. - Costs: $6/month per Vultr box × 2 = $12/month. Domain renewal is the only other cost. No platform fees, no per-event charges.
- Want to learn more? Read the NIPs (Nostr Implementation Possibilities) — they're short, readable specs. NIP-01 is the protocol core. NIP-11 is relay information documents (worth implementing later via
relay_infoin config.toml). NIP-42 is AUTH (a more sophisticated alternative to a flat whitelist). - Test client: nostrudel.ninja is a clean web client that lets you add custom relays per account. Great for poking at your own relay without committing to a heavy app.
You now have two private, whitelisted Nostr relays. Welcome to running your own Nostr infrastructure. 🌿