Join Nostr
2026-05-23 00:07:01 UTC

Running 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 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"

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.