The point of Nostr is not just that my account can move between clients. The point is that my events are mine.
If I publish everything through other people’s relays, I am still depending on other people’s databases. They may delete old events. They may stop accepting my writes. They may go offline. They may change policy. They may decide my event kind is not worth storing. That is better than a fully centralized social network, but it is not the strongest version of sovereignty.
The truly sovereign way to use Nostr is to run a relay that stores my own signed events under my own domain. Other relays can still be useful for distribution, discovery, replies, zaps, and redundancy, but my personal relay becomes the authoritative archive. My clients write to it. It stores my events. Anyone can read from it. Only my pubkey can write to it.
This article walks through this setup:
Nostr client
writes to:
wss://relay.example.com
reverse proxied by Caddy to:
strfry on 127.0.0.1:7777
stores events locally in:
LMDB database on a Debian PC or server
republishes selected events to:
public relays
The core pieces are:
Google Cloud project
Google Cloud DNS
domain name
optional dynamic DNS updater
router port forwarding
Caddy reverse proxy with HTTPS
strfry Nostr relay
write whitelist plugin
strfry-router republishing worker
optional backfill script
I use strfry because it is a serious relay implementation with local storage, write-policy plugins, router tooling, and support for NIP-77 negentropy syncing. strfry stores data locally in LMDB and supports NIPs including 11 and 77. (GitHub)
1. Understand the names and IDs before setting variables
There are a few names in this setup that look similar but are not the same thing.
Google Cloud project name
The project name is the human-readable label in Google Cloud.
Example:
My Nostr Relay
It does not need to be globally unique. You can change it later.
Google Cloud project ID
The project ID is the important one. It must be globally unique across all of Google Cloud, not just unique in your account. It becomes part of many commands and resource references. Google’s own docs say project IDs are globally unique, permanent after project creation, 6 to 30 characters, lowercase letters, numbers, and hyphens only, must start with a letter, cannot end with a hyphen, and cannot be reused if already in use or previously used. (Google Cloud Documentation)
Good project ID examples:
nostr-relay-alice-2026
my-nostr-relay-839271
sovereign-relay-202605
Bad project ID examples:
My Nostr Relay # spaces and uppercase letters
nostr # too short or likely unavailable
google-relay # restricted string risk
relay- # cannot end with hyphen
If project creation fails because the project ID is already taken, choose a more specific one and try again.
Domain name
This is the domain you register and control.
Example:
example.com
Your relay will normally use a subdomain:
relay.example.com
Cloud DNS managed zone name
The managed zone name is Google Cloud’s internal handle for the DNS zone. It is not public-facing. A simple convention is to replace dots with hyphens:
Domain: example.com
Zone: example-com
Nostr public key in hex
The relay whitelist uses your Nostr public key in hex format. It does not use your npub string. It definitely does not use your nsec.
Your hex pubkey is 64 lowercase hexadecimal characters.
Example shape:
0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
2. Install and authenticate gcloud
If you already use Gmail or any other Google service, you already have the Google account identity you need. That does not mean you already have a Cloud project, billing account, DNS zone, or registered domain. It means you can sign into Google Cloud Console with your Google credentials and create those things.
Start in the browser:
https://console.cloud.google.com
Sign in with your Google account. If this is your first time using Google Cloud, the console may ask you to accept terms or finish account setup. Do that first.
On Debian or Ubuntu, install the Google Cloud CLI:
sudo apt-get update
sudo apt-get install -y ca-certificates gnupg curl
curl https://packages.cloud.google.com/apt/doc/apt-key.gpg \
| sudo gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg
echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" \
| sudo tee /etc/apt/sources.list.d/google-cloud-sdk.list
sudo apt-get update
sudo apt-get install -y google-cloud-cli
Google documents this apt-based install path for Debian and Ubuntu. (Google Cloud Documentation)
Authenticate the CLI:
gcloud init
That will open a browser login flow.
3. Choose your project, domain, and relay variables
Now set the variables. Replace these example values with your own.
This is the first block that drives most of the remaining copy/paste commands:
export PROJECT_ID="nostr-relay-yourname-2026"
export PROJECT_NAME="My Nostr Relay"
export PRIMARY_DOMAIN="example.com"
export RELAY_HOST="relay.${PRIMARY_DOMAIN}"
export PRIMARY_ZONE="$(printf '%s' "$PRIMARY_DOMAIN" | tr '.' '-')"
export PUBKEY_HEX="your_64_character_nostr_public_key_hex"
echo "PROJECT_ID=$PROJECT_ID"
echo "PROJECT_NAME=$PROJECT_NAME"
echo "PRIMARY_DOMAIN=$PRIMARY_DOMAIN"
echo "RELAY_HOST=$RELAY_HOST"
echo "PRIMARY_ZONE=$PRIMARY_ZONE"
echo "PUBKEY_HEX=$PUBKEY_HEX"
If you only have your npub, create a helper script to convert it to hex:
mkdir -p ~/bin
cat > ~/bin/npub-to-hex.py <<'EOF'
#!/usr/bin/env python3
import sys
CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
def bech32_polymod(values):
generators = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
chk = 1
for value in values:
top = chk >> 25
chk = (chk & 0x1ffffff) << 5 ^ value
for i in range(5):
if (top >> i) & 1:
chk ^= generators[i]
return chk
def bech32_hrp_expand(hrp):
return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp]
def bech32_decode(bech):
bech = bech.strip()
if bech.lower() != bech and bech.upper() != bech:
raise ValueError("mixed-case bech32 string")
bech = bech.lower()
pos = bech.rfind("1")
if pos < 1:
raise ValueError("missing bech32 separator")
hrp = bech[:pos]
data = [CHARSET.find(c) for c in bech[pos + 1:]]
if any(x == -1 for x in data):
raise ValueError("invalid bech32 character")
if bech32_polymod(bech32_hrp_expand(hrp) + data) != 1:
raise ValueError("invalid bech32 checksum")
return hrp, data[:-6]
def convertbits(data, frombits, tobits, pad=False):
acc = 0
bits = 0
ret = []
maxv = (1 << tobits) - 1
for value in data:
if value < 0 or value >> frombits:
raise ValueError("invalid data range")
acc = (acc << frombits) | value
bits += frombits
while bits >= tobits:
bits -= tobits
ret.append((acc >> bits) & maxv)
if pad and bits:
ret.append((acc << (tobits - bits)) & maxv)
elif bits >= frombits or ((acc << (tobits - bits)) & maxv):
raise ValueError("invalid padding")
return ret
if len(sys.argv) != 2:
print("usage: npub-to-hex.py npub1...", file=sys.stderr)
sys.exit(2)
hrp, data = bech32_decode(sys.argv[1])
if hrp != "npub":
raise SystemExit(f"expected npub, got {hrp}")
raw = bytes(convertbits(data, 5, 8, False))
if len(raw) != 32:
raise SystemExit(f"expected 32-byte pubkey, got {len(raw)} bytes")
print(raw.hex())
EOF
chmod +x ~/bin/npub-to-hex.py
Then run:
export NPUB="npub1your_public_key_here"
export PUBKEY_HEX="$(~/bin/npub-to-hex.py "$NPUB")"
echo "$PUBKEY_HEX"
4. Create the Google Cloud project and link billing
Create the project:
gcloud projects create "$PROJECT_ID" \
--name="$PROJECT_NAME"
If this fails with a conflict or “already exists” error, your project ID is not unique. Change PROJECT_ID to something more specific and run the command again.
Set the project as your current gcloud project:
gcloud config set project "$PROJECT_ID"
I still recommend using explicit --project="$PROJECT_ID" flags in scripts. If you work with multiple Google Cloud projects, you do not want a DNS update script touching the wrong project because your default gcloud context changed.
List your billing accounts:
gcloud billing accounts list
If no billing account exists yet, create or link one in the browser through Google Cloud Console. You need billing for domain registration and some Google Cloud services. Cloud Domains registration requires selecting an available domain, accepting provider terms, and using Cloud Billing. (Google Cloud Documentation)
After you have a billing account ID, set it:
export BILLING_ACCOUNT_ID="your_billing_account_id"
Link billing to the project:
gcloud billing projects link "$PROJECT_ID" \
--billing-account="$BILLING_ACCOUNT_ID"
Verify billing:
gcloud beta billing projects describe "$PROJECT_ID"
5. Register the domain and create the Cloud DNS zone
In Google Cloud Console, go to:
Network Services -> Cloud Domains
Search for your domain and register it.
During registration, choose Cloud DNS as the DNS provider if prompted. Google Cloud DNS can manage public DNS zones and records for your domain. (Google Cloud Documentation)
Enable Cloud DNS in the project:
gcloud services enable dns.googleapis.com \
--project="$PROJECT_ID"
Create the public DNS zone if it was not already created during registration:
gcloud dns managed-zones create "$PRIMARY_ZONE" \
--dns-name="${PRIMARY_DOMAIN}." \
--description="DNS zone for ${PRIMARY_DOMAIN}" \
--visibility=public \
--project="$PROJECT_ID"
If Google says the zone already exists, that is fine. It means the registration flow already created it.
List the zones:
gcloud dns managed-zones list \
--project="$PROJECT_ID"
Expected shape:
NAME DNS_NAME VISIBILITY
example-com example.com. public
At this point, the project exists, billing is linked, the domain is registered, Cloud DNS is enabled, and the managed DNS zone exists.
6. Point the domain to the home IP address
Get the current public IP:
export HOME_IP="$(curl -fsS https://api.ipify.org)"
echo "$HOME_IP"
Create the apex A record:
gcloud dns record-sets create "${PRIMARY_DOMAIN}." \
--type=A \
--ttl=60 \
--rrdatas="$HOME_IP" \
--zone="$PRIMARY_ZONE" \
--project="$PROJECT_ID"
If the record already exists, update it instead:
gcloud dns record-sets update "${PRIMARY_DOMAIN}." \
--type=A \
--ttl=60 \
--rrdatas="$HOME_IP" \
--zone="$PRIMARY_ZONE" \
--project="$PROJECT_ID"
Cloud DNS supports adding and updating resource record sets through the console and the gcloud dns record-sets command surface. (Google Cloud Documentation)
Create the relay hostname as a CNAME:
gcloud dns record-sets create "${RELAY_HOST}." \
--type=CNAME \
--ttl=300 \
--rrdatas="${PRIMARY_DOMAIN}." \
--zone="$PRIMARY_ZONE" \
--project="$PROJECT_ID"
Using a CNAME here means the dynamic DNS script only needs to update the apex A record. The relay hostname follows it automatically.
Verify:
dig +short "$RELAY_HOST"
Expected:
example.com.
<your-current-home-ip>
7. Optional dynamic DNS script for a residential ISP
Many residential ISPs assign dynamic IP addresses. In my case, the IP only changes when the modem reboots, but that is enough to break remote access if DNS is stale.
This script checks the public IP every 10 seconds. If it changed, it updates the configured Cloud DNS record. Every gcloud command explicitly passes --project, because relying on the active default project is an avoidable footgun.
The filenames and paths are generic:
Script: ~/bin/gcloud-ddns.sh
State: ~/.local/state/gcloud-ddns/home-ip.env
Service: ~/.config/systemd/user/gcloud-ddns.service
Create the script:
mkdir -p ~/bin ~/.local/state/gcloud-ddns
cat > ~/bin/gcloud-ddns.sh <<'EOF'
#!/usr/bin/env bash
set -Eeuo pipefail
PROJECT_ID="${PROJECT_ID:?PROJECT_ID is required}"
DDNS_RECORDS="${DDNS_RECORDS:?DDNS_RECORDS is required. Format: zone|fqdn.;zone|fqdn.}"
TTL="${TTL:-60}"
CHECK_INTERVAL_SECONDS="${CHECK_INTERVAL_SECONDS:-10}"
STATE_DIR="${STATE_DIR:-$HOME/.local/state/gcloud-ddns}"
ENV_FILE="$STATE_DIR/home-ip.env"
mkdir -p "$STATE_DIR"
log() {
printf '[%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*"
}
is_valid_ipv4() {
local ip="$1"
local octet
local octets
[[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || return 1
IFS='.' read -r -a octets <<< "$ip"
for octet in "${octets[@]}"; do
[[ "$octet" =~ ^[0-9]+$ ]] || return 1
(( octet >= 0 && octet <= 255 )) || return 1
done
return 0
}
get_public_ip() {
local ip
ip="$(curl -fsS --max-time 8 https://api.ipify.org || true)"
if ! is_valid_ipv4 "$ip"; then
log "Failed to retrieve valid public IPv4 address. Got: ${ip:-empty}"
return 1
fi
printf '%s\n' "$ip"
}
load_cached_ip() {
local cached_ip=""
if [[ -f "$ENV_FILE" ]]; then
cached_ip="$(grep -E '^export HOME_IP=' "$ENV_FILE" | sed -E "s/^export HOME_IP='?([^']*)'?$/\1/" || true)"
fi
printf '%s\n' "$cached_ip"
}
write_cached_ip() {
local ip="$1"
{
printf 'export HOME_IP=%q\n' "$ip"
printf 'export HOME_IP_UPDATED_AT=%q\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
} > "$ENV_FILE"
}
sync_a_record() {
local zone="$1"
local fqdn="$2"
local ip="$3"
local existing_ip=""
if existing_ip="$(
gcloud dns record-sets describe "$fqdn" \
--type=A \
--zone="$zone" \
--project="$PROJECT_ID" \
--format="value(rrdatas[0])" 2>/dev/null
)"; then
if [[ "$existing_ip" == "$ip" ]]; then
log "Record already current: $fqdn -> $ip"
return 0
fi
log "Updating $fqdn in zone $zone: ${existing_ip:-unknown} -> $ip"
gcloud dns record-sets update "$fqdn" \
--type=A \
--ttl="$TTL" \
--rrdatas="$ip" \
--zone="$zone" \
--project="$PROJECT_ID" \
--quiet
return 0
fi
log "Creating $fqdn in zone $zone with IP $ip"
gcloud dns record-sets create "$fqdn" \
--type=A \
--ttl="$TTL" \
--rrdatas="$ip" \
--zone="$zone" \
--project="$PROJECT_ID" \
--quiet
}
sync_all_records() {
local ip="$1"
local records record zone fqdn
IFS=';' read -r -a records <<< "$DDNS_RECORDS"
for record in "${records[@]}"; do
[[ -n "$record" ]] || continue
IFS='|' read -r zone fqdn <<< "$record"
if [[ -z "${zone:-}" || -z "${fqdn:-}" ]]; then
log "Skipping malformed DDNS record entry: $record"
continue
fi
sync_a_record "$zone" "$fqdn" "$ip"
done
}
main() {
local current_ip cached_ip
command -v gcloud >/dev/null || {
log "gcloud not found in PATH"
exit 1
}
command -v curl >/dev/null || {
log "curl not found in PATH"
exit 1
}
log "Starting Cloud DNS dynamic updater for project $PROJECT_ID"
while true; do
if current_ip="$(get_public_ip)"; then
cached_ip="$(load_cached_ip)"
if [[ "$current_ip" != "$cached_ip" ]]; then
log "Detected public IP change: ${cached_ip:-none} -> $current_ip"
sync_all_records "$current_ip"
write_cached_ip "$current_ip"
log "Wrote state file: $ENV_FILE"
else
log "Public IP unchanged: $current_ip"
fi
fi
sleep "$CHECK_INTERVAL_SECONDS"
done
}
main "$@"
EOF
chmod +x ~/bin/gcloud-ddns.sh
Test it manually:
export DDNS_RECORDS="${PRIMARY_ZONE}|${PRIMARY_DOMAIN}."
PROJECT_ID="$PROJECT_ID" \
DDNS_RECORDS="$DDNS_RECORDS" \
TTL=60 \
CHECK_INTERVAL_SECONDS=10 \
~/bin/gcloud-ddns.sh
Stop it with Ctrl+C after it writes the state file.
Create a user-level systemd service:
mkdir -p ~/.config/systemd/user
cat > ~/.config/systemd/user/gcloud-ddns.service <<EOF
[Unit]
Description=Google Cloud DNS dynamic IP updater
[Service]
Type=simple
Environment=PROJECT_ID=${PROJECT_ID}
Environment=DDNS_RECORDS=${PRIMARY_ZONE}|${PRIMARY_DOMAIN}.
Environment=TTL=60
Environment=CHECK_INTERVAL_SECONDS=10
Environment=PATH=${HOME}/bin:/usr/local/bin:/usr/bin:/bin:/snap/bin
ExecStart=${HOME}/bin/gcloud-ddns.sh
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
EOF
systemctl --user daemon-reload
systemctl --user enable --now gcloud-ddns.service
Enable user services to run after reboot without an active login:
sudo loginctl enable-linger "$USER"
Watch logs:
journalctl --user -u gcloud-ddns.service -f
If you have more than one apex domain to update, use a semicolon-separated DDNS_RECORDS value in the service file:
Environment=DDNS_RECORDS=example-com|example.com.;example-net|example.net.
8. Router port forwarding
The relay itself should not be exposed directly. The public internet should reach Caddy on ports 80 and 443. Caddy then reverse proxies to strfry on 127.0.0.1:7777.
Forward these ports on the home router:
TCP 80 -> Debian PC LAN IP:80
TCP 443 -> Debian PC LAN IP:443
Do not forward these:
TCP 22
TCP 7777
If SSH is needed remotely, put SSH behind WireGuard, not directly on the public internet.
On the Debian PC, open local firewall ports:
sudo ufw allow 80/tcp comment 'Caddy HTTP'
sudo ufw allow 443/tcp comment 'Caddy HTTPS'
sudo ufw status numbered
Verify from outside the LAN if possible:
nmap -Pn -p 80,443,7777 "$RELAY_HOST"
Expected:
80/tcp open
443/tcp open
7777/tcp closed or filtered
9. Install and build strfry
Install dependencies:
sudo apt update
sudo apt install -y \
git g++ make libssl-dev zlib1g-dev liblmdb-dev \
libflatbuffers-dev libsecp256k1-dev libzstd-dev \
python3 jq
Build strfry:
mkdir -p ~/src
cd ~/src
git clone https://github.com/hoytech/strfry
cd strfry
git submodule update --init
make setup-golpe
make -j"$(nproc)"
Install the binary:
sudo install -m 755 ./strfry /usr/local/bin/strfry
strfry --help | head
The strfry project documents source builds with submodule initialization and make setup-golpe, and its README describes the router system for mirroring events to neighbor relays. (GitHub)
10. Create the strfry system user and directories
sudo useradd --system --home /var/lib/strfry --shell /usr/sbin/nologin strfry 2>/dev/null || true
sudo mkdir -p /etc/strfry /var/lib/strfry/db /var/log/strfry
sudo chown -R strfry:strfry /var/lib/strfry /var/log/strfry
sudo chmod 750 /etc/strfry /var/lib/strfry
11. Add a write whitelist plugin
This relay is publicly readable, but only your pubkey can write to it.
Create /etc/strfry/whitelist.py:
sudo tee /etc/strfry/whitelist.py >/dev/null <<EOF
#!/usr/bin/env python3
import json
import sys
ALLOWED_PUBKEYS = {
"$PUBKEY_HEX",
}
for line in sys.stdin:
try:
req = json.loads(line)
event = req.get("event") or {}
event_id = event.get("id")
pubkey = event.get("pubkey")
if not event_id:
continue
if pubkey in ALLOWED_PUBKEYS:
res = {"id": event_id, "action": "accept"}
else:
res = {
"id": event_id,
"action": "reject",
"msg": "blocked: relay is writable only by the operator",
}
print(json.dumps(res, separators=(",", ":")), flush=True)
except Exception as exc:
print(f"whitelist plugin error: {exc}", file=sys.stderr, flush=True)
EOF
sudo chmod 755 /etc/strfry/whitelist.py
strfry supports write-policy plugins, and the project changelog documents timeout handling and plugin behavior around write-policy responses. (GitHub)
12. Configure strfry
Create /etc/strfry/strfry.conf:
sudo tee /etc/strfry/strfry.conf >/dev/null <<EOF
db = "/var/lib/strfry/db/"
events {
maxEventSize = 262144
rejectEventsNewerThanSeconds = 900
rejectEventsOlderThanSeconds = 1576800000
rejectEphemeralEventsOlderThanSeconds = 60
ephemeralEventsLifetimeSeconds = 300
maxNumTags = 2000
maxTagValSize = 1024
}
relay {
bind = "127.0.0.1"
port = 7777
realIpHeader = "x-forwarded-for"
auth {
enabled = true
serviceUrl = "wss://${RELAY_HOST}"
}
info {
name = "Personal Nostr Relay"
description = "Personal Nostr relay. Public read access; write access restricted to the operator."
pubkey = "$PUBKEY_HEX"
contact = "nostr:$PUBKEY_HEX"
}
maxWebsocketPayloadSize = 524288
maxReqFilterSize = 200
autoPingSeconds = 55
enableTcpKeepalive = true
maxFilterLimit = 1000
maxSubsPerConnection = 200
writePolicy {
plugin = "/etc/strfry/whitelist.py"
timeoutSeconds = 10
}
compression {
enabled = true
slidingWindow = true
}
}
negentropy {
enabled = true
maxSyncEvents = 1000000
}
EOF
sudo chown root:strfry /etc/strfry /etc/strfry/strfry.conf
sudo chmod 750 /etc/strfry
sudo chmod 640 /etc/strfry/strfry.conf
The larger event and WebSocket limits are intentional. A personal relay should be able to store long-form notes and larger replaceable events without tripping over tiny defaults. The maxFilterLimit = 1000 setting still requires pagination for large history scans, but it is large enough for practical batch polling.
13. Create the strfry systemd service
sudo tee /etc/systemd/system/strfry.service >/dev/null <<'EOF'
[Unit]
Description=Strfry Nostr relay
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=strfry
Group=strfry
Environment=STRFRY_CONFIG=/etc/strfry/strfry.conf
ExecStart=/usr/local/bin/strfry --config /etc/strfry/strfry.conf relay
Restart=always
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ReadWritePaths=/var/lib/strfry /var/log/strfry
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now strfry
Check it:
sudo systemctl status strfry
sudo journalctl -u strfry -n 80 --no-pager
curl -sS -H 'Accept: application/nostr+json' http://127.0.0.1:7777 | jq
Expected output includes relay metadata. NIP-11 defines the relay information document that clients can request from a relay. strfry advertises support for several NIPs, including NIP-11 and NIP-77. (GitHub)
Example output:
{
"description": "Personal Nostr relay. Public read access; write access restricted to the operator.",
"limitation": {
"max_limit": 1000,
"max_message_length": 524288,
"max_subscriptions": 200
},
"name": "Personal Nostr Relay",
"negentropy": 1,
"pubkey": "your_hex_pubkey",
"supported_nips": [
1,
2,
4,
9,
11,
28,
40,
42,
45,
70,
77
]
}
14. Install and configure Caddy
Install Caddy:
sudo apt update
sudo apt install -y caddy
Configure /etc/caddy/Caddyfile:
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.bak.$(date -u +%Y%m%dT%H%M%SZ) 2>/dev/null || true
sudo tee /etc/caddy/Caddyfile >/dev/null <<EOF
${RELAY_HOST} {
encode zstd gzip
reverse_proxy 127.0.0.1:7777
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl enable --now caddy
sudo systemctl reload caddy
Caddy can serve a reverse proxy over HTTPS automatically when configured with a hostname, and its reverse_proxy directive is the standard Caddyfile method for proxying to a backend service. (GitHub)
Test locally:
curl -sS -H 'Accept: application/nostr+json' http://127.0.0.1:7777 | jq
Test publicly from outside your LAN:
curl -sS -H 'Accept: application/nostr+json' "https://${RELAY_HOST}" | jq
In Nostr clients, add:
wss://relay.example.com
Use your own relay hostname.
15. Set the relay role in the Nostr client
Set the personal relay as an outbox or write relay, not as a general inbox relay.
That is the correct shape for this setup:
Personal relay:
readable by anyone
writable only by me
outbox/write relay
Public relays:
used for discovery, replies, inbox, zaps, and general propagation
A write-whitelisted personal relay is not a good inbox relay. Other people cannot write replies, reactions, mentions, or other events to it because the whitelist correctly rejects their pubkeys.
16. One-time backfill from existing public relays
If the account is new, a 90-day lookback may be enough. If you need to poll events farther back, then change LOOKBACK_DAYS="${LOOKBACK_DAYS:-90}". This script polls public relays in conservative one-day batches and imports existing events into the personal relay.
Set the source relays:
export BACKFILL_RELAYS="wss://nostr.bitcoiner.social;wss://nostr.mom"
Create the script:
mkdir -p ~/bin
cat > ~/bin/nostr-backfill-events-90d.sh <<'EOF'
#!/usr/bin/env bash
set -Eeuo pipefail
CONFIG="/etc/strfry/strfry.conf"
STRFRY="/usr/local/bin/strfry"
STRFRY_USER="strfry"
PUBKEY_HEX="${PUBKEY_HEX:?PUBKEY_HEX is required}"
BACKFILL_RELAYS="${BACKFILL_RELAYS:?BACKFILL_RELAYS is required. Format: wss://relay1;wss://relay2}"
LOOKBACK_DAYS="${LOOKBACK_DAYS:-90}"
WINDOW_DAYS="${WINDOW_DAYS:-1}"
LIMIT="${LIMIT:-500}"
SLEEP_SECONDS="${SLEEP_SECONDS:-2}"
DOWNLOAD_TIMEOUT_SECONDS="${DOWNLOAD_TIMEOUT_SECONDS:-120}"
WORKDIR="${WORKDIR:-$HOME/.local/state/nostr-backfill}"
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)"
LOG_FILE="$WORKDIR/backfill-$RUN_ID.log"
RAW_DIR="$WORKDIR/raw-$RUN_ID"
mkdir -p "$RAW_DIR"
log() {
printf '[%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" | tee -a "$LOG_FILE"
}
strfry_cmd() {
sudo -u "$STRFRY_USER" env STRFRY_CONFIG="$CONFIG" "$STRFRY" --config "$CONFIG" "$@"
}
json_filter() {
local since="$1"
local until="$2"
printf '{"authors":["%s"],"since":%s,"until":%s,"limit":%s}' \
"$PUBKEY_HEX" "$since" "$until" "$LIMIT"
}
safe_name() {
printf '%s' "$1" | sed -E 's#^wss?://##; s#[^A-Za-z0-9._-]+#_#g'
}
main() {
sudo -v
if [[ ! -x "$STRFRY" ]]; then
log "ERROR: strfry binary not executable at $STRFRY"
exit 1
fi
if ! sudo -u "$STRFRY_USER" test -r "$CONFIG"; then
log "ERROR: $STRFRY_USER user cannot read $CONFIG"
exit 1
fi
local relays relay
local now start window_seconds cursor next relay_name filter outfile count
local total_downloaded=0
local total_batches=0
IFS=';' read -r -a relays <<< "$BACKFILL_RELAYS"
now="$(date -u +%s)"
start="$((now - LOOKBACK_DAYS * 86400))"
window_seconds="$((WINDOW_DAYS * 86400))"
log "Starting one-time Nostr backfill"
log "Pubkey: $PUBKEY_HEX"
log "Lookback days: $LOOKBACK_DAYS"
log "Window days: $WINDOW_DAYS"
log "Per-request limit: $LIMIT"
for relay in "${relays[@]}"; do
[[ -n "$relay" ]] || continue
relay_name="$(safe_name "$relay")"
cursor="$start"
log "Polling relay: $relay"
while (( cursor < now )); do
next="$((cursor + window_seconds))"
if (( next > now )); then
next="$now"
fi
filter="$(json_filter "$cursor" "$next")"
outfile="$RAW_DIR/${relay_name}_${cursor}_${next}.jsonl"
log "Downloading batch relay=$relay since=$cursor until=$next"
if timeout "$DOWNLOAD_TIMEOUT_SECONDS" \
sudo -u "$STRFRY_USER" env STRFRY_CONFIG="$CONFIG" \
"$STRFRY" --config "$CONFIG" download "$relay" --filter "$filter" > "$outfile"; then
count="$(wc -l < "$outfile" | tr -d ' ')"
if [[ "$count" == "0" ]]; then
rm -f "$outfile"
log "No events found for this batch"
else
log "Downloaded $count event line(s); importing into local relay DB"
if strfry_cmd import < "$outfile" | tee -a "$LOG_FILE"; then
total_downloaded="$((total_downloaded + count))"
total_batches="$((total_batches + 1))"
else
log "WARNING: import failed for $outfile"
fi
if (( count >= LIMIT )); then
log "WARNING: batch hit LIMIT=$LIMIT. This time window may contain more events than retrieved."
fi
fi
else
log "WARNING: download failed or timed out for relay=$relay since=$cursor until=$next"
rm -f "$outfile"
fi
cursor="$next"
sleep "$SLEEP_SECONDS"
done
done
log "Backfill complete"
log "Total downloaded event lines before duplicate handling: $total_downloaded"
log "Non-empty batches imported: $total_batches"
log "Local relay info after import:"
strfry_cmd info | tee -a "$LOG_FILE" || true
}
main "$@"
EOF
chmod +x ~/bin/nostr-backfill-events-90d.sh
Run it as the normal user, not with sudo:
PUBKEY_HEX="$PUBKEY_HEX" \
BACKFILL_RELAYS="$BACKFILL_RELAYS" \
~/bin/nostr-backfill-events-90d.sh
Check the local count afterward:
sudo -u strfry env STRFRY_CONFIG=/etc/strfry/strfry.conf \
/usr/local/bin/strfry --config /etc/strfry/strfry.conf scan \
'{"authors":["'"$PUBKEY_HEX"'"]}' \
| wc -l
17. Republish new events to public relays
The personal relay stores your events. Public relays are still useful for distribution. strfry-router can watch your local DB and push matching events to selected public relays. The strfry README describes router mode for mirroring events between relays. (GitHub)
Set relay lists:
export GENERAL_PUBLISH_RELAYS="wss://nostr.bitcoiner.social;wss://relay.primal.net;wss://relay.ditto.pub/;wss://nostrelites.org/"
export ZAP_PUBLISH_RELAYS="wss://relay.getalby.com/v1"
Create /etc/strfry/router.conf:
general_urls="$(
printf '%s' "$GENERAL_PUBLISH_RELAYS" \
| tr ';' '\n' \
| sed '/^$/d' \
| sed 's/.*/ "&",/' \
| sed '$ s/,$//'
)"
zap_urls="$(
printf '%s' "$ZAP_PUBLISH_RELAYS" \
| tr ';' '\n' \
| sed '/^$/d' \
| sed 's/.*/ "&",/' \
| sed '$ s/,$//'
)"
sudo tee /etc/strfry/router.conf >/dev/null <<EOF
connectionTimeout = 20
verbose = true
streams {
publishGeneralEvents {
dir = "up"
filter = { "authors": [ "$PUBKEY_HEX" ] }
urls = [
$general_urls
]
}
publishZapEvents {
dir = "up"
filter = { "authors": [ "$PUBKEY_HEX" ], "kinds": [ 9734, 9735 ] }
urls = [
$zap_urls
]
}
}
EOF
sudo chown root:strfry /etc/strfry/router.conf
sudo chmod 640 /etc/strfry/router.conf
Test the router config:
timeout 30s sudo -u strfry env STRFRY_CONFIG=/etc/strfry/strfry.conf \
/usr/local/bin/strfry --config /etc/strfry/strfry.conf router /etc/strfry/router.conf
If it connects cleanly, create the systemd worker:
sudo tee /etc/systemd/system/strfry-router.service >/dev/null <<'EOF'
[Unit]
Description=Strfry router for publishing local operator events to public relays
After=network-online.target strfry.service
Wants=network-online.target
Requires=strfry.service
[Service]
Type=simple
User=strfry
Group=strfry
Environment=STRFRY_CONFIG=/etc/strfry/strfry.conf
ExecStart=/usr/local/bin/strfry --config /etc/strfry/strfry.conf router /etc/strfry/router.conf
Restart=always
RestartSec=10
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ReadWritePaths=/var/lib/strfry /var/log/strfry
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now strfry-router
Watch logs:
sudo journalctl -u strfry-router -f
Successful writes may be quiet. Failed writes show up clearly. That is useful for pruning relays that do not store normal events, require payment, or reject your event kinds.
Publish a fresh kind-1 note and then verify it exists on the destination relays:
EVENT_ID="paste_event_id_here"
for relay in ${GENERAL_PUBLISH_RELAYS//;/ }; do
echo "=== $relay ==="
timeout 20s sudo -u strfry env STRFRY_CONFIG=/etc/strfry/strfry.conf \
/usr/local/bin/strfry --config /etc/strfry/strfry.conf download "$relay" \
--filter '{"ids":["'"$EVENT_ID"'"],"limit":1}' \
| jq -r 'if .id then "FOUND kind=\(.kind) id=\(.id)" else empty end'
done
After testing, reduce log noise:
sudo sed -i 's/^verbose = true/verbose = false/' /etc/strfry/router.conf
sudo systemctl restart strfry-router
18. Operational commands
Check services:
systemctl is-active strfry
systemctl is-active strfry-router
systemctl status strfry
systemctl status strfry-router
View logs:
sudo journalctl -u strfry -n 100 --no-pager
sudo journalctl -u strfry-router -n 100 --no-pager
sudo journalctl -u strfry-router -f
Check local relay metadata:
curl -sS -H 'Accept: application/nostr+json' http://127.0.0.1:7777 | jq
curl -sS -H 'Accept: application/nostr+json' "https://${RELAY_HOST}" | jq
Count local events for your pubkey:
sudo -u strfry env STRFRY_CONFIG=/etc/strfry/strfry.conf \
/usr/local/bin/strfry --config /etc/strfry/strfry.conf scan \
'{"authors":["'"$PUBKEY_HEX"'"]}' \
| wc -l
Check whether a specific event exists on public relays:
EVENT_ID="paste_event_id_here"
for relay in ${GENERAL_PUBLISH_RELAYS//;/ }; do
echo "=== $relay ==="
timeout 20s sudo -u strfry env STRFRY_CONFIG=/etc/strfry/strfry.conf \
/usr/local/bin/strfry --config /etc/strfry/strfry.conf download "$relay" \
--filter '{"ids":["'"$EVENT_ID"'"],"limit":1}' \
| jq -r 'if .id then "FOUND kind=\(.kind) id=\(.id)" else empty end'
done
19. The power outage problem
This setup is only as available as the home machine running it.
A normal consumer UPS usually gives enough runtime to bridge a short outage or shut down gracefully, not enough to keep a desktop alive for multiple hours. A bigger battery or power station can solve that, but it gets expensive fast.
The more annoying issue is motherboard behavior. Some BIOS or UEFI setups have a setting like:
Restore on AC Power Loss
AC Power Recovery
After Power Loss
State After G3
Power On After Power Failure
If available, set it to:
Power On
That way the PC boots automatically when power returns.
If the motherboard lacks that setting, a smart plug alone will not fix it. Restoring AC power to the PSU is not the same as pressing the power button. The practical workaround is an ATX auto-start module connected to the motherboard front-panel power switch pins. It simulates a momentary power-button press after power returns.
Do not dwell on this before the relay works. Get the relay running, get DNS and forwarding correct, and then harden availability.
20. Final target state
The completed setup should look like this:
Google Cloud DNS:
relay.example.com -> example.com -> home IP
Dynamic DNS:
optional script keeps apex A records updated when ISP IP changes
Router:
TCP 80 -> relay host machine
TCP 443 -> relay host machine
no public SSH
no public strfry raw port
Debian firewall:
allow 80/tcp
allow 443/tcp
keep 7777 local-only
Caddy:
public HTTPS and WebSocket reverse proxy
strfry:
listens on 127.0.0.1:7777
stores events locally
supports NIP-11 and NIP-77
writable only by your pubkey
Nostr client:
personal relay set as outbox/write relay
strfry-router:
republishes your events to selected public relays
keeps special-purpose relays separate
This is not the only way to run a Nostr relay, but it is the shape I want: my own domain, my own relay, my own database, my own archive. Public relays are useful distribution infrastructure. They should not be the only place my events exist.

