Nostr-relay-with-whitelist

From CompleteNoobs
Jump to navigation Jump to search
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:
https://creativecommons.org/share-your-work/public-domain/cc0

The person who associated a work with this deed has dedicated the work to the public domain by waiving all of his or her rights to the work worldwide under copyright law, including all related and neighboring rights, to the extent allowed by law.

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 keys
  • wss://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

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.com and nostr.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.html in any browser. It generates an npub + nsec entirely in the browser using window.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 generate to 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.

  1. Log into my.vultr.com
  2. Click Deploy New Server
  3. Choose Cloud Compute — Shared CPU
  4. Choose a location close to where most of your readers are
  5. Choose Ubuntu 24.04 LTS x64
  6. Choose the $6/month plan (1 CPU, 1GB RAM, 25GB SSD) — plenty for a private relay with 3 publishers
  7. Set Server Hostname to something like nostr-hivebook
  8. 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 nostr
Value 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 with OK 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 use nostr-gen.html in 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 like z, p, j — instant red flag.
  • If you typo a hex character (63 chars, or a stray g-z), the relay refuses to start. Check docker compose logs nostr-relay for 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_url and name to 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 ./data on 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.com via the ACME challenge, then a successful certificate obtained line.

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.com from 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 websocat on 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 an edition2024 error. 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-musl
sudo chmod +x /usr/local/bin/websocat
websocat --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 of websocat 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-go
go install github.com/fiatjaf/nak@latest
echo 'export PATH=$PATH:$HOME/go/bin' >> ~/.bashrc && source ~/.bashrc
nak --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 --help says "command not found" after install, your shell hasn't reloaded PATH. Either open a new terminal or run export PATH=$PATH:$HOME/go/bin in the current one.
Option 1D — Mac users
brew install websocat just 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 at docker 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_url and name in config.toml use 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:

  1. SSH into the box.
  2. nano /opt/nostr-relay/config.toml and edit the pubkey_whitelist array.
  3. 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 yourdomain from 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 into pubkey_whitelist instead 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.html from the v4call repo (the hex pubkey is now displayed under the npub with a copy button). Replace every entry in pubkey_whitelist with the hex form, then:
cd /opt/nostr-relay && docker compose restart nostr-relay
Watch docker compose logs -f nostr-relay while 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 to config.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.toml on 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 websocat fails with feature '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 wscat via npm. Or skip ahead to nak.
websocat: command not found after 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 file looping 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 ./data folder 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 via docker run --rm scsibug/nostr-rs-relay:latest id, then chown -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 and docker 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 with nsec1, 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:30078 announce 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_info in 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. 🌿