
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
A relay speaks one protocol over one WebSocket connection:
EVENT— a client publishes a signed event, the relay verifies the signature and stores itREQ— a client subscribes with a filter (authors, kinds, tags, time range), the relay replays matching stored events and then streams new onesCLOSE— the client ends the subscription
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
| Reason | What you get |
|---|---|
| Archive | A permanent copy of your notes, independent of any public relay’s uptime |
| Censorship resistance | No one can remove your events from a machine you control |
| Speed | Your own reads come from your own disk, not a relay serving 50,000 strangers |
| Private groups | A 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 limits | Public 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:
| Software | Language | Storage | Best for |
|---|---|---|---|
| strfry | C++ | LMDB | The default choice — fast, single binary, negentropy sync |
| nostr-rs-relay | Rust | SQLite | Small footprint, easy to reason about |
| khatru | Go | Pluggable | A framework — build a relay with custom rules in ~50 lines |
| HAVEN | Go (khatru) | BadgerDB | Opinionated personal relay: inbox/outbox/chat/private in one |
| rnostr | Rust | LMDB | High 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 type | RAM | vCPU | Disk | Notes |
|---|---|---|---|---|
| Personal (1 user, whitelisted) | 1 GB | 1 | 20 GB | Overkill already |
| Small community (10–100 users) | 2 GB | 1–2 | 40 GB | Comfortable |
| Public, open-write | 4 GB+ | 2–4 | 80 GB+ | Spam is the real cost |
| Public archive relay | 8 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:
- A static IPv4 address — clients connect to a hostname you control, and you need a stable A record.
- Unmetered or generous bandwidth — a relay that syncs and blasts to other relays moves more traffic than you’d guess.
Recommended VPS plans
| Provider | Plan | Price | Why |
|---|---|---|---|
| RackNerd | 1GB KVM | $4.98/mo | Cheapest workable personal relay |
| Hetzner | CX23 | €5.49/mo | Best value; EU jurisdiction |
| Vultr | Regular | $2.50/mo | 32 locations, pick your jurisdiction |
| Hostinger | KVM 1 | $5.99/mo | Easy panel, good for a first server |
| Contabo | VPS S SSD | $4.99/mo | 8 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:
- Web of trust — accept writes from anyone your pubkey follows, and anyone they follow. Refresh the set from a kind-3 contact list on a cron, and keep it in a file the plugin reads at startup.
- Kind filtering — a personal archive relay rarely needs kind 1984 (reports) or ephemeral kinds. Drop what you’ll never read.
- Paid relay — check the pubkey against a payments table; a Lightning invoice per signup is the cheapest anti-spam proof-of-work there is. This is where public relays are heading, as nostr.how notes.
- Rate limiting by
sourceInfo— cap events per IP per minute.
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
| Item | Monthly |
|---|---|
| VPS (1–2 GB) | $4–6 |
| Domain (amortised) | ~$1 |
| TLS certificate | $0 (Let’s Encrypt) |
| Bandwidth | Usually 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.
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
// related guides
$1 VPS Hosting 2026: Cheapest VPS Servers Starting at $1/Month
Looking for $1 VPS hosting? Compare the cheapest VPS providers starting from $1-3/month. Real specs, no hidden fees, honest reviews of budget VPS options.
tutorialCaddy Reverse Proxy Guide 2026: Automatic HTTPS Made Easy
Set up Caddy as a reverse proxy with automatic HTTPS, zero-config SSL, and simple Caddyfile syntax. Complete VPS deployment guide.
tutorialCloudflare Tunnel VPS Guide 2026: Expose Services Without Opening Ports
Set up Cloudflare Tunnel on your VPS to expose web apps securely without opening ports or revealing your server IP. Complete guide with Docker and DNS config.
tutorialCoolify VPS Setup Guide 2026: Self-Hosted Vercel Alternative
Deploy Coolify on your VPS for a self-hosted Vercel/Netlify experience. Complete setup guide with Docker, SSL, and app deployments.
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.