Stand up a private, invite-only community on your own domain, self-hosted on a $24/month server. Built on Buzz — open source group communication over Nostr.
This was written by actually doing it. Every trap listed at the bottom cost real time on the first pass — the prompt exists so it doesn't cost you any.
What you end up with: wss://relay.yourdomain.com —
your own relay, real TLS, invite-only, nobody else's servers. About 30 minutes.
ls ~/.ssh/*.pub. If there's
nothing, run ssh-keygen -t ed25519 and press Enter through the prompts.DigitalOcean → Create → Droplets
| Setting | Value |
|---|---|
| Image | Ubuntu 24.04 LTS — plain, not a Marketplace image |
| Type | Basic → Regular SSD |
| Size | $24/mo — 4 GB RAM / 2 vCPU / 80 GB |
| Region | Closest to you |
| Authentication | SSH key — paste your ~/.ssh/id_ed25519.pub |
| Hostname | community-relay |
Three of these matter more than they look:
Copy the droplet's IP when it's ready.
Fill in the three bracketed values and paste the whole block into Claude Code.
Set up a private, invite-only Buzz community on my own domain, self-hosted on my
DigitalOcean droplet. Build it and verify it actually works — don't just tell me
it should.
## My details
- Domain: [YOURDOMAIN.com]
- Droplet IP: [IP] — Ubuntu 24.04, root SSH with my key
- DNS is managed at: [Squarespace / Cloudflare / Namecheap / ...]
## What I'm building
A self-hosted Buzz relay (https://github.com/block/buzz) using its
`deploy/compose` bundle, reachable at `relay.[YOURDOMAIN.com]`, with TLS
terminated by Caddy and Let's Encrypt.
It is five long-lived containers: a Rust WebSocket relay, Postgres, Redis,
MinIO, and Caddy. This cannot run on serverless hosting. If I suggest putting it
somewhere like that, correct me instead of agreeing.
## Order of work — verify each step before starting the next
### 1. Provision the droplet
SSH in and do these in order:
1. **Firewall first.** `ufw allow 22`, then 80 and 443, THEN `ufw enable`.
Enabling before allowing 22 locks you out of my server.
2. **Wait for apt.** A fresh droplet runs cloud-init and unattended-upgrades on
first boot and holds the dpkg lock. Poll until it's free — check
`fuser /var/lib/dpkg/lock-frontend` and run `cloud-init status --wait` —
rather than failing on a confusing dpkg error.
3. Install Docker from **Docker's official apt repo**, not Ubuntu's, so the
Compose plugin is current. Confirm `docker compose version` is >= 2.24.4.
4. Shallow-clone https://github.com/block/buzz to `/root/buzz`.
### 2. Get my identity
I need a Nostr keypair to be the relay owner.
Tell me to install the Buzz desktop app from
https://github.com/block/buzz/releases/latest, create an identity, and send you
the `npub`. I need the app anyway.
Then decode it yourself: it's bech32 — verify the checksum and confirm it's 32
bytes. `RELAY_OWNER_PUBKEY` needs **64-character hex**, and an `npub` pasted
straight in will not work.
**Never ask me for an `nsec` or a private key.** `npub` is public and safe to
share; `nsec` is secret and must never leave my machine. If I paste an `nsec` by
accident, tell me to abandon that identity and generate a fresh one.
### 3. Configure
Write `deploy/compose/.env` from `.env.example`:
- Generate every secret with `openssl rand -hex 32` — Postgres password, Redis
password, relay private key, git hook HMAC, S3 access and secret keys.
**Generate them on the droplet or my machine. Never invent them in your own
output** — a private key in a chat transcript is compromised.
- Set the domain to `relay.[YOURDOMAIN.com]`.
- Set `RELAY_OWNER_PUBKEY` to my decoded hex.
- Set `BUZZ_REQUIRE_RELAY_MEMBERSHIP=true` — this is what makes it invite-only.
- `chmod 600` the file.
`run.sh` refuses to start while any `CHANGE_ME` remains. Confirm zero are left.
### 4. DNS
Have me add one record:
| Type | Name | Data |
|------|---------|-----------------|
| A | `relay` | my droplet IP |
Two warnings to give me:
- It must be a **plain DNS record, not proxied through Cloudflare.** Caddy needs
port 80 reachable for the Let's Encrypt HTTP-01 challenge, and a proxy also
complicates the WebSocket upgrade.
- Some DNS panels reject pasted values carrying a trailing period or whitespace,
with a misleading "invalid" error that blames the value. Tell me to type it by
hand.
Confirm it resolves before continuing.
### 5. Start it
`BUZZ_COMPOSE_TLS=true ./run.sh start`
Then verify from the public internet and show me the actual output:
- all containers healthy
- Let's Encrypt cert issued — print the issuer and expiry
- `https://relay.[DOMAIN]/_liveness` returns 200
- the NIP-11 document is served
- **a WebSocket connection returns HTTP 101 and the relay replies with a NIP-42
AUTH challenge** — this is the real test, not the liveness endpoint
- `./run.sh list-members` shows me as `owner`
- **port-scan from outside**: 5432, 6379, 9000 and 3000 must all be closed
One expected result that looks like a failure: in TLS mode `compose.caddy.yml`
uses `!reset` to unpublish port 3000, so `curl 127.0.0.1:3000` **on the droplet**
fails by design. Only Caddy is exposed. Don't chase it.
## How you should work
- **Verify, don't assert.** Every claim about DNS, TLS, or ports gets a command
and real output. "Should work" is not acceptable.
- Read `deploy/compose/README.md` and `NOSTR.md` before acting — they contain
constraints that aren't obvious from the code.
- Tell me when something I asked for is wrong, and why, before building it.
- Never print a secret back to me in full.
One command per member, from your machine:
ssh root@YOUR_DROPLET_IP 'cd /root/buzz/deploy/compose && ./run.sh add-member <npub>'
Takes an npub or raw hex. Add them before they try to
connect — a non-member hitting the relay gets an auth failure with no
useful explanation.
Then they install Buzz, choose Join a community, and enter
wss://relay.yourdomain.com.
Everything else, from /root/buzz/deploy/compose:
./run.sh list-members # who's in
./run.sh remove-member <k> # revoke
./run.sh logs # follow relay logs
./run.sh status # container health
./run.sh backup-hint # what to back up
Back up deploy/compose/.env. Losing
BUZZ_RELAY_PRIVATE_KEY changes your relay's identity and every member
has to re-trust it. It's the one irreplaceable file.
Pin BUZZ_IMAGE. It defaults to
ghcr.io/block/buzz:main — a moving tag, so an upgrade could pull a
breaking change. Pin a digest or release tag once you're happy.
| Trap | What happens without the guardrail |
|---|---|
| 2 GB droplet | Swaps, OOM-kills the relay |
| Docker Marketplace image | Compose older than 2.24.4; the TLS override's !reset tag fails |
ufw enable before allowing 22 | Locked out of your own server |
| apt lock on first boot | Install fails with a confusing dpkg error |
| Relay subdomain proxied by Cloudflare | Let's Encrypt challenge fails, cert never issues |
| Pasted DNS values | Trailing period rejected with an error that blames the value |
npub used as RELAY_OWNER_PUBKEY | Needs 64-char hex; the owner never bootstraps |
curl 127.0.0.1:3000 on the droplet | Fails by design in TLS mode — looks like a broken deploy |
| Adding a member after they connect | Auth failure with no useful explanation |
| Approving members with SQL | Unnecessary — ./run.sh add-member exists |