Self-host

Run your own relay

The relay is one executable, one SQLite file and a directory of blobs. This page is the whole of what running it involves — including the two settings that decide whether you have a sync backend or an anonymous file host.

Getting the binary

The relay is the opensync-relay crate in the OpenSync workspace. Building it is cargo build --release -p opensync-relay and produces a single static-enough executable with no runtime dependencies beyond libc.

Publication of the source repository is in progress. Until it lands, mail hello@opensync.network and you will be sent the source and a Linux x86_64 build.

The configuration

Every policy decision the relay makes is in this file, because they are the operator's decisions rather than ours. Anything you leave out keeps its default.

# /etc/opensync/relay.toml

bind = "127.0.0.1:4848"

# Set this when something terminates TLS in front of the relay. The startup
# audit reasons from the bind address, and behind a proxy that reasoning is
# backwards: the relay binds loopback *because* nginx is forwarding the
# internet to it, so without this line every check that treats loopback as a
# boundary quietly passes.
behind_a_reverse_proxy = true

data_dir = "/var/lib/opensync-relay"
database_url = "sqlite:///var/lib/opensync-relay/relay.db?mode=rwc"

name = "my relay"
description = "A sync backend, not a public Nostr relay."

# Who this relay stores data for.
#   "open"   — anybody may sync, at unregistered_quota_bytes. The right
#              setting for anything with a hostname.
#   "roster" — only accounts you admit. For a family, a team, a trial.
#   "anyone" — everybody, at the full quota_bytes. Right on a machine only
#              you can reach, refused on a public one unless you say so.
admission = "open"
unregistered_quota_bytes = 104857600   # 100 MB
require_auth = true

# Bytes per account, and the ceiling on a single blob.
quota_bytes = 5368709120
max_blob_bytes = 268435456

The one setting that matters most

require_auth = true reads like a gate and is not one. It proves the writer controls the keypair it claims — and a keypair costs nothing to generate, so on a public hostname it admits everybody.

That is fine, as long as what an unknown account can cost is bounded. admission = "open" does exactly that: anybody may sync the day they make an account, and an account nobody has granted anything to is capped at unregistered_quota_bytes. No guest list, nothing for anyone to ask you for. Left at admission = "anyone", though, a relay on the open internet hands its full allowance to every signature it has never seen, and refuses to start rather than letting you find out later:

refusing to serve 127.0.0.1:4848 behind a reverse proxy with
admission = "anyone": every unknown pubkey would be granted 5 GB, and a
pubkey is free to generate, so this is an anonymous file host rather than
a sync backend.

On a laptop, a home server or anything else reachable only from your own network, anyone is the correct setting and the audit will not object — reaching the relay is already the permission. The moment a hostname points at it, use open.

Running it

Its own user, its own state directory, and nothing else reachable. A defect in the blob path should not be able to touch the rest of the machine.

# /etc/systemd/system/opensync-relay.service
[Unit]
Description=OpenSync relay
After=network-online.target
Wants=network-online.target

[Service]
User=opensync
Group=opensync
ExecStart=/usr/local/bin/opensync-relay /etc/opensync/relay.toml
Restart=on-failure
RestartSec=3
Environment=RUST_LOG=opensync_relay=info

StateDirectory=opensync-relay
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
NoNewPrivileges=true
ReadWritePaths=/var/lib/opensync-relay
RestrictAddressFamilies=AF_INET AF_INET6

[Install]
WantedBy=multi-user.target

TLS and the WebSocket upgrade

Two things in this block are not optional. Without the Upgrade headers the socket never becomes a WebSocket and clients hang at connect with nothing in any log; without client_max_body_size nginx rejects large attachments with a 413 that never reaches the relay, so its own size limit never gets a say.

server {
    server_name relay.example.com;

    client_max_body_size 257m;   # a megabyte over max_blob_bytes

    location / {
        proxy_pass http://127.0.0.1:4848;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_read_timeout 1h;   # idle subscriptions are the normal case
    }
}

$connection_upgrade is not built in. Define it once, at http level:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

Then certbot --nginx -d relay.example.com. A long proxy_read_timeout matters because a client holding a subscription open sends nothing for minutes at a time, and the default cuts it at sixty seconds — which presents as sync that works and then silently stops.

Giving one account more room

Under open the roster is a list of grants rather than a guest list: everybody is already served, and a row says "this one gets more". Under roster the same list decides who is served at all. Either way it takes the npub1… or the 64-character hex from any app's settings screen.

opensync-relay /etc/opensync/relay.toml admit <npub or hex> "me" --quota 5368709120
opensync-relay /etc/opensync/relay.toml roster
opensync-relay /etc/opensync/relay.toml revoke <64-hex-pubkey>

No restart is needed between granting somebody room and their first write. Revoking drops them back to the unregistered allowance (or, on a roster relay, removes their permission to write) and deletes nothing — those are separate decisions, and coupling them would make every mis-click destructive.

If you would rather drive this from a signup page than from a shell, set operator_pubkey to a key you control and the same three operations are available over HTTP at /admin/roster, authorised by a signed request from that key. With no operator_pubkey configured those routes return 404 rather than opening to anyone.

Giving one account more room

Grants are added to the free allowance rather than replacing it, so a grant smaller than the free tier cannot accidentally shrink an account. Writing one down also admits the account, because an operator who has decided how much space somebody gets has already decided they get some.

[extra_bytes]
"<64-hex-pubkey>" = 21474836480   # 20 GB on top of the free allowance

Moving an account here

There is nothing to export. The relay never held a key or any plaintext, so an account is not stored anywhere in a format only its current relay can read.

  1. Admit the account's public key on the new relay.
  2. Change the relay URL in one app's settings and let it finish uploading.
  3. Change it on the remaining devices.

Blobs are content-addressed, so a device that already holds a chunk does not fetch it again — the second and third devices have almost nothing to do. When you are satisfied, delete the old account's data on the relay you left, remembering that deletion on any relay is a request rather than a guarantee. The removal that does not depend on anybody's goodwill is a key rotation, which re-seals the whole namespace and strands every byte the old relay is holding.

Checking it works

curl -s -H "Accept: application/nostr+json" https://relay.example.com/ | jq

A relay reports "auth_required": true either way. "restricted_writes" tells you which kind it is: true means a roster decides who is served, false means anybody may sync. What false must never mean is admission = "anyone" on a public address — that hands the full quota to every signature, and the relay refuses to start that way unless it was told to.