How to Run a Nostr Relay on a VPS: Complete strfry Setup Guide
TUTORIAL16 min readfordnox

How to Run a Nostr Relay on a VPS: Complete strfry Setup Guide

Run your own Nostr relay on a $5 VPS. Full strfry setup with Docker, HTTPS via Caddy, NIP-42 auth, spam filtering, note import, and backups.


How to Run a Nostr Relay on a VPS

Nostr has no servers in the traditional sense — it has relays. As nostr.how explains, relays are dumb pipes: they accept signed events, store them, and hand them back to whoever asks. There is no central instance to sign up to, no admin who can delete your identity, and no federation handshake to negotiate.

The catch is the one that page warns about: if every relay that holds your notes goes offline, your notes are gone. Your keypair survives, your history doesn’t. Running your own relay is the only way to guarantee a permanent copy of everything you have ever posted and everything anyone has sent you.

The good news for the wallet: a relay is a WebSocket server storing small blobs of text. A $5 VPS handles a personal relay with room to spare.

What a Nostr Relay Actually Does

What a Nostr Relay Actually Does

What a Nostr Relay Actually Does

A relay speaks one protocol over one WebSocket connection:

That’s essentially it. No user accounts, no passwords, no federation protocol. Identity is a secp256k1 keypair held by the client, and every event carries its own signature, so a relay never has to trust anything it receives — it just checks the math.

This is why relays are cheap to run, and why the interesting decisions are all about policy: who is allowed to write, what you keep, and how long you keep it.

Why run your own

ReasonWhat you get
ArchiveA permanent copy of your notes, independent of any public relay’s uptime
Censorship resistanceNo one can remove your events from a machine you control
SpeedYour own reads come from your own disk, not a relay serving 50,000 strangers
Private groupsA whitelisted relay is a private, encrypted-in-transit group channel
Outbox model (NIP-65)Advertise your relay so clients fetch your notes from the source
No rate limitsPublic relays throttle aggressively; yours doesn’t

Info

A personal relay is not a replacement for public relays. Nostr’s reach comes from writing to several relays at once — yours is the copy of record, public relays are the distribution. Most clients let you configure both.

Choosing Relay Software

There are dozens of implementations. These are the ones worth your time in 2026:

SoftwareLanguageStorageBest for
strfryC++LMDBThe default choice — fast, single binary, negentropy sync
nostr-rs-relayRustSQLiteSmall footprint, easy to reason about
khatruGoPluggableA framework — build a relay with custom rules in ~50 lines
HAVENGo (khatru)BadgerDBOpinionated personal relay: inbox/outbox/chat/private in one
rnostrRustLMDBHigh throughput alternative to strfry

This guide uses strfry. It’s the most widely deployed implementation, stores everything in a single embedded LMDB database (no Postgres to babysit), and ships with strfry sync — a negentropy-based set reconciliation protocol that pulls your entire history off other relays using almost no bandwidth. That last feature is the reason to pick it: it turns “I set up a relay” into “I set up a relay and it already has all 4,000 of my old notes.”

If you want inbox/outbox separation and web-of-trust spam filtering out of the box without writing policy code, look at HAVEN instead — the VPS setup below is identical, only the container changes.

VPS Requirements

Events are text. A note with a few tags is under 1 KB, signature included. Ten thousand notes is roughly 10–20 MB on disk before compression.

Relay typeRAMvCPUDiskNotes
Personal (1 user, whitelisted)1 GB120 GBOverkill already
Small community (10–100 users)2 GB1–240 GBComfortable
Public, open-write4 GB+2–480 GB+Spam is the real cost
Public archive relay8 GB+4+500 GB+LMDB benefits from RAM ≥ working set

The scaling factor nobody mentions: open-write public relays get hammered. Bots blast the same spam events to every relay in the directory, and each one costs a signature verification. A whitelisted personal relay handles thousands of connections on 1 GB; an open relay on the same box will spend its CPU rejecting garbage.

Two things matter more than specs:

  1. A static IPv4 address — clients connect to a hostname you control, and you need a stable A record.
  2. Unmetered or generous bandwidth — a relay that syncs and blasts to other relays moves more traffic than you’d guess.
ProviderPlanPriceWhy
RackNerd1GB KVM$4.98/moCheapest workable personal relay
HetznerCX23€5.49/moBest value; EU jurisdiction
VultrRegular$2.50/mo32 locations, pick your jurisdiction
HostingerKVM 1$5.99/moEasy panel, good for a first server
ContaboVPS S SSD$4.99/mo8 GB RAM if you plan to archive broadly

