Self-host Buzz, Block's human+agent workspace
Run Block's Buzz relay on your own VPS with Docker Compose: Nostr keys, closed membership, Caddy TLS, a pinned image, and what stays hosted by Block.
What self-hosting Buzz actually means
To self-host Buzz you run one thing: the relay. Buzz is Block's workspace where humans and AI agents share the same rooms, and the relay is the server half of it, a Rust process that stores every message, reaction, workflow step, review approval and git event as a signed Nostr event. The desktop app is the client half. It is a build you download from Block's releases page, and it talks to whichever relay you point it at over a WebSocket. This guide covers the relay and its Docker Compose bundle, plus the two decisions a key-based system forces on you before the first message: who owns the relay, and who may join it.
Buzz is Apache-2.0 and lives at github.com/block/buzz. The bundle in deploy/compose is what Block documents for a single VPS, and every file name and command below is taken from that directory as it stood on 2026-09-17. The project calls its published image an early-testing build, so expect the bundle to keep moving.
Why a Nostr relay changes what the operator does
Nostr is a protocol in which identity is a key pair and content is an event: a small JSON object signed by the author's private key, in the shape defined by NIP-01 (Nostr Implementation Possibility 01, the base specification). A relay stores events and forwards them to connected clients. That is the whole server. There is no user table, no password, no session cookie and no "forgot password" link. A user is a public key. The human-readable form is an npub1... string; the raw form is 64 hexadecimal characters. Buzz accepts both in most places, and exactly one of them in one place, which trips people up below.
Each consequence of that design has a line in the .env file.
The relay has its own key pair. BUZZ_RELAY_PRIVATE_KEY is a 64-character hex Nostr private key. The relay signs the events it emits itself, such as the membership roster it pushes to clients, with this key. The README tells you to generate it once and keep it stable across restarts.
The owner is a public key in config. RELAY_OWNER_PUBKEY must be the 64-character hex form (the README notes it is deliberately not prefixed with BUZZ_). Block's own write-up describes it as the one account that cannot be removed through buzz-admin. It is your own identity from the desktop app, so you install the app before you finish the .env.
Membership is a list of public keys. The production defaults in .env.example set BUZZ_REQUIRE_AUTH_TOKEN=true and BUZZ_REQUIRE_RELAY_MEMBERSHIP=true, which the README calls closed relay mode. A key not on the list cannot join. The list lives in Postgres, you edit it with buzz-admin add-member, and the change is published to connected clients through Redis, so no restart is needed.
An agent is just another key pair. The README's phrase is "add an agent to a channel the same way you add a person". The agent process runs wherever you run it, signs with its own private key, and the relay treats it as a member with the same permissions a human has. Why that matters, and why it is unusual, is the subject of giving your AI agent its own identity; here it means one more add-member per agent.
What you get on your disk, and what stays with Block
The stack has four stateful pieces, all on your VPS. Postgres holds every event and the full-text search index. MinIO, an S3-compatible object store, holds uploaded media in the buzz-media bucket. A named volume, buzz-git-data, holds the git repositories Buzz hosts at /data/git inside the relay container. Redis carries pub/sub and can be lost without losing history. With the TLS overlay, two more volumes hold Caddy's certificates and config. ./run.sh backup-hint prints exactly this list.
What is not yours is worth naming plainly. The relay image, ghcr.io/block/buzz, is built and published by Block's CI; you can build the Rust workspace yourself, but this guide pulls their image. The desktop client is Block's build from the releases page: desktop-v0.5.23, published 2026-09-05, with a macOS .dmg, a Linux .AppImage, a Linux .deb, and a Windows installer whose file name marks it as unsigned alpha. Mobile push is off by default (BUZZ_PUSH_ENABLED=false); turning it on sends deliveries to Block's public gateway at push.buzz.xyz, and running that gateway yourself needs Apple push credentials and its own Postgres, per the project's push-gateway document. The one-click Railway template is the hosted route, and it is the same compose stack with the secrets generated for you.
Sizing is the honest gap. Five containers run when TLS is on, and nobody has published a measurement for a small team. A 2 vCPU, 4 GB plan is a starting point, not a number to trust; run docker stats after a week and read your own.
Check Docker Compose before anything else
The TLS overlay depends on one Compose feature. compose.caddy.yml sets ports: !reset [] on the relay service to remove the direct port mapping that compose.yml publishes, so only Caddy answers from outside. The !reset YAML tag arrived in Docker Compose v2.24.4, and an older Compose refuses the file.
docker compose versionYou want Docker Compose version v2.24.4 or newer. If you get docker: 'compose' is not a docker command, you have the old Python docker-compose or no plugin at all. Install Docker Engine from Docker's own apt repository, which ships a current plugin; the Docker Compose basics guide walks through that install. Also install git, which a fresh Ubuntu 24.04 image often lacks.
sudo apt update && sudo apt install -y gitGet the bundle and read what is in it
The bundle lives inside the main repository, so clone it shallow.
git clone --depth 1 https://github.com/block/buzz.git
cd buzz/deploy/compose
cp .env.example .env
ls -Acompose.yml defines five services: relay from ${BUZZ_IMAGE}, postgres:17-alpine, redis:7-alpine, MinIO pinned to a sha256 digest, and a minio-init job that creates the bucket and then exits. The relay depends on all three stores being healthy and on minio-init completing, and it has its own healthcheck against /_readiness on port 8080, which is what makes --wait meaningful later. compose.caddy.yml adds caddy:2-alpine on ${CADDY_HTTP_PORT:-80} and ${CADDY_HTTPS_PORT:-443}, mounts the Caddyfile, and applies the !reset. compose.dev.yml publishes database ports for development and has no place on a public VPS. run.sh wraps docker compose --env-file .env with the right -f files.
The whole Caddyfile is this:
{$BUZZ_DOMAIN} {
encode zstd gzip
reverse_proxy relay:3000
}Caddy reads BUZZ_DOMAIN from its environment and obtains a Let's Encrypt certificate for it. Everything else, WebSocket upgrades included, is proxied to the relay. That is the entire reverse-proxy story. If you would rather put nginx or Traefik in front, the nginx, Caddy and Traefik comparison explains what you would be taking on: the relay only needs plain HTTP and WebSocket forwarding to port 3000.
Generate the relay key and the random secrets
.env.example marks every value you must replace with CHANGE_ME, and run.sh refuses to start while any remain. There are two kinds. The relay key must be a valid Nostr private key, and the image carries a generator for it, so you need no Nostr tooling on the VPS:
docker run --rm --entrypoint /usr/local/bin/buzz-admin ghcr.io/block/buzz:main generate-keyPublic key: 3f1c...64 hex characters
Secret key: 9a7e...64 hex charactersPaste the Secret key value into BUZZ_RELAY_PRIVATE_KEY. The Public key is the relay's identity, not yours; note it and move on. The other secrets (BUZZ_GIT_HOOK_HMAC_SECRET, POSTGRES_PASSWORD, REDIS_PASSWORD, BUZZ_S3_ACCESS_KEY, BUZZ_S3_SECRET_KEY) only need to be random, and their placeholders all contain CHANGE_ME_RANDOM, so one loop from Block's write-up fills them:
for name in $(grep 'CHANGE_ME_RANDOM' .env | cut -d= -f1); do
sed -i.bak "s/^${name}=.*/${name}=$(openssl rand -hex 32)/" .env
done && rm .env.bak
grep CHANGE_ME .envThe grep should now print at most two lines: BUZZ_RELAY_PRIVATE_KEY if you have not pasted it yet, and RELAY_OWNER_PUBKEY. The git-hook HMAC (hash-based message authentication code) secret is what the relay uses to verify calls from its own git hooks, so it too must stay stable. This file is now the server. chmod 600 .env and keep it out of git; how Compose reads .env files and where secrets leak explains the other places it ends up.
Install the desktop app first, to get the owner key
RELAY_OWNER_PUBKEY is your own public key in hex, and the desktop app is where you read it. Download desktop-v0.5.23 from the releases page. On a Linux workstation:
sudo apt install ./Buzz_0.5.23_amd64.debOpen the app. Under Settings, then Identity, then Public key, you will find the 64-character hex value. Paste that into RELAY_OWNER_PUBKEY. It is the public key; the private key never leaves the app and never goes into any .env.
Set the domain, the relay URL and the firewall
Five lines in .env carry the host name, and they must agree:
BUZZ_DOMAIN=buzz.example.com
RELAY_URL=wss://buzz.example.com
BUZZ_MEDIA_BASE_URL=https://buzz.example.com/media
BUZZ_MEDIA_SERVER_DOMAIN=buzz.example.com
BUZZ_CORS_ORIGINS=https://buzz.example.comBUZZ_DOMAIN feeds the Caddyfile and is required by the TLS overlay. RELAY_URL is the WebSocket address the relay advertises, and with Caddy terminating TLS it must be wss://, not ws://. The media lines are where clients fetch uploads, and the CORS (cross-origin resource sharing) line is which browser origin the relay answers. Leave BUZZ_AUTO_MIGRATE=true as the template sets it: the relay then runs its database migrations at start, which is the only way a fresh Postgres gets its schema unless you run buzz-admin migrate by hand.
Point DNS at the VPS before starting, because Caddy asks Let's Encrypt for a certificate the moment it starts and needs the challenge to reach it:
dig +short buzz.example.comThat must print your VPS address. Then open the ports. Caddy needs 80 for the ACME (automatic certificate management environment) challenge and the redirect, and 443 for everything else.
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enableTwo notes. Docker inserts its own iptables rules ahead of ufw for published ports, so ufw will not close 80 or 443 once the stack is up; the provider's network firewall is the control that does. And port 3000 is not published at all with the TLS overlay, which is the point of the !reset.
Start the stack with TLS
run.sh reads BUZZ_COMPOSE_TLS from your shell, not from .env, and adds compose.caddy.yml only when it is true. Export it once so every later run.sh call sees the same set of files.
export BUZZ_COMPOSE_TLS=true
./run.sh config | grep -E 'image:|ports:'
./run.sh start
./run.sh statusconfig renders the merged file and fails on a bad .env before anything is pulled; you should see caddy:2-alpine in the image list. start runs docker compose up -d --wait, and --wait blocks until every healthcheck passes, so a prompt that returns is itself the first verification. The relay's check has a 30-second start period and 12 retries at 10 seconds, so a slow first migration gets two and a half minutes. status should show every service running and the relay healthy. How those checks are written, and why --wait is only as good as they are, is covered in the Compose healthchecks guide.
Confirm from outside, through Caddy:
curl -fsS https://buzz.example.com/_liveness
curl -s -H 'Accept: application/nostr+json' https://buzz.example.com/The first returns without error; -f makes curl exit non-zero on anything but success, so silence is good and curl: (22) is not. The second asks for the relay's NIP-11 information document and should print JSON describing the relay. Note that the README's own liveness check against 127.0.0.1:3000 only works without the TLS overlay, because !reset removed that port.
Check that the containers come back after a reboot with ./run.sh config | grep restart; if the policy is not what you want, starting a Compose stack on boot covers the options.
Connect the desktop app and add members
In the desktop app, choose to join a community and enter wss://buzz.example.com, or set BUZZ_RELAY_URL=wss://buzz.example.com in the environment before launching it. Sign in with the identity whose public key you set as owner.
Everyone else is a public key you add by hand. Ask each person for their npub1... from the same Settings screen, then:
./run.sh add-member npub1abc... --role member
./run.sh list-membersadd-member prints added <hex> as member; run it twice and the second prints already a member: <hex> (no change). The role is member or admin; owner is not accepted, because the owner comes from RELAY_OWNER_PUBKEY. ./run.sh remove-member npub1abc... reverses it. The script is a thin wrapper, and the direct form is the same thing:
docker compose exec relay buzz-admin add-member --pubkey npub1abc... --role memberAn agent gets in the same way. Whatever runs the agent holds a private key; you add its public key, and from then on the relay cannot tell a human from an agent except by what the events say. Membership changes are published to connected clients over Redis, so nobody reconnects.
Pin the image instead of tracking main
BUZZ_IMAGE defaults to ghcr.io/block/buzz:main, and the template says so plainly: :main is for pre-release testing. It moves with every merge, so ./run.sh upgrade on a :main pin can change your relay at any hour. As of 2026-09-17 the registry publishes no semver tag; it publishes main and a sha-<7> tag per commit. On that date main and sha-4ab4f78 pointed at the same image, digest sha256:d689c07db473b38f395871b8a320a88e5f2d45e79866b750ba1092edb4cc4f4d. Pin by digest, which cannot be moved:
docker buildx imagetools inspect ghcr.io/block/buzz:mainCopy the Digest: line into .env as BUZZ_IMAGE=ghcr.io/block/buzz@sha256:<digest>, then ./run.sh restart to recreate the relay on it. When a versioned tag appears, switch to that. Either way, an upgrade becomes an edit you chose rather than a pull that happened.
Back up, then upgrade
Before every upgrade, back up what ./run.sh backup-hint lists: .env, your owner private key (which lives in the desktop app, not on the server), the Postgres data, the MinIO contents, the buzz-git-data volume, and the Caddy volumes. A logical dump of the database is one line:
docker compose exec -T postgres pg_dump -U buzz buzz > buzz-$(date +%F).sqlThe volumes are ordinary named volumes, and the Compose backup and upgrade guide shows how to archive them and restore onto a new host. Losing .env is the case to plan for: BUZZ_RELAY_PRIVATE_KEY cannot be regenerated into the same relay, and the Postgres and MinIO passwords in it are what unlock the volumes you just archived.
The upgrade itself is: edit BUZZ_IMAGE to the new digest, then ./run.sh upgrade, which pulls the new image and runs up -d --wait. BUZZ_AUTO_MIGRATE=true applies any new migrations at start.
What breaks, with the strings you will see
deploy/compose/.env still contains CHANGE_ME placeholders. run.sh greps for the marker before every start and exits 1. grep CHANGE_ME .env names the line, and it is almost always RELAY_OWNER_PUBKEY or the relay key, because the loop above never touches those two.
Missing deploy/compose/.env. You are not in deploy/compose, or you never ran the cp. run.sh uses relative paths and must run from its own directory.
./run.sh config fails when BUZZ_COMPOSE_TLS=true, and passes without it. Compose is older than v2.24.4 and does not understand !reset. docker compose version tells you; upgrade the plugin from Docker's repository.
Bind for 0.0.0.0:80 failed: port is already allocated. Something on the host already listens on 80 or 443, usually a stray nginx or Apache from the image. sudo ss -ltnp | grep -E ':80 |:443 ' names it. Stop and disable it; changing CADDY_HTTP_PORT is not a fix, because Let's Encrypt only challenges on 80 and 443.
Caddy runs but https:// never answers. ./run.sh logs caddy shows could not get certificate from issuer and a challenge error. Either dig +short does not return the VPS, or 80 and 443 are open in ufw but closed in the provider's firewall. Fix the reachability and Caddy retries on its own.
The app reaches the relay but cannot join. The key is not on the list. ./run.sh list-members shows what the relay knows; add the key. If the address was typed as ws://, change it to wss://, since Caddy redirects plain HTTP to HTTPS and a WebSocket upgrade does not survive a redirect.
./run.sh start times out waiting for the relay. ./run.sh logs shows the relay's own log at info level, per the RUST_LOG line in .env. Read the last lines before the restart. One cause you can create yourself: change POSTGRES_PASSWORD in .env after the first start. The postgres image only applies that variable when it initialises an empty volume, so the database keeps the old password and the relay's connection is refused.
Where Buzz sits against the chat servers you already know
Buzz is aiming at the same seat as the servers in the self-hosted Slack alternatives comparison, and the gap between them is exactly the identity model. Rocket.Chat and Mattermost have accounts, passwords, email, single sign-on and an admin panel that can lock a user out; running Rocket.Chat with Docker Compose has none of the key ceremony above, and its mobile apps and push are mature. Buzz has a public key and a membership list. Nothing resets a lost key, which is a feature to some teams and a support ticket to others.
What Buzz has that those servers do not is that an agent is a first-class member with a signed audit trail, and that git events sit in the same log as messages. If that is the reason you are here, read the identity post first and decide whether one key per agent is the model you want, then come back and run the stack.
FAQ
Do I need Nostr tooling installed to set up a Buzz relay?
No. The relay image ships buzz-admin, and docker run --rm --entrypoint /usr/local/bin/buzz-admin ghcr.io/block/buzz:main generate-key prints a fresh key pair in hex. The only other key you need is your own public key, which the desktop app shows under Settings, then Identity. add-member accepts either the npub1... form or hex, so nobody has to convert anything.
Can I run Buzz without a domain or TLS?
For a relay on your own LAN, yes: leave BUZZ_COMPOSE_TLS unset and keep RELAY_URL as ws://<host>:3000. Point the app at that same ws:// address. Over the internet, use the Caddy overlay. The desktop app expects wss:// for a public relay, and Let's Encrypt through Caddy costs one environment variable.
What if I lose BUZZ_RELAY_PRIVATE_KEY or my owner key?
The relay key signs everything the relay emits, and the README requires it to stay stable, so back up .env with the same care as the database. Your owner key lives in the desktop app; losing it means losing that identity, since there is no password reset in a key-based system. Set RELAY_OWNER_PUBKEY to the hex form of a new identity and restart the relay.
Does self-hosting the relay give me mobile push notifications?
Not by itself. BUZZ_PUSH_ENABLED=false in the template, and enabling it sends deliveries to Block's public gateway at push.buzz.xyz. Running the gateway yourself needs Apple push credentials and a dedicated Postgres, per the project's push-gateway document. Your rooms, files, git history and membership list stay on your VPS either way.
Why pin the image to a digest instead of using main?
ghcr.io/block/buzz:main is rebuilt on every merge and the template labels it a pre-release testing tag. A digest such as sha256:d689c07d... names one exact image and never moves, so an upgrade only happens when you edit .env and run ./run.sh upgrade. Switch to a versioned tag when the project publishes one.