quoting naddr1qq…uyneRunning a Sovereign Personal Nostr Relay at Home
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 relaysThe 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 scriptI use
strfrybecause it is a serious relay implementation with local storage, write-policy plugins, router tooling, and support for NIP-77 negentropy syncing.strfrystores 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 RelayIt 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-202605Bad 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 hyphenIf 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.comYour relay will normally use a subdomain:
relay.example.comCloud 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-comNostr public key in hex
The relay whitelist uses your Nostr public key in hex format. It does not use your
npubstring. It definitely does not use yournsec.Your hex pubkey is 64 lowercase hexadecimal characters.
Example shape:
0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef2. Install and authenticate
gcloudIf 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.comSign 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-cliGoogle documents this apt-based install path for Debian and Ubuntu. (Google Cloud Documentation)
Authenticate the CLI:
gcloud initThat 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.pyThen 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_IDto something more specific and run the command again.Set the project as your current
gcloudproject: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 defaultgcloudcontext changed.List your billing accounts:
gcloud billing accounts listIf 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 DomainsSearch 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. publicAt 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
Arecord: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-setscommand 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
Arecord. 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
gcloudcommand 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.serviceCreate 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.shTest 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.shStop it with
Ctrl+Cafter 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.serviceEnable user services to run after reboot without an active login:
sudo loginctl enable-linger "$USER"Watch logs:
journalctl --user -u gcloud-ddns.service -fIf you have more than one apex domain to update, use a semicolon-separated
DDNS_RECORDSvalue 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
80and443. Caddy then reverse proxies to strfry on127.0.0.1:7777.Forward these ports on the home router:
TCP 80 -> Debian PC LAN IP:80 TCP 443 -> Debian PC LAN IP:443Do not forward these:
TCP 22 TCP 7777If 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 numberedVerify 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 filtered9. 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 jqBuild 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 | headThe 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/strfry11. 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.pystrfry 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.confThe 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 = 1000setting 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 strfryCheck 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 | jqExpected 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 caddyConfigure
/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 caddyCaddy can serve a reverse proxy over HTTPS automatically when configured with a hostname, and its
reverse_proxydirective 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 | jqTest publicly from outside your LAN:
curl -sS -H 'Accept: application/nostr+json' "https://${RELAY_HOST}" | jqIn Nostr clients, add:
wss://relay.example.comUse 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 propagationA 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.shRun it as the normal user, not with
sudo:PUBKEY_HEX="$PUBKEY_HEX" \ BACKFILL_RELAYS="$BACKFILL_RELAYS" \ ~/bin/nostr-backfill-events-90d.shCheck 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 -l17. Republish new events to public relays
The personal relay stores your events. Public relays are still useful for distribution.
strfry-routercan 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.confTest 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.confIf 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-routerWatch logs:
sudo journalctl -u strfry-router -fSuccessful 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' doneAfter testing, reduce log noise:
sudo sed -i 's/^verbose = true/verbose = false/' /etc/strfry/router.conf sudo systemctl restart strfry-router18. Operational commands
Check services:
systemctl is-active strfry systemctl is-active strfry-router systemctl status strfry systemctl status strfry-routerView logs:
sudo journalctl -u strfry -n 100 --no-pager sudo journalctl -u strfry-router -n 100 --no-pager sudo journalctl -u strfry-router -fCheck 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}" | jqCount 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 -lCheck 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' done19. 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 FailureIf available, set it to:
Power OnThat 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 separateThis 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.