Jurisdiction is a real consideration here in a way it isn’t for a Matrix homeserver or a Mastodon instance — the whole point of Nostr is that nobody can compel a takedown. Pick a host and a country whose terms you’re comfortable with, and check the providers that accept crypto without KYC if that matters to you. Anything in the under $5/mo tier is enough hardware.

Step 1: DNS and Server Prep

Point a subdomain at your VPS before anything else — Let’s Encrypt needs it to resolve.

Type    Name              Value
A       relay             203.0.113.10
AAAA    relay             2001:db8::1     (if you have IPv6)

SSH in and update:

ssh root@your-server-ip
apt update && apt upgrade -y

Create a non-root user and harden SSH before you expose anything — the VPS security guide covers the full checklist, and SSH key management covers keys properly.

Install Docker:

curl -fsSL https://get.docker.com | sh
apt install docker-compose-plugin -y
docker --version

Open the firewall for HTTP/HTTPS only. Port 7777 stays private — the reverse proxy is the only thing that talks to it:

ufw allow ssh
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable

Warning

Docker writes its own iptables rules and will happily punch through UFW if you publish a container port with -p 7777:7777. In the compose file below, strfry has no published ports — it’s reachable only over the internal Docker network. Keep it that way.

Step 2: Create the strfry Config

mkdir -p /opt/relay/strfry-db
cd /opt/relay
nano strfry.conf

This is a trimmed production config. The defaults strfry ships are sane; these are the lines that actually matter for a VPS deployment:

db = "/app/strfry-db/"

dbParams {
    maxreaders = 256
    mapsize = 10995116277760
    # Set true if your DB grows larger than RAM — cuts IO churn
    noReadAhead = false
}

events {
    maxEventSize = 65536
    rejectEventsNewerThanSeconds = 900
    rejectEventsOlderThanSeconds = 94608000
    ephemeralEventsLifetimeSeconds = 300
    maxNumTags = 2000
    maxTagValSize = 1024
}

relay {
    # 0.0.0.0 inside the container; the proxy is the only thing that reaches it
    bind = "0.0.0.0"
    port = 7777
    nofiles = 524288

    # Required behind a reverse proxy, or every client looks like the proxy
    realIpHeader = "x-forwarded-for"

    auth {
        # NIP-42 — needed for private relays and DM privacy
        enabled = true
        serviceUrl = "wss://relay.yourdomain.com"
        restrictedReadKinds = "4, 1059"
        restrictReadToInvolvedPubkey = true
    }

    info {
        name = "yourdomain relay"
        description = "Personal Nostr relay. Write access is whitelisted."
        pubkey = "your-npub-or-64-char-hex-pubkey"
        contact = "mailto:[email protected]"
        icon = "https://yourdomain.com/icon.png"
    }

    maxWebsocketPayloadSize = 131072
    maxReqFilterSize = 200
    # Must be lower than your proxy's idle timeout or connections get culled
    autoPingSeconds = 55
    enableTcpKeepalive = true
    maxFilterLimit = 500
    maxSubsPerConnection = 200

    writePolicy {
        # Filled in at Step 6
        plugin = ""
        timeoutSeconds = 10
    }

    compression {
        enabled = true
        slidingWindow = true
    }

    numThreads {
        # Match roughly to your vCPU count. 1 vCPU: drop these to 1-2 each.
        ingester = 3
        reqWorker = 3
        reqMonitor = 3
        negentropy = 2
    }

    negentropy {
        enabled = true
        maxSyncEvents = 1000000
    }
}

The info block is your NIP-11 relay information document — it’s what clients show in relay pickers and what directories index. Fill it in properly; an unnamed relay looks abandoned.

Tip

mapsize of ~10 TB looks alarming but is just the size of the mmap reservation, not disk usage. LMDB allocates lazily. Leave it alone unless you’re on a 32-bit host.

Step 3: Docker Compose

nano /opt/relay/docker-compose.yml
services:
  strfry:
    image: dockurr/strfry:latest
    container_name: strfry
    restart: always
    stop_grace_period: 2m
    volumes:
      - ./strfry-db:/app/strfry-db
      - ./strfry.conf:/etc/strfry.conf:ro
      - ./plugins:/app/plugins:ro
    networks:
      - web

  caddy:
    image: caddy:2-alpine
    container_name: caddy
    restart: always
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    networks:
      - web

volumes:
  caddy_data:
  caddy_config:

networks:
  web:

Note stop_grace_period: 2m. LMDB wants a clean shutdown; killing strfry mid-write after the default 10 seconds is how you end up running strfry compact on a corrupted-feeling database. Give it time.

If you already run a reverse proxy, drop the caddy service and point your existing one at strfry:7777 on a shared network — see the Nginx or Traefik guides.

Step 4: HTTPS with Caddy

nano /opt/relay/Caddyfile
relay.yourdomain.com {
    reverse_proxy strfry:7777

    encode zstd gzip

    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains"
        X-Content-Type-Options "nosniff"
    }
}

That’s the whole config. Caddy detects the WebSocket upgrade automatically, requests a certificate from Let’s Encrypt on first boot, and renews it forever. The Caddy reverse proxy guide goes deeper if you want to host other services on the same box.

Using Nginx instead? WebSockets need explicit upgrade headers and a long timeout, or connections die every 60 seconds:

server {
    listen 443 ssl http2;
    server_name relay.yourdomain.com;

    ssl_certificate     /etc/letsencrypt/live/relay.yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/relay.yourdomain.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:7777;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
    }
}

proxy_read_timeout 3600s must exceed strfry’s autoPingSeconds. If it doesn’t, clients silently drop every minute and you’ll blame the relay.

Step 5: Launch and Verify

cd /opt/relay
mkdir -p plugins
docker compose up -d
docker compose logs -f strfry

Three checks, in order.

NIP-11 document — a JSON response proves the relay and proxy are wired correctly:

curl -H "Accept: application/nostr+json" https://relay.yourdomain.com

You should get your name, description, pubkey, and supported NIPs back.

WebSocket handshake — install websocat locally and ask for one note:

websocat wss://relay.yourdomain.com
["REQ","test",{"kinds":[1],"limit":1}]

An empty relay replies ["EOSE","test"] immediately. That’s success — there’s just nothing stored yet.

Database stats:

docker exec strfry strfry info

Now add wss://relay.yourdomain.com to your client (Damus, Amethyst, Primal, nostrudel — all of them have a relay list in settings) and post a note. Run strfry info again; the event count should tick up.

Step 6: Stop It Filling With Spam

This is the step people skip, and it’s why their 20 GB disk is full three weeks later. An open relay on the public internet will be found by blast bots within hours.

strfry solves this with write policy plugins: an executable that reads one JSON object per line on stdin and writes one verdict per line to stdout. Any language works.

nano /opt/relay/plugins/whitelist.js
#!/usr/bin/env node

// 32-byte hex pubkeys (not npub). Convert at nostrcheck.me or with nak.
const whiteList = {
  '003ba9b2c5bd8afeed41a4ce362a8b7fc3ab59c25b6a1359cae9093f296dac01': true,
};

const rl = require('readline').createInterface({
  input: process.stdin,
  output: process.stdout,
  terminal: false,
});

rl.on('line', (line) => {
  const req = JSON.parse(line);

  if (req.type !== 'new') {
    console.error('unexpected request type');
    return;
  }

  const res = { id: req.event.id };

  // Always accept events we pulled in ourselves via sync/import
  if (req.sourceType === 'Sync' || req.sourceType === 'Import') {
    res.action = 'accept';
  } else if (whiteList[req.event.pubkey]) {
    res.action = 'accept';
  } else {
    res.action = 'reject';
    res.msg = 'blocked: not on white-list';
  }

  console.log(JSON.stringify(res));
});

Make it executable and wire it up:

chmod +x /opt/relay/plugins/whitelist.js

In strfry.conf:

writePolicy {
    plugin = "/app/plugins/whitelist.js"
    timeoutSeconds = 10
}

Restart: docker compose restart strfry.

Each input message gives you event, receivedAt, sourceType (IP4, IP6, Import, Stream, Sync, or Stored), sourceInfo (usually the IP), and authed (the NIP-42 authenticated pubkey, when present). Your verdict is accept, reject, or shadowReject — the last one accepts silently and throws the event away, which frustrates bots that retry on rejection.

Warning

The plugin runs per event, synchronously, inside the ingest path. Keep it fast and keep it stateless. A plugin that shells out to an API on every event will bottleneck your relay and time out under load.

Policies worth building on top of the skeleton:

For read-side privacy, relay.auth.enabled = true plus restrictedReadKinds = "4, 1059" (already in the config above) forces NIP-42 authentication before anyone can query DMs and gift-wrapped events — and restrictReadToInvolvedPubkey ensures the authenticated user is actually a party to them.

Step 7: Import Your Existing Notes

A fresh relay is empty. Pull your history off public relays with negentropy sync — it reconciles the difference between two event sets instead of downloading everything:

# Pull all your notes down from a public relay
docker exec strfry strfry sync wss://relay.damus.io \
  --filter '{"authors":["your-hex-pubkey"]}' --dir down

# And from a few more, for coverage
docker exec strfry strfry sync wss://nos.lol \
  --filter '{"authors":["your-hex-pubkey"]}' --dir down

docker exec strfry strfry sync wss://relay.primal.net \
  --filter '{"authors":["your-hex-pubkey"]}' --dir down

Want the replies, zaps, and reactions aimed at you too?

docker exec strfry strfry sync wss://relay.damus.io \
  --filter '{"#p":["your-hex-pubkey"]}' --dir down

--dir both also pushes your local events up to the remote relay, which is a neat way to re-seed public relays with an archive only you still have.

Tip

Run the sync commands on a weekly cron. Public relays prune; yours doesn’t. Over a few years the difference between “my notes exist” and “my notes existed” is one cron entry.

For continuous mirroring rather than periodic sync, strfry router maintains persistent streaming connections to a set of relays defined in a config file — up-, down-, or bidirectional, with per-relay filters and write plugins.

Step 8: Backups and Maintenance

The entire relay is one directory. Back up strfry-db/, and you’ve backed up everything.

The safe way to snapshot a live LMDB database is to export events rather than copy files mid-write:

nano /opt/relay/backup.sh
#!/bin/bash
BACKUP_DIR="/opt/relay/backups"
DATE=$(date +%Y%m%d)
mkdir -p $BACKUP_DIR

# JSONL export — portable to any relay implementation
docker exec strfry strfry export | gzip > $BACKUP_DIR/events_$DATE.jsonl.gz

# Keep 14 days
find $BACKUP_DIR -name "events_*.jsonl.gz" -mtime +14 -delete

echo "Backup complete: $DATE"
chmod +x /opt/relay/backup.sh
crontab -e
0 4 * * * /opt/relay/backup.sh >> /var/log/strfry-backup.log 2>&1

Restoring is symmetrical — zcat events_20260820.jsonl.gz | docker exec -i strfry strfry import. Push the gzipped exports off-server with restic or rclone; the VPS backup guide covers offsite rotation.

Reclaiming disk space. LMDB fragments as events are replaced and ephemeral events expire:

docker exec strfry strfry compact /app/strfry-db/compacted.mdb

Compressing stored events. Events are stored uncompressed by default. A zstd dictionary typically halves the database with no query penalty:

docker exec strfry strfry dict --decay 0.9 stats
docker exec strfry strfry dict compress

Monitoring. strfry exposes Prometheus metrics at /metrics. Scrape it for connection counts, event rates, and rejection ratios — a rejection ratio that suddenly spikes means a bot found you. Wire it into the stack from the VPS monitoring guide.

Block /metrics at the proxy so it isn’t public:

relay.yourdomain.com {
    @metrics path /metrics
    respond @metrics 403

    reverse_proxy strfry:7777
}

Step 9: Tell Clients About Your Relay (NIP-65)

Running a relay nobody knows about defeats the outbox model. Publish a kind-10002 relay list from your client — most have a “relay list” or “my relays” setting that writes it for you:

{
  "kind": 10002,
  "tags": [
    ["r", "wss://relay.yourdomain.com"],
    ["r", "wss://relay.damus.io", "read"],
    ["r", "wss://nos.lol", "read"]
  ]
}

Marking your own relay as read+write (no marker) tells other clients: to find this person’s notes, come here. That’s the outbox model working as designed, and it’s what makes a personal relay useful to other people rather than just an archive for you.

Optionally serve NIP-05 identifiers from the same domain so you become [email protected] instead of an npub. Add to the Caddyfile:

yourdomain.com {
    handle /.well-known/nostr.json {
        header Content-Type application/json
        header Access-Control-Allow-Origin *
        respond `{"names":{"you":"your-hex-pubkey"},"relays":{"your-hex-pubkey":["wss://relay.yourdomain.com"]}}`
    }
}

Costs

ItemMonthly
VPS (1–2 GB)$4–6
Domain (amortised)~$1
TLS certificate$0 (Let’s Encrypt)
BandwidthUsually included
Total~$5–7

A public open-write relay with real traffic is a different conversation — plan for 4 GB+ RAM, several hundred GB of disk, and enough CPU to verify signatures on spam you’ll reject. That’s the $15–30/mo tier, and it’s why paid relays exist.

Troubleshooting

Client connects, then disconnects after ~60 seconds

Proxy idle timeout is shorter than autoPingSeconds. Raise proxy_read_timeout in Nginx to 3600s, or switch to Caddy which handles it by default.

curl returns HTML instead of the NIP-11 JSON

You’re missing the header. NIP-11 only responds to Accept: application/nostr+json — without it strfry serves its landing page.

Every client shows the same IP in logs

realIpHeader = "x-forwarded-for" isn’t set, or your proxy isn’t sending the header. Rate limiting and per-IP policy are useless until this is right.

Events rejected with “bad signature”

Not your relay’s fault — the client signed against different content than it sent. Test with a second client before debugging your setup.

Database won’t open after a reboot

Almost always an unclean shutdown. Check stop_grace_period is at least 2 minutes, then:

docker compose down
docker exec strfry strfry compact /app/strfry-db/repaired.mdb

Disk filling faster than expected

Something is writing that shouldn’t be. Check the ratio:

docker exec strfry strfry scan --count '{"kinds":[1]}'
docker exec strfry strfry info

If kind-1 notes are a tiny fraction of total events, you’re storing someone else’s spam. Tighten the write policy, then bulk-delete:

docker exec strfry strfry delete --filter '{"authors":["spammer-hex-pubkey"]}'

Certificate won’t issue

DNS hasn’t propagated, or port 80 is closed. Let’s Encrypt needs an inbound HTTP challenge — ufw allow 80/tcp is not optional even though the relay itself is HTTPS-only.

FAQ

Do I need a domain? Yes, in practice. Clients require wss://, wss:// requires a valid certificate, and certificates require a hostname. A $10/year domain is the cost of entry.

Can I run it behind Cloudflare? You can, with WebSocket support enabled on the proxy — but you’re inserting a company that can see and terminate your traffic into a censorship-resistant protocol. If you want the DDoS protection anyway, a Cloudflare Tunnel works and keeps your origin IP hidden.

How much history will a 20 GB disk hold? Millions of events. Text is small. You’ll outgrow patience before disk on a personal relay.

Can one VPS run other services too? Easily. A relay idles near zero. Add it to an existing Docker Compose stack behind the same proxy.

Is a relay legally risky? An open-write public relay stores whatever strangers send it, which is a moderation problem you now own. A whitelisted personal relay only stores your own events and things you asked for. Start whitelisted.

Should I run more than one? Two cheap relays in different countries, syncing to each other with strfry router, is a genuinely resilient setup for about $10/mo. That’s the actual answer to the “what if my relays go offline” problem nostr.how raises.

Conclusion

A personal Nostr relay is the rare self-hosting project where the software is easier than the domain registration. Twenty minutes of work gets you:

✅ Your own wss:// relay with automatic HTTPS ✅ A permanent archive of every note you’ve written ✅ Whitelisted writes, so spam never touches your disk ✅ Negentropy sync pulling your history off public relays ✅ Nightly JSONL backups you can restore anywhere ✅ NIP-65 outbox advertising, so clients fetch from the source

The protocol is designed so nobody can take your identity away from you. Running your own relay is how you make sure nobody can take your history away either.

~/nostr-relay-vps-setup/get-started

Ready to get started?

Get the best VPS hosting deal today. Hostinger offers 4GB RAM VPS starting at just $5.99/mo.

Get Hostinger VPS — $5.99/mo

// up to 70% off + free domain

// related topics

nostr relay vpshow to run a nostr relaystrfry setupself-hosted nostr relaypersonal nostr relay

// related guides

Andrius Putna

Andrius Putna

I am Andrius Putna. Geek. Since early 2000 in love tinkering with web technologies. Now AI. Bridging business and technology to drive meaningful impact. Combining expertise in customer experience, technology, and business strategy to deliver valuable insights. Father, open-source contributor, investor, 2xIronman, MBA graduate.

// last updated: August 20, 2026. Disclosure: This article may contain affiliate links.