Run the Buzz relay on an always-on Mac and the desktop app on the workstation
you sit at, with Ansible driving both halves. The relay hosts a community —
Buzz’s name for a shared workspace — and is the shared log every person and
every agent in it writes to, so it wants a machine that does not close.
Install and Use Buzz on macOS puts both
programs on one laptop and binds that community to localhost:3000, which is a
good way to see what Buzz does and a poor way to run it for real.
One Ansible project, safe to re-run, stands up a Buzz relay on an always-on Mac and a verified desktop install on the workstation you sit at — two invocations the first time, because the relay needs the public key the app generates. The relay lands on a headless Mac mini over SSH; the desktop app lands on the workstation over a local connection. One role each, one inventory, and a single definition of the relay’s URL that both halves read.
The automation is the straightforward part. The community authority is not — the host and port the relay takes from its own URL, and matches every later connection against. Once the relay and the app live on different machines it stops being a formality: bake the wrong spelling of your own hostname into it and you get a relay that is plainly running and refuses every client. Settle that hostname before the first boot, because changing it afterwards does not move the community: the relay provisions a second, empty one under the new authority and leaves the first one’s history in the database, unreachable.
Ansible does not own the whole install. Creating a Nostr identity is a GUI flow that writes a secret key to the login keychain, and that part stays yours.
What you end up with
- A Buzz relay on an always-on Mac, published on port 3000, reachable by name from every machine on your LAN, and back up on its own after a reboot — with no login and nothing started by hand. That last part depends on the Docker daemon starting at boot as well, which is a prerequisite below rather than something this playbook sets up; the last section proves the whole thing.
- Six generated secrets in an
ansible-vaultfile, never in the shell history and never in the repository in plaintext. - A verified Buzz desktop install on the workstation, refused outright if the bundle is not signed by Block and notarized.
- Both halves idempotent: a second run reports zero changes.
Two hostnames appear throughout: minime for the headless mini and devbot5
for the workstation. They are examples. Substitute your own, and if you only
have one Mac, the last section before the troubleshooting notes shows the
one-host inventory that runs both halves against it.
Prerequisites
| Requirement | Version used here | Notes |
|---|---|---|
| macOS on the controller | 26.5.2, Apple Silicon | The Mac you run ansible-playbook from |
| macOS on the relay host | 26.6.1, Apple Silicon | Headless Mac mini, reachable over SSH |
| Command Line Tools on both Macs | xcode-select --install | /usr/bin/python3 is a shim that opens an install dialog without them, which a headless host cannot answer |
| Homebrew on the relay host | /opt/homebrew (Apple Silicon) | The role installs socat with it and reads the Compose plugin from /opt/homebrew/lib/docker/cli-plugins. On Intel, set buzz_relay_homebrew_prefix: /usr/local |
| Colima started at boot on the relay host | A LaunchDaemon, not brew services | brew services installs a LaunchAgent, which never loads without a GUI session — so the relay would not come back after a reboot. This article does not set it up |
| Ansible | ansible-core 2.21.1 | brew install ansible |
| ansible-lint | 26.6.0 | Optional, but make lint uses it |
| A Docker daemon on the relay host | Docker 28.4.0 via Colima 0.10.1 | Docker Desktop needs a GUI session, so a headless Mac wants Colima |
| Docker Compose | 5.1.1 on the relay host | Must resolve as docker compose, not only docker-compose; Step 6 is about why |
| Docker Engine on the workstation | 29.7.2 (Docker Desktop 4.87.0) | Only if you also run a relay there |
| Buzz desktop | 0.5.18 | Installed by the client role |
| Buzz relay image | ghcr.io/block/buzz:main, reporting v0.2.1 | Pulled by the relay role |
| SSH to the relay host | Key-based, with passwordless sudo | The port-forward tasks write to /Library/LaunchDaemons |
| A fixed LAN address for the relay host | Static, or a DHCP reservation | The forward binds that address by name; if DHCP moves it, socat cannot bind and launchd restarts it in a loop |
Read Install and Use Buzz on macOS first if
you have not. This article assumes you know what a relay is, what an npub is,
and why RELAY_OWNER_PUBKEY wants hex. NIP-nn below refers to a numbered
Nostr Implementation Possibility, the
protocol’s specification series.
Step 1: Decide what Ansible owns
Buzz is two programs, and both are called a bundle here — the relay’s
docker compose deployment bundle and the app’s macOS .app bundle — so the
word is qualified wherever it could be either. The relay is a docker compose
bundle whose environment
file ships seven values you have to fill in. The desktop app is a signed macOS bundle that
generates a secp256k1 keypair on first launch and stores the secret half in the
login keychain. Those two halves automate very differently, so settle the split
first.
The relay automates completely. It is a checkout, a rendered environment file,
a port forward, and a docker compose up. Nothing in it needs a human.
The identity does not automate, and should not. On the headless mini there is no session to run a GUI app in at all, which is easy to confirm:
ssh minime 'who; stat -f "%Su" /dev/console; launchctl print gui/$(id -u)'
root
Could not print domain: 125: Domain does not support specified action
who prints nothing because nobody is logged in. The console is owned by
root rather than a user, and there is no GUI launchd domain to ask about.
Screen Sharing can give you a session on demand, so this is a policy choice
rather than a hard wall, but taking it would mean a Nostr identity sitting in
the login keychain of an account nobody signs into. The relay goes on the mini.
The app goes where you sit.
That leaves a third thing the role can own without touching the identity: the app bundle. Downloading a DMG, checking its Developer ID signature, and confirming the notarization ticket is stapled are exactly the steps people skip when doing it by hand. Step 10 automates those and stops there.
So the boundary is:
| Half | Owner | Why |
|---|---|---|
| Relay checkout, secrets, environment, port forward, startup | Ansible | Pure configuration, no session needed |
| Desktop app bundle: download, signature, notarization, install | Ansible | Mechanical, and the checks are the part people skip |
| Nostr identity: keypair, keychain, backup | You | GUI flow, and the secret key is the account |
The relay also gets something from the mini that it cannot get from a laptop.
Colima on that host is started at boot by a LaunchDaemon rather than by
brew services, whose LaunchAgent never loads without a GUI session, and the
Buzz bundle marks its four long-lived services restart: unless-stopped.
Together that means the Docker daemon comes back after a reboot and the relay
comes back with it, with no login, no make start, and no cron.
Step 2: Scaffold the project
Two files set the ground rules before any task exists: what git must never see, and how Ansible connects. Both are short, and both are awkward to change later.
The .gitignore comes first, because the vault password file is created in
Step 5. Add the ignore rule afterwards and the repository has already had one
chance to commit the passphrase.
Create the files
mkdir -p ~/projects/buzz-ansible
cd ~/projects/buzz-ansible
mkdir -p inventory/group_vars/all playbooks \
roles/buzz_relay/{defaults,tasks,templates,handlers} \
roles/buzz_client/{defaults,tasks}
touch .gitignore ansible.cfg
Add the code: .gitignore
# Ansible vault password — the one file that must never be committed.
# The encrypted vault itself belongs in git; this is what decrypts it.
.vault-pass
*.vault-pass
# In YOUR repository, commit inventory/group_vars/all/vault.yml. An encrypted vault in git
# is the point: it is what makes the repo a complete description of the relay,
# and these six values must never rotate, so losing them is worse than most
# outages. It is excluded here only because this is a published tutorial, and a
# vault whose passphrase appears in the article is decryptable by every reader.
# Run `make vault-init` to create your own.
inventory/group_vars/all/vault.yml
# Decrypted secrets, if you ever dump them to inspect
*.decrypted
*.plain.yml
# The relay environment file. The real one is written straight to the relay
# host with mode 0600 and never lands here, but it is easy to render one
# locally while working on the template — and it holds five secrets plus the
# relay signing key.
.env
*.env
# Ansible runtime noise
*.retry
.ansible/
fact_cache/
# Python virtualenv, if you install Ansible into one rather than via Homebrew
.venv/
__pycache__/
# macOS
.DS_Store
Detailed breakdown
.vault-passis the only file here that must never be committed. The encrypted vault itself belongs in git: an encrypted secret in version control is the whole idea, and it is what makes the repository a complete description of the relay. The passphrase is what decrypts that vault, so the passphrase stays out of git.- The vault is excluded in this tutorial and should not be in yours. A vault whose passphrase appears in a published article is decryptable by every reader. The comment in the file says so.
.envand*.envbecause the rendered relay environment holds five secrets plus the relay signing key. The real one is written straight to the relay host and never lands on the controller, but rendering one locally while working on the template is easy to do and easy to forget about..ansible/is the fact cache configured below. It is machine-local state, not configuration.
Add the code: ansible.cfg
[defaults]
inventory = inventory/hosts.yml
roles_path = roles
# Off because the relay host is a LAN appliance that gets reimaged. On a machine
# you do not re-key, leave this on: it is the one convenience setting here that
# trades away a real check.
host_key_checking = False
retry_files_enabled = False
deprecation_warnings = False
gathering = smart
fact_caching = jsonfile
fact_caching_connection = .ansible/fact_cache
fact_caching_timeout = 86400
# Read by the vault-backed secrets in group_vars/all/vault.yml.
# Keep this file out of git — see .gitignore.
vault_password_file = .vault-pass
[privilege_escalation]
# Only the port-forward tasks need root, and each asks for it by name: the
# firewall exception, the two LaunchDaemon writes, and the reload handlers.
# Everything else — the clone, the .env, the compose run — is unprivileged and
# runs as the account that owns the Docker daemon.
become = False
become_method = sudo
become_user = root
[ssh_connection]
pipelining = True
Detailed breakdown
inventory = inventory/hosts.ymlfixes the inventory path so everyansibleandansible-playbookinvocation agrees without a flag. It also makesinventory/the directory Ansible searches forgroup_vars, which matters in Step 4.host_key_checking = Falseis a real check traded for convenience on a LAN host that gets reimaged. On a machine you do not re-key, leave it on.vault_password_file = .vault-passmeansansible-playbookdecrypts the vault without prompting and without--vault-password-fileon every command. The file it points at is the one.gitignoreexcludes.become = Falseglobally, with only the port-forward tasks asking for root by name: the firewall exception, the two LaunchDaemon writes, and the reload handlers. Everything in/Library/LaunchDaemonsneeds it; the checkout, the environment file, and the Compose run must stay unprivileged, because they run as the account that owns the Docker daemon.pipelining = Truecuts one SSH round trip per task. It is safe here because no target usesrequirettyinsudoers.
Step 3: Describe both Macs in one inventory
The obvious way to group two Macs is by what they are: a laptop and a mini.
That grouping is wrong here, and following it produces an inventory you have to
rewrite the first time somebody runs a relay on a laptop. Group them by the job
they do instead. relays is wherever the always-on relay runs; clients is
wherever a human sits in front of the app. A machine can be in both.
Create the file
touch inventory/hosts.yml
Add the code: inventory/hosts.yml
---
# Buzz is two programs, so this inventory has two groups — and they are grouped
# by JOB, not by hardware. `relays` is wherever the always-on relay runs;
# `clients` is wherever a human sits in front of the desktop app.
#
# ansible-playbook playbooks/install-buzz.yml # both
# ansible-playbook playbooks/install-buzz.yml --limit relays
# ansible-playbook playbooks/install-buzz.yml --limit clients
#
# A reader with one Mac puts that Mac in both groups; see the one-Mac inventory
# in the article's "If you only have one Mac" section. Nothing in either role
# assumes the two are separate.
all:
children:
relays:
hosts:
minime:
ansible_host: minime.local
ansible_connection: ssh
ansible_user: serviceuser
# /usr/bin/python3 is a shim: with the Command Line Tools installed
# it runs them, without them it opens the install dialog. Pin it so
# interpreter discovery cannot drift to a future Homebrew python.
ansible_python_interpreter: /usr/bin/python3
# The authority the relay bakes into the community at first boot, and
# matches every later Host header against. It must be a name the
# CLIENT can resolve, which is why it is `.local` (mDNS, works on any
# Mac LAN) rather than a short name that only resolves if your router
# happens to hand it out. Changing this later does not rename the
# community: the relay provisions a second, empty one under the new
# authority and the first one's history stays in the database.
buzz_public_host: minime.local
# YOUR 64-character hex Nostr pubkey. Settings → Profile → Identity
# details → Public key in the desktop app, which is the HEX form —
# not the npub next to it. The role refuses to run while this is
# empty, which is the right failure: a relay seeded with somebody
# else's key has a roster that looks correct and an owner you are not.
buzz_relay_owner_pubkey_hex: ""
# This host runs Colima, whose VM holds its own address; published
# container ports bind there, not on the Mac's loopback. The role
# discovers the address and bridges it to the LAN. See Step 8.
buzz_relay_lan_forward: true
buzz_relay_lan_bind_address: 192.168.1.100
clients:
hosts:
devbot5:
# The machine you are typing on. No SSH, no network round trip, and no
# sshd to enable just to install an app on your own laptop.
ansible_connection: local
ansible_user: mitch
ansible_python_interpreter: /usr/bin/python3
Detailed breakdown
ansible_connection: localon the workstation. Configuring the machine you are typing on does not need SSH, a network round trip, or an sshd you then have to remember you enabled.ansible_host: minime.localrather than a short name or an address. mDNS resolves.localon any Mac LAN without the router’s cooperation, and this same name becomes the community authority below, so the two cannot drift.ansible_python_interpreter: /usr/bin/python3on both. The systempython3is a shim: with the Command Line Tools installed it runs them, without them it opens the install dialog — which is why the Command Line Tools are a prerequisite on a machine with no screen. Pinning both Macs also stops Ansible warning that a future Homebrew install could change which one it picks.buzz_public_hostis the one value the deployment turns on. It is set on the relay host and read from there by both halves. Step 4 explains what the relay does with it, and Step 14 shows what happens when a client spells it differently.buzz_relay_owner_pubkey_hexis deliberately empty. The role refuses to run while it is, which is the right failure: a relay seeded with somebody else’s key has a roster — the list of pubkeys the relay admits — that looks correct and an owner you are not.buzz_relay_lan_forwardandbuzz_relay_lan_bind_addressturn on the bridging that Step 8 builds. They live on the host rather than in a group because they describe this machine’s network, not a class of machine.
Step 4: One authority, two hosts
Three files sit in inventory/group_vars. The split between them is not
cosmetic: all/main.yml holds the handful of facts both halves must agree
on, and the two group files hold policy that differs by job. Anything that is
role mechanics stays in the role’s own defaults, so there is exactly one
place to change any given value.
Note the directory. group_vars is searched relative to the inventory
directory, so inventory/group_vars/all/ is loaded automatically for every
play. A group_vars/ at the project root is not searched at all, and a vault
placed there silently never loads.
Create the files
touch inventory/group_vars/all/main.yml \
inventory/group_vars/relays.yml \
inventory/group_vars/clients.yml
Add the code: inventory/group_vars/all/main.yml
---
# Applies to every host in the inventory. What lives here is the small set of
# facts the two halves must AGREE on — pinned upstream versions, and the relay
# URL. Anything that differs by class belongs in relays.yml or clients.yml, and
# anything that is role mechanics belongs in the role's defaults/main.yml.
# The desktop app is pinned. The relay deliberately is NOT: `:main` and the
# `main` branch are moving targets, which is what upstream publishes for
# pre-release testing. Two consequences worth naming rather than discovering:
# the relay you validate today is not the image you get next month, and a
# re-run reports a change whenever upstream moves. For a deployment you want to
# hold still, pin `buzz_image` to `:sha-<7>` or a release tag as `.env.example`
# recommends, and `buzz_repo_version` to a commit SHA.
buzz_image: ghcr.io/block/buzz:main
buzz_repo_version: main
buzz_desktop_version: "0.5.18"
# The port the relay publishes, and the scheme clients speak. `ws` because this
# is a LAN deployment with no certificate; a relay reachable from outside your
# network wants `wss` and a reverse proxy in front, which is out of scope here.
buzz_http_port: 3000
buzz_relay_scheme: ws
# ---------------------------------------------------------------------------
# The one value the whole deployment turns on.
#
# The relay derives the community's host from the AUTHORITY of RELAY_URL —
# hostname plus port, with only :80 and :443 stripped — the first time it
# boots, then matches the Host header of every later connection against it.
# Defining it once, here, is what stops the relay and the client disagreeing:
# the client half reads the same value out of the relay host's inventory entry
# rather than being told separately.
#
# Do not override these two. Set `buzz_public_host` on the relay host instead.
# ---------------------------------------------------------------------------
buzz_relay_authority: >-
{{ hostvars[groups['relays'][0]].buzz_public_host }}:{{ buzz_http_port }}
buzz_relay_url: "{{ buzz_relay_scheme }}://{{ buzz_relay_authority }}"
Detailed breakdown
buzz_relay_authorityreaches into the relay host’s inventory entry.hostvars[groups['relays'][0]].buzz_public_hostis what makes the client half learn the URL from the relay instead of being told separately. Two variables holding the same hostname is how a client ends up pointed at a relay that will refuse it.- The authority is host plus port. The relay strips only
:80and:443, as scheme defaults, so3000is part of the community’s identity. Every refusal in Step 14 comes from a client disagreeing with this line. ws, notwss. This is a LAN deployment with no certificate. A relay reachable from outside your network wants TLS and a reverse proxy in front, which is out of scope here — and on a host that already runs something on 80 and 443, the bundle’s own Caddy profile cannot bind anyway.- Only the desktop app is pinned, and the comment says why the relay is not.
:mainand themainbranch are what upstream publishes for pre-release testing, so this deployment tracks them deliberately. The cost is that the relay you validate today is not the image you get next month, and thatansible.builtin.gitreports a change whenever upstream moves, so the zero-change second run holds only until the next push tomain. Pinbuzz_imageto a:sha-<7>or release tag andbuzz_repo_versionto a commit SHA when you want a deployment that holds still.
Add the code: inventory/group_vars/relays.yml
---
# The relay is the shared log for everyone and everything in the workspace, so
# the policy here is "closed by default", and it is written down rather than
# inherited from a template someone may have edited.
# Only pubkeys on the roster are admitted. This is the switch that makes the
# relay yours instead of an open one that anybody on your LAN can write to.
buzz_require_relay_membership: true
# Governs the HTTP/NIP-98 bridge, not reads. With it false, a dev-mode X-Pubkey
# header fallback activates — which is a reasonable thing to want on a laptop
# and never on an always-on host. WebSocket read authentication is
# unconditional either way.
buzz_require_auth_token: true
# Let the relay run its own schema migrations on first boot. Without it, an
# empty database sits there until you run `buzz-admin migrate` by hand — on a
# machine with no screen, which is the wrong place to discover that.
buzz_auto_migrate: true
Detailed breakdown
buzz_require_relay_membership: trueis the switch that closes the relay. Only pubkeys on the roster are admitted. Without it you have an open relay that anybody on your LAN can write to.buzz_require_auth_token: truegoverns the HTTP/NIP-98 bridge rather than reads. With it false, a dev-modeX-Pubkeyheader fallback activates, which is a defensible thing to want on a laptop and never on an always-on host. WebSocket read authentication is unconditional either way.buzz_auto_migrate: truelets the relay run its own schema migrations on first boot. Without it, an empty database waits for a hand-runbuzz-admin migrateon a machine with no screen.
Add the code: inventory/group_vars/clients.yml
---
# A client host is a Mac somebody sits at. The only class-level policy is that
# the download is verified before it is installed.
# Refuse to install a bundle that is not signed by Block's Developer ID team and
# does not carry a stapled notarization ticket. Turning this off is not a
# supported configuration; it exists as a variable so the assertion has a name
# in the play output rather than being an anonymous `when:`.
buzz_verify_signature: true
# Block, Inc. — the team identifier in the Developer ID certificate on every
# macOS Buzz build. Read off `codesign -dv` output; see the article's Step 10.
buzz_expected_team_id: EYF346PHUG
Detailed breakdown
buzz_expected_team_idis Block’s Developer ID team identifier, read offcodesignoutput. Step 10 asserts on it, so a bundle from anywhere else fails the play instead of landing in/Applications.buzz_verify_signatureexists to give the assertion a name. Turning it off is not supported; making it a variable means the skip shows up in the play output instead of hiding inside an anonymous condition.
Step 5: Put the six secrets in a vault
The relay’s environment file ships with seven CHANGE_ME assignments: six
secrets and your owner key. run.sh refuses to start while any remain, and the
bundle’s own comment states the constraint that matters: these values must not
rotate on restart. BUZZ_RELAY_PRIVATE_KEY in particular is the relay’s own
Nostr keypair. The relay is a participant in the community, not just a server for
it: every relay-authored event, the membership roster included, is signed with
this key. Regenerating it on an existing relay changes who those
events appear to come from.
Generating them once into an encrypted file, and never again, is therefore the
whole job. make vault-init in Step 11 does it; here is what it produces and
where it goes.
The passphrase file can be created now; the generator that uses it is a Makefile target written in Step 11, so the second command below is the one to come back to, not run now.
Create the file
openssl rand -hex 24 > .vault-pass
chmod 600 .vault-pass
Then, once Step 11 has given you a Makefile:
make vault-init
Created and encrypted inventory/group_vars/all/vault.yml
What is inside it
ansible-vault view inventory/group_vars/all/vault.yml shows the plaintext.
Yours will hold different values; the shape is what matters:
---
# Encrypted with ansible-vault. Commit this file; .vault-pass is not
# committed. These six values must NEVER rotate on an existing relay:
# BUZZ_RELAY_PRIVATE_KEY is the identity every relay-authored event,
# including the membership roster, was signed with.
buzz_relay_private_key: "<64 hex characters>"
buzz_git_hook_hmac_secret: "<64 hex characters>"
buzz_postgres_password: "<64 hex characters>"
buzz_redis_password: "<64 hex characters>"
buzz_s3_access_key: "<24 hex characters>"
buzz_s3_secret_key: "<64 hex characters>"
Detailed breakdown
- The file is in
inventory/group_vars/all/, next tomain.yml. That is what makes it load for every play with novars_filesentry and no ordering to remember. It is also whymain.ymlis a file inside anall/directory rather than anall.ymlbeside it: a group cannot be both. openssl rand -hex 24for the passphrase, written straight to a file.gitignorealready excludes. It never appears in shell history as a literal.vault-initrefuses to overwrite. Re-running it on an existing vault is the mistake that costs you the relay’s identity, so the target checks and exits instead of overwriting.- The seventh
CHANGE_ME, your owner pubkey, is not here. It is not a secret, it is not generated, and it belongs in the inventory next to the host it identifies.
Step 6: Teach Docker where Compose lives
This step exists because of a failure that looks like something else. On the
relay host, docker works, docker-compose works, and docker compose does
not:
ssh minime 'export PATH=/opt/homebrew/bin:$PATH; docker compose version'
docker: unknown command: docker compose
(Abridged: the real CLI follows that line with a blank line and
Run 'docker --help' for more information.)
Compose is a Docker CLI plugin. Homebrew’s docker-compose formula installs
the plugin binary into /opt/homebrew/lib/docker/cli-plugins and prints a caveat
telling you to add that directory to ~/.docker/config.json. The caveat scrolls
past, the standalone docker-compose on PATH works, and nothing looks broken
until something invokes the plugin form. Buzz’s run.sh does, on every
subcommand:
compose() {
docker compose --env-file .env "${COMPOSE_FILES[@]}" "$@"
}
add-member, remove-member and list-members call docker compose exec
directly as well, so the whole bundle is unusable until the plugin is
discoverable. The role fixes it by merging one key into the existing config.
The role in Step 9 does this, and the output below is the state you will have after Step 13 — not now. Run the command above again once the relay is up if you want to watch it change.
Merging, not writing. This host’s config.json already carries
currentContext, and on a machine whose daemon is Colima that key is what points
the CLI at the right socket. Replacing the file with a freshly composed one
would add the plugin path and take the daemon away. After the role runs, the
file reads:
{
"auths": {},
"cliPluginsExtraDirs": [
"/opt/homebrew/lib/docker/cli-plugins"
],
"currentContext": "colima"
}
and the plugin resolves:
Docker Compose version 5.1.1
Step 7: Template the relay environment
The environment file is where the community’s identity is decided, so this template is worth reading closely. Two of its values look like they set the public hostname and do not; a third actually does.
Create the file
touch roles/buzz_relay/templates/relay.env.j2
Add the code: roles/buzz_relay/templates/relay.env.j2
# {{ ansible_managed }}
#
# Buzz single-node relay environment. Rendered by roles/buzz_relay onto
# {{ inventory_hostname }} with mode 0600. Do not edit on the host: the next
# playbook run overwrites it and restarts the relay.
#
# The six generated secrets come from group_vars/all/vault.yml and must never
# rotate — the relay signing key in particular is the identity every
# relay-authored event, including the membership roster, is signed with.
BUZZ_IMAGE={{ buzz_image }}
# --- Identity of the deployment -------------------------------------------
# RELAY_URL is the one that provisions the community. The relay takes its
# AUTHORITY — host plus port, with only :80 and :443 stripped as scheme
# defaults — as the community host at first boot, and matches the Host header
# of every later connection against it. With the value below, a client that
# connects to a different spelling of this same machine is refused.
RELAY_URL={{ buzz_relay_url }}
# Read only by compose.caddy.yml, which this deployment does not use. Set
# consistently so it is not misleading, but changing it alone moves nothing.
BUZZ_DOMAIN={{ buzz_public_host }}
# Read by nothing in the repository at all. Kept for parity with .env.example.
BUZZ_MEDIA_SERVER_DOMAIN={{ buzz_public_host }}
BUZZ_MEDIA_BASE_URL=http://{{ buzz_relay_authority }}/media
BUZZ_CORS_ORIGINS=http://{{ buzz_relay_authority }}
# --- Policy ----------------------------------------------------------------
BUZZ_REQUIRE_AUTH_TOKEN={{ buzz_require_auth_token | bool | lower }}
BUZZ_REQUIRE_RELAY_MEMBERSHIP={{ buzz_require_relay_membership | bool | lower }}
BUZZ_ALLOW_NIP_OA_AUTH=true
BUZZ_AUTO_MIGRATE={{ buzz_auto_migrate | bool | lower }}
BUZZ_GIT_CONFORMANCE_PROBE=true
RUST_LOG=buzz_relay=info,buzz_db=info,buzz_auth=info,buzz_pubsub=info,tower_http=info
# --- Owner -----------------------------------------------------------------
# Deliberately not prefixed BUZZ_. Enforced on EVERY start, not just the first:
# the relay upserts this key to role `owner` and demotes any other owner in the
# community to `admin`. Changing it and restarting therefore transfers
# ownership, which is upstream's documented way to do it — `add-member` refuses
# the `owner` role and tells you to use this variable instead.
RELAY_OWNER_PUBKEY={{ buzz_relay_owner_pubkey_hex }}
# --- Generated secrets (vault) ---------------------------------------------
BUZZ_RELAY_PRIVATE_KEY={{ buzz_relay_private_key }}
BUZZ_GIT_HOOK_HMAC_SECRET={{ buzz_git_hook_hmac_secret }}
POSTGRES_DB=buzz
POSTGRES_USER=buzz
POSTGRES_PASSWORD={{ buzz_postgres_password }}
REDIS_PASSWORD={{ buzz_redis_password }}
BUZZ_S3_ACCESS_KEY={{ buzz_s3_access_key }}
BUZZ_S3_SECRET_KEY={{ buzz_s3_secret_key }}
BUZZ_S3_BUCKET=buzz-media
# The bundled MinIO serves path-style URLs; compose.yml pins this to match.
BUZZ_S3_ADDRESSING_STYLE=path
# --- Ports -----------------------------------------------------------------
BUZZ_HTTP_PORT={{ buzz_http_port }}
Detailed breakdown
RELAY_URLprovisions the community. The relay takes its authority as the community host the first time it boots and matches theHostheader of every later connection against it. Everything else in the identity block is downstream of this line.BUZZ_DOMAINis not that value, despite the name. Nothing in the relay reads it; it is consumed only by the optional Caddy TLS profile. Setting it consistently keeps the file from being misleading, but changing it alone moves nothing.BUZZ_MEDIA_SERVER_DOMAINis read by nothing in the repository at all, and is present only for parity with.env.example.RELAY_OWNER_PUBKEYhas noBUZZ_prefix. That is deliberate upstream; renaming it to match the others produces a variable nothing reads. It is also applied on every start rather than seeded once: the relay upserts this key toownerand demotes any other owner in the community toadmin(the roster has three roles:owner,admin, andmember). Changing it and restarting is therefore how you transfer ownership, and upstream says so —add-memberrefuses theownerrole and points at this variable instead.- The six vault values are interpolated directly, and the task that renders
this template sets
no_log: trueso the diff never reaches the terminal or a CI log. - The dev and Caddy port variables from
.env.exampleare omitted. They are read only bycompose.dev.ymlandcompose.caddy.yml, neither of which this deployment includes. Carrying them would imply they do something.
Step 8: One socat listener per address family
Compose publishes the relay on the Docker host, and on a Mac the Docker host
is a Linux VM rather than the Mac. Docker Desktop hides that by forwarding
published ports onto the Mac’s loopback. Colima advertises automatic port
forwarding too, but it is not in play on this host — measure rather than assume,
and if 127.0.0.1:3000 answers on yours, set buzz_relay_lan_forward: false and
skip Step 8 entirely. Where it is not in play, as here,
once the relay is running (Step 13) the difference is
measurable from the relay host itself. Come back and run this, because it is the
reason the step exists:
ssh minime 'curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3000/;
curl -s -o /dev/null -w "%{http_code}\n" http://192.168.64.2:3000/'
000
404
Run it before Step 13 and both return 000, because nothing is published yet.
Afterwards the Mac’s own loopback still returns 000 while the VM’s address
answers. The 404 is not a missing route: it is the relay declining a Host it
has not provisioned a community for, which Step 14 is about. For this purpose it
is the useful reply, because only a live relay produces it.
The port is alive at the VM’s address and nowhere else, so nothing on the LAN
can reach the relay at all. A socat process bridging the Mac’s address to the
VM’s fixes it, and because socat works at the TCP layer the client’s Host
header crosses it untouched, which is what lets the community authority still
match on the far side.
The part that is easy to get wrong is how many listeners you need. One is not
enough. A .local name is advertised over mDNS with AAAA records as well as an
A record, and getaddrinfo hands the IPv6 addresses back first:
python3 -c "
import socket
for fam, _, _, _, sa in socket.getaddrinfo('minime.local', 3000, type=socket.SOCK_STREAM):
print('IPv6' if fam == socket.AF_INET6 else 'IPv4', sa[0])
"
IPv6 fe80::4d1b:2a70:9c33:e5d2
IPv6 2001:db8:a010:1:6a91:3f04:2c18:7b52
IPv6 2001:db8:a010:1::25
IPv4 192.168.1.100
(The IPv6 addresses above are replaced with documentation-range equivalents. The ordering is the point, and yours will differ.)
An IPv4-only forward therefore appears to work and does not. curl races both
families and falls back in milliseconds, so a hand check with curl passes.
A client that simply tries addresses in order, which many WebSocket clients
still do, hits a link-local IPv6 address first and stalls. The usual symptom is a
hang during the opening handshake against a relay that is provably up. Whether
it hangs or refuses depends on the relay host: with the application firewall
enabled it drops the packet and you get a timeout, and with the firewall off the
kernel sends a reset and you get an immediate refusal.
Two listeners, one per family, from one template.
Create the file
touch roles/buzz_relay/templates/socat-forward.plist.j2
Add the code: roles/buzz_relay/templates/socat-forward.plist.j2
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!-- {{ ansible_managed }} — do not edit on the host.
Bridges port {{ buzz_http_port }} on this Mac ({{ item }}) to
{{ buzz_relay_docker_host_address }}:{{ buzz_http_port }} inside the
Docker VM. The VM address is IPv4 either way, so only the LISTEN half
changes between the two families.
KeepAlive, unlike the Colima boot daemon, because socat is a long-running
process: if it dies the forward is gone. RunAtLoad brings it back after a
reboot.
This is a TCP-level relay, so the client's Host header crosses it
unchanged — which is what lets the community authority still match on the
far side. -->
<plist version="1.0">
<dict>
<key>Label</key>
<string>{{ buzz_relay_launchdaemon_label }}-{{ item }}</string>
<key>ProgramArguments</key>
<array>
<string>{{ buzz_relay_homebrew_prefix }}/bin/socat</string>
{% if item == 'v4' %}
<string>TCP4-LISTEN:{{ buzz_http_port }},bind={{ buzz_relay_lan_bind_address }},reuseaddr,fork</string>
{% else %}
<string>TCP6-LISTEN:{{ buzz_http_port }},ipv6only=1,reuseaddr,fork</string>
{% endif %}
<string>TCP4:{{ buzz_relay_docker_host_address }}:{{ buzz_http_port }}</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/{{ buzz_relay_launchdaemon_label }}-{{ item }}.log</string>
<key>StandardErrorPath</key>
<string>/tmp/{{ buzz_relay_launchdaemon_label }}-{{ item }}.err</string>
</dict>
</plist>
Detailed breakdown
itemis the address family,v4orv6, supplied by a loop in Step 9. The two listeners differ only in the LISTEN half; the target is IPv4 either way, because the Colima VM’s address is IPv4.TCP4-LISTENbinds one interface. Naming the LAN address rather than0.0.0.0makes the exposure a deliberate act on one interface. The IPv6 listener usesipv6only=1on all interfaces, which is what reaches the link-local address a client may try first. That is a real asymmetry: if your router permits inbound IPv6, the v6 listener is reachable off-network in a way the v4 one is not. Membership and token checks still apply, but add abind=to the host’s own address if you want the exposure to match.forkso each connection gets its own child. Without it socat serves one connection and exits, andKeepAliveturns that into a restart loop.KeepAlivehere, unlike the Colima boot daemon, because socat is a long-running process rather than a script that exits when its work is done. If it dies the forward is gone, and launchd bringing it back is why the key is set.RunAtLoadbrings both listeners back after a reboot.
Step 9: Write the relay role
The role is three files: defaults that nobody should need to edit, a task list that runs top to bottom, and one handler. The tasks are ordered so that every cheap check happens before anything slow. A first run pulls about 1.2 GB of images, and there is no reason to find a missing owner key after that.
Create the file
touch roles/buzz_relay/defaults/main.yml
Add the code: roles/buzz_relay/defaults/main.yml
---
# Role mechanics. Policy lives in inventory/group_vars; per-host identity lives
# in inventory/hosts.yml. Nothing here should need editing to deploy a relay.
# Where the deployment bundle is checked out on the relay host. `deploy/compose`
# inside it is the single-node bundle: a prebuilt relay image plus its
# dependencies. The repository root's docker-compose.yml is a different thing —
# development infrastructure that expects you to build the relay from source.
buzz_relay_checkout_dir: "{{ ansible_env.HOME }}/buzz"
buzz_relay_compose_dir: "{{ buzz_relay_checkout_dir }}/deploy/compose"
buzz_relay_repo_url: https://github.com/block/buzz.git
# Required. A 64-character lowercase hex Nostr pubkey — the identity the relay
# seeds the roster with as `owner` on first boot. There is no sensible default:
# an unset owner is a relay you cannot join, and someone else's key is a relay
# whose roster looks right and whose owner you are not.
buzz_relay_owner_pubkey_hex: ""
# Homebrew's Compose is a Docker CLI PLUGIN, and Docker only finds plugins in
# directories it has been told about. See tasks/main.yml.
buzz_relay_homebrew_prefix: /opt/homebrew
buzz_relay_compose_plugin_dir: "{{ buzz_relay_homebrew_prefix }}/lib/docker/cli-plugins"
buzz_relay_docker_config_path: "{{ ansible_env.HOME }}/.docker/config.json"
# A non-interactive SSH session does not source a login shell, so Homebrew's bin
# directory is not on PATH and `docker` is simply not found. Every task in this
# role runs with this PATH rather than relying on the remote user's dotfiles.
buzz_relay_env_path: "{{ buzz_relay_homebrew_prefix }}/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
# LAN bridging. Off by default because it is only needed when the Docker daemon
# publishes ports somewhere other than the Mac's own loopback — which is the
# Colima case, and not the Docker Desktop case. See tasks/main.yml.
buzz_relay_lan_forward: false
buzz_relay_lan_bind_address: ""
# Left empty to be discovered from `colima ls --json`. Set it explicitly to skip
# discovery, for example on a Docker daemon that is neither Colima nor Desktop.
buzz_relay_docker_host_address: ""
# Which Colima VM to ask. Colima supports several side by side and
# `colima ls --json` prints one JSON object per line, so the profile has to
# be named rather than assuming the first line is the right one.
buzz_relay_colima_profile: default
buzz_relay_launchdaemon_label: "com.homelab.buzz-forward-{{ buzz_http_port }}"
# Address families to forward. Drop `v6` only if the relay host has no IPv6
# address at all — see the note on the forward tasks for why an IPv4-only
# forward fails in a way that looks like the relay being down.
buzz_relay_forward_families: [v4, v6]
# Seconds to wait for the relay to answer on its published port after a start.
# `run.sh start` already blocks on Compose health checks; this catches the case
# where the relay is healthy INSIDE the VM and unreachable from outside it,
# which is a different failure and the one worth surfacing loudly.
buzz_relay_ready_timeout: 120
Detailed breakdown
buzz_relay_checkout_dirpoints at the repository, and the compose directory is derived from it.deploy/composeis the single-node bundle: a prebuilt relay image plus its dependencies. The repository root’sdocker-compose.ymlis a different thing: development infrastructure that expects you to build the relay from source, and the two are easy to confuse.buzz_relay_env_pathis not optional. A non-interactive SSH session does not source a login shell, so Homebrew’sbinis absent fromPATHanddockeris simply not found. That failure reads as “Docker is not installed” on a machine where Docker is plainly installed. Every task in the role sets this instead of trusting the remote account’s dotfiles.buzz_relay_lan_forwarddefaults to off because the bridging is only needed when published ports land somewhere other than the Mac’s own loopback. That is the Colima case and not the Docker Desktop case, so a workstation relay needs none of it.buzz_relay_colima_profileis named, not assumed. Colima supports several VMs side by side andcolima ls --jsonprints one JSON object per line, so taking the first line would silently pick the wrong VM on a host that has two.- The reachability wait runs on the relay host, not from a client. It has no
delegate_to, so it proves the socat listener accepts a connection from the mini to its own LAN address. That validates the forward; it does not prove another machine can get through a firewall rule. Step 14 is the cross-LAN proof. buzz_relay_ready_timeoutguards a specific failure, not startup in general.run.sh startalready blocks on Compose health checks, so by the time the wait runs the relay is healthy inside the VM. What the wait catches is a relay that is healthy there and unreachable from outside, which is a different problem and the one worth surfacing loudly.
Create the file
touch roles/buzz_relay/tasks/main.yml
Add the code: roles/buzz_relay/tasks/main.yml
---
# Everything runs with an explicit PATH. A non-interactive SSH session does not
# source a login shell, so Homebrew's bin directory is absent and `docker` is
# not found — a failure that reads like "Docker is not installed" on a machine
# where Docker is plainly installed.
- name: Assert an owner pubkey was supplied
ansible.builtin.assert:
that:
# `is not none` is load-bearing: regex_search returns None on no match,
# and ansible-core 2.21 refuses a conditional that is not a boolean
# rather than treating None as false.
- buzz_relay_owner_pubkey_hex | default('') | regex_search('^[0-9a-f]{64}$') is not none
fail_msg: >-
buzz_relay_owner_pubkey_hex must be a 64-character lowercase hex Nostr pubkey.
This is the `Public key` shown under Settings → Profile in the desktop
app, not the npub. Set it in inventory/hosts.yml or pass
-e buzz_relay_owner_pubkey_hex=<hex>.
quiet: true
- name: Assert the generated secrets are present
ansible.builtin.assert:
that:
- buzz_relay_private_key | default('') | length == 64
- buzz_git_hook_hmac_secret | default('') | length == 64
- buzz_postgres_password | default('') | length > 0
- buzz_redis_password | default('') | length > 0
- buzz_s3_access_key | default('') | length > 0
- buzz_s3_secret_key | default('') | length > 0
fail_msg: >-
The vault is missing one or more relay secrets. Run `make vault-init`
once, then `make vault-view` to confirm all six are set. Do not
regenerate them on an existing relay: the signing key is the identity
every relay-authored event was signed with.
quiet: true
- name: Confirm the Docker daemon is reachable
ansible.builtin.command:
argv:
- docker
- version
- "--format={{ '{{.Server.Version}}' }}"
environment:
PATH: "{{ buzz_relay_env_path }}"
register: buzz_relay_docker_version
changed_when: false
failed_when: buzz_relay_docker_version.rc != 0
# ---------------------------------------------------------------------------
# `docker compose` is a CLI plugin, and Homebrew installs it where Docker does
# not look. `brew install docker-compose` drops the plugin binary in
# {{ buzz_relay_compose_plugin_dir }} and prints a caveat telling you to add that
# directory to ~/.docker/config.json — which nobody reads, so `docker compose`
# stays "unknown command" while `docker-compose` works. Every Buzz run.sh
# subcommand uses the plugin form, so the bundle is unusable until this is done.
# ---------------------------------------------------------------------------
- name: Look for a Compose plugin in the Homebrew plugin directory
ansible.builtin.stat:
path: "{{ buzz_relay_compose_plugin_dir }}/docker-compose"
register: buzz_relay_compose_plugin
- name: Ensure the Docker CLI configuration directory exists
ansible.builtin.file:
path: "{{ buzz_relay_docker_config_path | dirname }}"
state: directory
mode: "0700"
when: buzz_relay_compose_plugin.stat.exists
- name: Read the existing Docker CLI configuration
ansible.builtin.slurp:
src: "{{ buzz_relay_docker_config_path }}"
register: buzz_relay_docker_config_raw
failed_when: false
changed_when: false
when: buzz_relay_compose_plugin.stat.exists
# MERGE, do not overwrite. This host's config.json already carries
# `currentContext`, and on a machine whose Docker daemon is Colima that one key
# is what points the CLI at the right socket. A `copy` of a freshly composed
# file would take the daemon away while adding the plugin path.
- name: Merge the plugin directory into the Docker CLI configuration
ansible.builtin.set_fact:
buzz_relay_docker_config_merged: >-
{{ buzz_relay_docker_config_existing | combine({'cliPluginsExtraDirs':
buzz_relay_docker_config_existing.cliPluginsExtraDirs | default([])
| union([buzz_relay_compose_plugin_dir])}) }}
vars:
buzz_relay_docker_config_existing: >-
{{ (buzz_relay_docker_config_raw.content | b64decode | from_json)
if (buzz_relay_docker_config_raw.content | default('')) | length > 0
else {} }}
when: buzz_relay_compose_plugin.stat.exists
- name: Write the Docker CLI configuration
ansible.builtin.copy:
content: "{{ buzz_relay_docker_config_merged | to_nice_json }}\n"
dest: "{{ buzz_relay_docker_config_path }}"
mode: "0600"
when: buzz_relay_compose_plugin.stat.exists
- name: Confirm `docker compose` now resolves
ansible.builtin.command:
argv: [docker, compose, version, --short]
environment:
PATH: "{{ buzz_relay_env_path }}"
register: buzz_relay_compose_version
changed_when: false
failed_when: buzz_relay_compose_version.rc != 0
# ---------------------------------------------------------------------------
# The deployment bundle and its environment
# ---------------------------------------------------------------------------
- name: Check out the Buzz deployment bundle
ansible.builtin.git:
repo: "{{ buzz_relay_repo_url }}"
dest: "{{ buzz_relay_checkout_dir }}"
version: "{{ buzz_repo_version }}"
depth: 1
force: false
register: buzz_relay_checkout
- name: Render the relay environment file
ansible.builtin.template:
src: relay.env.j2
dest: "{{ buzz_relay_compose_dir }}/.env"
mode: "0600"
# No handler. `run.sh start` is `docker compose up -d --wait`, and Compose
# folds env_file contents into the service's config hash, so a changed .env
# recreates the relay on the very next start.
no_log: true
# ---------------------------------------------------------------------------
# LAN reachability
#
# Compose publishes the relay on the DOCKER HOST, and on a Mac the Docker host
# is a Linux VM, not the Mac. With Docker Desktop that distinction is hidden:
# it forwards published ports onto the Mac's loopback. Colima with a VM address
# does not, so the port is live at the VM's address and nowhere else — and a
# second Mac on the LAN cannot reach it at all.
# ---------------------------------------------------------------------------
- name: Bridge the published port onto the LAN
when: buzz_relay_lan_forward | bool
block:
- name: Assert a LAN bind address was supplied
ansible.builtin.assert:
that:
- buzz_relay_lan_bind_address | default('') | length > 0
fail_msg: >-
buzz_relay_lan_forward is true, so buzz_relay_lan_bind_address must be this Mac's
LAN address. Bind to the address rather than 0.0.0.0 so the forward is
a deliberate exposure of one interface.
quiet: true
- name: Ask Colima for the address of its VM
ansible.builtin.command:
argv: [colima, ls, --json]
environment:
PATH: "{{ buzz_relay_env_path }}"
register: buzz_relay_colima_ls
changed_when: false
failed_when: false
when: buzz_relay_docker_host_address | length == 0
- name: Use the Colima VM address as the forward target
ansible.builtin.set_fact:
buzz_relay_docker_host_address: >-
{{ (buzz_relay_colima_ls.stdout_lines | map('from_json')
| selectattr('name', 'equalto', buzz_relay_colima_profile)
| first).address }}
when:
- buzz_relay_docker_host_address | length == 0
- buzz_relay_colima_ls.rc | default(1) == 0
- buzz_relay_colima_ls.stdout_lines | default([]) | length > 0
- name: Assert a forward target is known
ansible.builtin.assert:
that:
- buzz_relay_docker_host_address | default('') | length > 0
fail_msg: >-
Could not determine the address published ports land on. Colima was
not usable and buzz_relay_docker_host_address was not set. On Docker
Desktop set it to 127.0.0.1; on Colima run `colima ls --json` and use
the `address` field.
quiet: true
# `creates` is what makes this idempotent, and it keeps the role on builtin
# modules — no collection to install before the playbook will even parse.
- name: Install socat
ansible.builtin.command:
argv: [brew, install, socat]
creates: "{{ buzz_relay_homebrew_prefix }}/bin/socat"
environment:
PATH: "{{ buzz_relay_env_path }}"
# --unblockapp fails on a binary the firewall has never heard of, so --add
# has to come first. Both are tolerant: on a host with the firewall off,
# socketfilterfw still exits non-zero and that is not a reason to fail.
- name: Add socat to the macOS application firewall
ansible.builtin.command:
argv:
- /usr/libexec/ApplicationFirewall/socketfilterfw
- --add
- "{{ buzz_relay_homebrew_prefix }}/bin/socat"
become: true
changed_when: false
failed_when: false
- name: Allow socat through the macOS application firewall
ansible.builtin.command:
argv:
- /usr/libexec/ApplicationFirewall/socketfilterfw
- --unblockapp
- "{{ buzz_relay_homebrew_prefix }}/bin/socat"
become: true
changed_when: false
failed_when: false
# One listener per address family. This is not belt-and-braces: a `.local`
# name is advertised over mDNS with AAAA records as well as an A record,
# and getaddrinfo hands the IPv6 addresses back FIRST. An IPv4-only forward
# therefore works for curl — which races both families and falls back in
# milliseconds — and hangs for every client that simply tries addresses in
# order, which includes most WebSocket libraries. The symptom is a timeout
# during the opening handshake against a relay that is demonstrably up.
- name: Install the port-forward LaunchDaemons
ansible.builtin.template:
src: socat-forward.plist.j2
dest: "/Library/LaunchDaemons/{{ buzz_relay_launchdaemon_label }}-{{ item }}.plist"
owner: root
group: wheel
mode: "0644"
become: true
loop: "{{ buzz_relay_forward_families }}"
notify: Restart the port forward
- name: Reload the port-forward LaunchDaemons
ansible.builtin.command:
argv:
- launchctl
- bootstrap
- system
- "/Library/LaunchDaemons/{{ buzz_relay_launchdaemon_label }}-{{ item }}.plist"
become: true
loop: "{{ buzz_relay_forward_families }}"
register: buzz_relay_forward_bootstrap
# `bootstrap` returns launchd's generic error 5 for a label that is already
# loaded, which is the steady state after the first run and not a failure.
# (5 is EIO, not EBUSY — the number is what launchctl returns, the name is
# not meaningful here.) Anything else is a real error.
failed_when: buzz_relay_forward_bootstrap.rc not in [0, 5]
changed_when: buzz_relay_forward_bootstrap.rc == 0
# Apply any pending forward change now rather than at the end of the play.
# Handlers normally run last, which would leave the reachability check below
# testing the OLD forward — passing on a target the play has just replaced.
- name: Apply any pending port-forward change
ansible.builtin.meta: flush_handlers
# ---------------------------------------------------------------------------
# Start it
# ---------------------------------------------------------------------------
- name: Start the relay
ansible.builtin.command:
cmd: ./run.sh start
chdir: "{{ buzz_relay_compose_dir }}"
environment:
PATH: "{{ buzz_relay_env_path }}"
register: buzz_relay_start
# Compose reports progress on stderr, and a converged stack reports "Running"
# and "Healthy" for the four long-lived services. `minio-init` is excluded
# deliberately: it is a one-shot job (`restart: "no"`, depended on with
# `service_completed_successfully`), so Compose starts it on EVERY `up` and it
# always prints "Started". Matching it would report a change on every run
# forever — an idempotence bug that only shows up on the second run.
changed_when: >-
buzz_relay_start.stderr_lines
| reject('search', 'minio-init')
| select('search', 'Started|Created|Recreated')
| list | length > 0
- name: Wait for the relay to answer where clients will reach it
ansible.builtin.wait_for:
host: "{{ buzz_relay_lan_bind_address if buzz_relay_lan_forward | bool else buzz_relay_docker_host_address | default('127.0.0.1', true) }}"
port: "{{ buzz_http_port }}"
timeout: "{{ buzz_relay_ready_timeout }}"
Detailed breakdown
- The two asserts run first and cost nothing. The owner check uses
regex_search(...) is not nonerather than the bare filter, becauseregex_searchreturnsNoneon no match andansible-core2.21 refuses a conditional whose result is not a boolean instead of treatingNoneas false. The failure message is specific about wanting the hex form rather than thenpub, because that is the mistake people actually make. - The secrets assert checks lengths, never values.
assertprints the failing expression, not what it evaluated to, so this stays safe withoutno_log— and keepingno_logoff means the guidance infail_msgis actually visible when it fires. - The Compose plugin block reads the existing config and merges.
slurpwithfailed_when: falsehandles a host that has noconfig.jsonat all, and thecombinewithunionadds the plugin directory without disturbingauthsorcurrentContext. The verification task afterwards is what turns “we wrote a file” into “the plugin resolves”. ansible.builtin.gitwithforce: falsewill not throw away local modifications in the checkout. The environment file it renders lives inside that checkout, and the repository’s own.gitignorealready covers.env.- The environment task sets
no_log: true, so the template diff Ansible would otherwise print does not put six secrets in your scrollback. - The forward block discovers its target rather than being told.
colima ls --jsonis filtered by profile name, and an assert catches the case where discovery failed and no explicit address was supplied — with a message naming both fixes. launchctl bootstraptolerates rc 5. That is what launchd returns for a label that is already loaded, which is the steady state after the first run rather than an error. (It isEIOnumerically, notEBUSY; the number is what matters here, the name is not.) Anything else still fails.- The
changed_whenon the start task excludesminio-init. This is the one piece of the role whose behavior only shows up on a second run.minio-initis a one-shot job (restart: "no", depended on withservice_completed_successfully), so Compose starts it on everyupand it always printsStarted. A naive match onStartedreports a change forever. So the match is onStarted|Created|Recreatedwithminio-initfiltered out first; a converged stack reports onlyRunningandHealthyfor the four long-lived services, and therefore matches nothing. meta: flush_handlersruns before the relay starts, not at the end of the play where handlers normally fire. Without it the reachability check below would test a forward the play has just replaced but not yet reloaded, and pass against the old target.- There is no handler on the environment file.
run.sh startisdocker compose up -d --wait, and Compose foldsenv_filecontents into the service’s config hash, so a changed.envrecreates the relay on the next start with no notification to arrange.
Create the file
touch roles/buzz_relay/handlers/main.yml
Add the code: roles/buzz_relay/handlers/main.yml
---
# `launchctl kickstart -k` restarts a job from launchd's IN-MEMORY definition.
# It does not re-read the plist, so a job kicked after its file changed comes
# back running the OLD arguments — which for this role means socat forwarding to
# a stale VM address, silently. Only `bootout` followed by `bootstrap` loads a
# definition from disk. Proved by editing a test plist between the two: kickstart
# re-ran the old argv, bootout/bootstrap ran the new one.
#
# Both handlers share a `listen` topic so one notify runs them in order.
- name: Unload the port forward
ansible.builtin.command:
argv: [launchctl, bootout, "system/{{ buzz_relay_launchdaemon_label }}-{{ item }}"]
become: true
loop: "{{ buzz_relay_forward_families }}"
register: buzz_relay_forward_bootout
# Tolerates "not loaded": the handler must also work on a job nobody has
# bootstrapped yet.
failed_when: false
changed_when: buzz_relay_forward_bootout.rc == 0
listen: Restart the port forward
- name: Load the port forward
ansible.builtin.command:
argv:
- launchctl
- bootstrap
- system
- "/Library/LaunchDaemons/{{ buzz_relay_launchdaemon_label }}-{{ item }}.plist"
become: true
loop: "{{ buzz_relay_forward_families }}"
register: buzz_relay_forward_reload
failed_when: buzz_relay_forward_reload.rc not in [0, 5]
changed_when: buzz_relay_forward_reload.rc == 0
listen: Restart the port forward
Detailed breakdown
bootoutthenbootstrap, notkickstart.launchctl kickstart -krestarts a job from launchd’s in-memory definition and never re-reads the file, so a job kicked after its plist changed comes back running the old arguments. Since this role discoversbuzz_relay_docker_host_addressfromcolima ls --json, that address really can change, and the failure would be a forward silently pointing at a stale VM with nothing in the play output to say so. Onlybootoutfollowed bybootstraploads a definition from disk.bootouttolerates any exit code, because the handler has to work on a job nobody has bootstrapped yet.bootstrapkeeps the rc 5 tolerance for the already-loaded case.- Both handlers share a
listentopic, so onenotifyruns them in order, and each loops over the same family list as the tasks. A template change reloads both listeners instead of leaving one serving the old configuration.
Step 10: Verify the download before installing it
The client role does the mechanical half of a desktop install: fetch the disk
image, prove it is Block’s, prove the notarization ticket is stapled, ask
Gatekeeper for its opinion, and only then copy the bundle into /Applications.
Every one of those checks is something people skip when installing by hand, and
automating them costs five tasks.
It does not create an identity, and the last task says so rather than leaving you to notice the role ended early.
Create the file
touch roles/buzz_client/defaults/main.yml
Add the code: roles/buzz_client/defaults/main.yml
---
# The desktop app half. This role installs a verified app bundle and stops
# there. It does not create an identity: the first launch generates a Nostr
# keypair and stores the secret half in the login keychain, which is a GUI flow
# on an unlocked session and is not something to automate away.
# aarch64 for Apple Silicon, x64 for Intel. Derived rather than configured so a
# mixed-hardware inventory needs no per-host override.
buzz_client_desktop_arch: "{{ 'aarch64' if ansible_architecture == 'arm64' else 'x64' }}"
buzz_client_dmg_name: "Buzz_{{ buzz_desktop_version }}_{{ buzz_client_desktop_arch }}.dmg"
buzz_client_dmg_url: "https://github.com/block/buzz/releases/download/desktop-v{{ buzz_desktop_version }}/{{ buzz_client_dmg_name }}"
# Optional. Buzz publishes no per-file checksums, so this is empty by default —
# an invented digest would be worse than none. The Developer ID signature and
# the stapled notarization ticket, checked in tasks/main.yml, are the real
# assurance. Set it once you have a digest you trust and every later download is
# checked against it.
buzz_client_dmg_sha256: ""
buzz_client_download_dir: "{{ ansible_env.HOME }}/Library/Caches/buzz-ansible"
buzz_client_app_path: /Applications/Buzz.app
buzz_client_mount_point: "/tmp/buzz-dmg-{{ buzz_desktop_version }}"
Detailed breakdown
- The architecture is derived, not configured.
aarch64on Apple Silicon andx64on Intel, read fromansible_architecture, so a mixed inventory needs no per-host override. buzz_client_dmg_sha256is empty on purpose. Buzz publishes no per-file checksums, and an invented digest would be worse than none. The Developer ID signature and the stapled ticket are the real assurance. Set it once you have a digest you trust and every later download is checked against it.- The download goes to
~/Library/Caches, not the home directory, so a ~108 MB disk image lands somewhere the system already understands as disposable. (The installed bundle is about twice that:du -shon/Applications/Buzz.appreports 216 MB.)
Create the file
touch roles/buzz_client/tasks/main.yml
Add the code: roles/buzz_client/tasks/main.yml
---
- name: Read the installed app version
ansible.builtin.command:
argv:
- /usr/bin/defaults
- read
- "{{ buzz_client_app_path }}/Contents/Info.plist"
- CFBundleShortVersionString
register: buzz_client_installed_version
changed_when: false
failed_when: false
- name: Install the Buzz desktop app
# `defaults read` exits non-zero when the bundle is absent, so a failed read
# and a version mismatch are the same condition: install.
when: buzz_client_installed_version.stdout | default('') | trim != buzz_desktop_version
block:
- name: Create the download directory
ansible.builtin.file:
path: "{{ buzz_client_download_dir }}"
state: directory
mode: "0755"
- name: Download the disk image
ansible.builtin.get_url:
url: "{{ buzz_client_dmg_url }}"
dest: "{{ buzz_client_download_dir }}/{{ buzz_client_dmg_name }}"
checksum: "{{ ('sha256:' + buzz_client_dmg_sha256) if buzz_client_dmg_sha256 | length > 0 else omit }}"
mode: "0644"
- name: Mount, verify and copy the bundle
block:
- name: Mount the disk image read-only
ansible.builtin.command:
argv:
- /usr/bin/hdiutil
- attach
- -nobrowse
- -readonly
- -mountpoint
- "{{ buzz_client_mount_point }}"
- "{{ buzz_client_download_dir }}/{{ buzz_client_dmg_name }}"
changed_when: true
# codesign writes to stderr, and its field ORDER is not stable enough to
# read positionally. Match on the field names.
- name: Read the code signature
ansible.builtin.command:
argv: [/usr/bin/codesign, -dv, --verbose=2, "{{ buzz_client_mount_point }}/Buzz.app"]
register: buzz_client_codesign
changed_when: false
- name: Assert the bundle is signed by the expected team
ansible.builtin.assert:
that:
- "'TeamIdentifier=' + buzz_expected_team_id in buzz_client_codesign.stderr"
fail_msg: >-
{{ buzz_client_dmg_name }} is not signed by team
{{ buzz_expected_team_id }}. Do not install it. Re-download from
the GitHub release page and check the URL you fetched.
quiet: true
when: buzz_verify_signature | bool
# stapler answers the notarization question directly and in one line.
# codesign's `Notarization Ticket=stapled` says the same thing, but only
# when the ticket is embedded, so this is the check that cannot be
# satisfied by a bundle that merely passed an online assessment once.
- name: Validate the stapled notarization ticket
ansible.builtin.command:
argv: [/usr/bin/xcrun, stapler, validate, "{{ buzz_client_mount_point }}/Buzz.app"]
register: buzz_client_stapler
changed_when: false
when: buzz_verify_signature | bool
- name: Ask Gatekeeper to assess the bundle
ansible.builtin.command:
argv: [/usr/sbin/spctl, -a, -vvv, "{{ buzz_client_mount_point }}/Buzz.app"]
register: buzz_client_spctl
changed_when: false
when: buzz_verify_signature | bool
- name: Assert Gatekeeper accepted it
ansible.builtin.assert:
that:
- "'accepted' in buzz_client_spctl.stderr"
- "'Notarized Developer ID' in buzz_client_spctl.stderr"
fail_msg: >-
Gatekeeper rejected {{ buzz_client_dmg_name }}. Do not reach for
`xattr -d com.apple.quarantine`: stripping quarantine from a
bundle that failed assessment defeats the check that just told you
something is wrong.
quiet: true
when: buzz_verify_signature | bool
- name: Remove any previously installed bundle
ansible.builtin.file:
path: "{{ buzz_client_app_path }}"
state: absent
# ansible.builtin.copy would walk the bundle file by file and lose the
# signature's sealed resources. `cp -R` on macOS preserves the bundle.
- name: Copy the app into /Applications
ansible.builtin.command:
argv: [/bin/cp, -R, "{{ buzz_client_mount_point }}/Buzz.app", /Applications/]
changed_when: true
always:
- name: Unmount the disk image
ansible.builtin.command:
argv: [/usr/bin/hdiutil, detach, "{{ buzz_client_mount_point }}"]
register: buzz_client_detach
changed_when: buzz_client_detach.rc == 0
failed_when: false
- name: Report where the client should point
ansible.builtin.debug:
msg: >-
Buzz {{ buzz_desktop_version }} is installed. Launch it with
BUZZ_RELAY_URL={{ buzz_relay_url }} and join with that same URL. The
identity is yours to create — see the article's Step 12.
Detailed breakdown
defaults readdecides whether to do anything. It exits non-zero when the bundle is absent, so a missing app and a version mismatch collapse into one condition. On a machine already at the pinned version the whole block skips.The
block/alwayspair guarantees the unmount. A failed signature assertion in the middle leaves no stray volume at/tmp/buzz-dmg-0.5.18, which matters because the next run would otherwise fail to mount.codesignoutput is matched by field name. It writes to stderr, and its field order varies with the signature’s contents, so reading it positionally is a trap.TeamIdentifier=plus the expected team is the assertion. This is what the role is matching against, and wherebuzz_expected_team_idcomes from — run it yourself on the installed bundle to check the value for a different vendor:codesign -dv --verbose=2 /Applications/Buzz.app 2>&1 | grep -E 'Authority|TeamIdentifier'Authority=Developer ID Application: Block, Inc. (EYF346PHUG) Authority=Developer ID Certification Authority Authority=Apple Root CA TeamIdentifier=EYF346PHUGstapler validateanswers the notarization question directly.codesignreportsNotarization Ticket=stapledwhen a ticket is embedded, butstapleris the check that is about exactly that and says so in one line.The Gatekeeper failure message tells you what not to do. Reaching for
xattr -d com.apple.quarantineon a bundle that failed assessment defeats the check that just told you something is wrong, and it is the first thing people search for.cp -Rrather thanansible.builtin.copy. The copy module would walk the bundle file by file and lose the signature’s sealed resources. This is the one place a shell command is the correct tool.The closing
debugprints the relay URL the role derived, which is the cheapest possible check that the client and relay halves agree on one authority.
Step 11: The playbook and the Makefile
The playbook is two plays because the two halves are different work on different machines. Running it whole executes them in the only order that makes sense: the relay has to exist before there is anything to join.
Create the file
touch playbooks/install-buzz.yml
Add the code: playbooks/install-buzz.yml
---
# Two plays, because the two halves are genuinely different work on genuinely
# different machines. `--limit relays` or `--limit clients` runs one of them;
# with one Mac in both groups, both plays run against it in this order, which is
# the right order: the relay has to exist before there is anything to join.
- name: Install the Buzz relay
hosts: relays
gather_facts: true
roles:
- buzz_relay
- name: Install the Buzz desktop client
hosts: clients
gather_facts: true
roles:
- buzz_client
Detailed breakdown
- Two plays, not one play with two roles.
hosts: relaysandhosts: clientslet--limittarget either half without conditionals inside the roles, and a machine in both groups gets both plays in order. gather_facts: trueon both. The client role readsansible_architectureto pick a disk image and both roles readansible_env.HOME, so neither can skip fact gathering.- No
vars_files. The vault loads on its own because it lives underinventory/group_vars/all/, which is one fewer thing to get right in a second playbook later.
The Makefile gives the common invocations names, and make with no arguments
prints them.
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
.PHONY: help ping lint syntax check install install-relay install-client \
status logs members relay-restart relay-stop \
vault-init vault-edit vault-view clean
# Override on the command line: make install LIMIT=minime
LIMIT ?= all
help: ## Show this help screen
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}'
ping: ## Check Ansible can reach every host
ansible all -m ping
lint: ## Lint the playbook and roles with ansible-lint
ansible-lint playbooks/ roles/
syntax: ## Parse the playbook without connecting to anything
ansible-playbook playbooks/install-buzz.yml --syntax-check
check: ## Dry run against $(LIMIT), showing what would change
ansible-playbook playbooks/install-buzz.yml --limit $(LIMIT) --check --diff
install: ## Install both halves (default: every host)
ansible-playbook playbooks/install-buzz.yml --limit $(LIMIT)
install-relay: ## Install the relay only
ansible-playbook playbooks/install-buzz.yml --limit relays
install-client: ## Install the desktop app only
ansible-playbook playbooks/install-buzz.yml --limit clients
# A non-interactive SSH session gets no login shell, so Homebrew's bin directory
# is not on PATH and `docker` is not found. Every ad-hoc target below exports it,
# for the same reason roles/buzz_relay sets it on every task.
RPATH = PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin
RDIR = cd ~/buzz/deploy/compose
status: ## Show Compose service status on every relay host
ansible relays -m shell -a '$(RDIR) && $(RPATH) ./run.sh status'
# Not `./run.sh logs`: that wraps `compose logs -f` and follows forever, which
# is right at a terminal and wrong in a target that has to return.
logs: ## Tail the last 40 relay log lines on every relay host
ansible relays -m shell -a '$(RDIR) && $(RPATH) docker compose logs --tail 40 relay'
members: ## List the relay membership roster
ansible relays -m shell -a '$(RDIR) && $(RPATH) ./run.sh list-members'
relay-restart: ## Recreate the relay container after an env change
ansible relays -m shell -a '$(RDIR) && $(RPATH) ./run.sh restart'
relay-stop: ## Stop the relay, keeping its volumes
ansible relays -m shell -a '$(RDIR) && $(RPATH) ./run.sh stop'
vault-init: ## Create inventory/group_vars/all/vault.yml with six generated secrets
@test -f .vault-pass || { echo "Create .vault-pass first (see the README)"; exit 1; }
@test -f inventory/group_vars/all/vault.yml && { echo "vault.yml already exists — use make vault-edit"; exit 1; } || true
@mkdir -p inventory/group_vars/all
@umask 077; printf '%s\n' \
'---' \
'# Encrypted with ansible-vault. Commit this file; .vault-pass is not' \
'# committed. These six values must NEVER rotate on an existing relay:' \
'# BUZZ_RELAY_PRIVATE_KEY is the identity every relay-authored event,' \
'# including the membership roster, was signed with.' \
"buzz_relay_private_key: \"$$(openssl rand -hex 32)\"" \
"buzz_git_hook_hmac_secret: \"$$(openssl rand -hex 32)\"" \
"buzz_postgres_password: \"$$(openssl rand -hex 32)\"" \
"buzz_redis_password: \"$$(openssl rand -hex 32)\"" \
"buzz_s3_access_key: \"$$(openssl rand -hex 12)\"" \
"buzz_s3_secret_key: \"$$(openssl rand -hex 32)\"" \
> inventory/group_vars/all/vault.yml
@ansible-vault encrypt inventory/group_vars/all/vault.yml
@echo "Created and encrypted inventory/group_vars/all/vault.yml"
vault-edit: ## Edit the encrypted vault
ansible-vault edit inventory/group_vars/all/vault.yml
vault-view: ## View the encrypted vault
ansible-vault view inventory/group_vars/all/vault.yml
clean: ## Remove the local fact cache
rm -rf .ansible/fact_cache
Detailed breakdown
.DEFAULT_GOAL := helpwith agrep/awkpair over the##comments, so the help screen is generated from the targets, not maintained beside them.RPATHon every ad-hoc target. The samePATHproblem the role solves withenvironment:applies toansible -m shell, and forgetting it producesdocker: command not foundon a host running Docker.logsusesdocker compose, not./run.sh logs. The wrapper’slogssubcommand iscompose logs -f, which follows forever. That is right at a terminal and wrong in a target that has to return.vault-initrefuses to overwrite an existing vault and requires.vault-passto exist first. Both guards protect the same thing: the six values that must never rotate.install-relayandinstall-clientare--limitshorthands, not separate playbooks, so there is one description of the work.
Confirm the help screen before going further:
make
help Show this help screen
ping Check Ansible can reach every host
lint Lint the playbook and roles with ansible-lint
syntax Parse the playbook without connecting to anything
check Dry run against $(LIMIT), showing what would change
install Install both halves (default: every host)
install-relay Install the relay only
install-client Install the desktop app only
status Show Compose service status on every relay host
logs Tail the last 40 relay log lines on every relay host
members List the relay membership roster
relay-restart Recreate the relay container after an env change
relay-stop Stop the relay, keeping its volumes
vault-init Create inventory/group_vars/all/vault.yml with six generated secrets
vault-edit Edit the encrypted vault
vault-view View the encrypted vault
clean Remove the local fact cache
Step 12: Install the app, then create your identity
The order matters and it is not the order the playbook runs in. The relay needs your public key before its first boot, and the only place that key exists is the app. So the client half goes first, you create an identity, and the relay gets the key it seeds its roster with.
Install the app:
make install-client
ansible-playbook playbooks/install-buzz.yml --limit clients
PLAY [Install the Buzz relay] **************************************************
skipping: no hosts matched
PLAY [Install the Buzz desktop client] *****************************************
TASK [Gathering Facts] *********************************************************
ok: [devbot5]
TASK [buzz_client : Read the installed app version] ****************************
ok: [devbot5]
TASK [buzz_client : Create the download directory] *****************************
changed: [devbot5]
TASK [buzz_client : Download the disk image] ***********************************
changed: [devbot5]
TASK [buzz_client : Mount the disk image read-only] ****************************
changed: [devbot5]
TASK [buzz_client : Read the code signature] ***********************************
ok: [devbot5]
TASK [buzz_client : Assert the bundle is signed by the expected team] **********
ok: [devbot5]
TASK [buzz_client : Validate the stapled notarization ticket] ******************
ok: [devbot5]
TASK [buzz_client : Ask Gatekeeper to assess the bundle] ***********************
ok: [devbot5]
TASK [buzz_client : Assert Gatekeeper accepted it] *****************************
ok: [devbot5]
TASK [buzz_client : Remove any previously installed bundle] ********************
ok: [devbot5]
TASK [buzz_client : Copy the app into /Applications] ***************************
changed: [devbot5]
TASK [buzz_client : Unmount the disk image] ************************************
changed: [devbot5]
TASK [buzz_client : Report where the client should point] **********************
ok: [devbot5] => {
"msg": "Buzz 0.5.18 is installed. Launch it with BUZZ_RELAY_URL=ws://minime.local:3000 and join with that same URL. The identity is yours to create — see the article's Step 12."
}
PLAY RECAP *********************************************************************
devbot5 : ok=14 changed=5 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Launch it and take Create a new identity key. Buzz generates the keypair, tells you it keeps the secret in your keychain — the login keychain, the same one referred to throughout this article — and offers a Reveal private key button. Reveal it and put it in a password manager: there is no recovery flow, no email reset, and no administrator who can restore you. The agent-harness setup screen that follows can be skipped.
Then open Settings → Profile → Identity details and copy the Public key.
The section is collapsed by default. You want the 64-hex form and not the
npub1… beside it, because RELAY_OWNER_PUBKEY takes hex. Put it in the
inventory:
# inventory/hosts.yml, under the relay host
buzz_relay_owner_pubkey_hex: "<your 64 hex characters>"
Getting this wrong is not subtle, and the role catches it before anything slow
happens. Abridged to the assertion itself; ansible-core also prints an
[ERROR]: line and a source excerpt above it:
TASK [buzz_relay : Assert an owner pubkey was supplied] ************************
fatal: [minime]: FAILED! => {"assertion": "buzz_relay_owner_pubkey_hex |
default('') | regex_search('^[0-9a-f]{64}$') is not none", "changed": false,
"evaluated_to": false, "msg": "buzz_relay_owner_pubkey_hex must be a
64-character lowercase hex Nostr pubkey. This is the `Public key` shown under
Settings → Profile in the desktop app, not the npub. Set it in
inventory/hosts.yml or pass -e buzz_relay_owner_pubkey_hex=<hex>."}
There is a way round the ordering if you would rather Ansible go first: the app’s opening screen also offers Use an existing key, so a keypair generated elsewhere can be imported. The order above is simpler and involves handling one fewer secret key.
Step 13: Bring up the relay
With the owner key in the inventory, the relay half is one command. A first run clones the repository and pulls about 1.2 GB of images across five services, so give it a few minutes.
make install-relay
ansible-playbook playbooks/install-buzz.yml --limit relays
PLAY [Install the Buzz relay] **************************************************
TASK [Gathering Facts] *********************************************************
ok: [minime]
TASK [buzz_relay : Assert an owner pubkey was supplied] ************************
ok: [minime]
TASK [buzz_relay : Assert the generated secrets are present] *******************
ok: [minime]
TASK [buzz_relay : Confirm the Docker daemon is reachable] *********************
ok: [minime]
TASK [buzz_relay : Look for a Compose plugin in the Homebrew plugin directory] ***
ok: [minime]
TASK [buzz_relay : Ensure the Docker CLI configuration directory exists] *******
ok: [minime]
TASK [buzz_relay : Read the existing Docker CLI configuration] *****************
ok: [minime]
TASK [buzz_relay : Merge the plugin directory into the Docker CLI configuration] ***
ok: [minime]
TASK [buzz_relay : Write the Docker CLI configuration] *************************
ok: [minime]
TASK [buzz_relay : Confirm `docker compose` now resolves] **********************
ok: [minime]
TASK [buzz_relay : Check out the Buzz deployment bundle] ***********************
changed: [minime]
TASK [buzz_relay : Render the relay environment file] **************************
changed: [minime]
TASK [buzz_relay : Assert a LAN bind address was supplied] *********************
ok: [minime]
TASK [buzz_relay : Ask Colima for the address of its VM] ***********************
ok: [minime]
TASK [buzz_relay : Use the Colima VM address as the forward target] ************
ok: [minime]
TASK [buzz_relay : Assert a forward target is known] ***************************
ok: [minime]
TASK [buzz_relay : Install socat] **********************************************
ok: [minime]
TASK [buzz_relay : Allow socat through the macOS application firewall] *********
ok: [minime]
TASK [buzz_relay : Install the port-forward LaunchDaemons] *********************
changed: [minime] => (item=v4)
changed: [minime] => (item=v6)
TASK [buzz_relay : Reload the port-forward LaunchDaemons] **********************
changed: [minime] => (item=v4)
changed: [minime] => (item=v6)
TASK [buzz_relay : Apply any pending port-forward change] **********************
RUNNING HANDLER [buzz_relay : Unload the port forward] *************************
changed: [minime] => (item=v4)
changed: [minime] => (item=v6)
RUNNING HANDLER [buzz_relay : Load the port forward] ***************************
changed: [minime] => (item=v4)
changed: [minime] => (item=v6)
TASK [buzz_relay : Start the relay] ********************************************
changed: [minime]
TASK [buzz_relay : Wait for the relay to answer where clients will reach it] ***
ok: [minime]
PLAY [Install the Buzz desktop client] *****************************************
skipping: no hosts matched
PLAY RECAP *********************************************************************
minime : ok=24 changed=7 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Three lines in that transcript need a caveat, and they all point the same way:
this host had been through the role before. Install socat reports ok because
socat was already present. Merge the plugin directory into the Docker CLI configuration and Write the Docker CLI configuration report ok for the same
reason — copy with new content is always a change, so an ok there means
cliPluginsExtraDirs was already in place. On a Mac in the state Step 6
describes, those three report changed and the total is changed=9, not
changed=7. Read the recap as the steady state it is. The image pull is real:
the five images are about 1.2 GB and most of the run’s several minutes are spent
in Start the relay.
The ok= counts include Gathering Facts. ansible.cfg caches facts for a day,
so a run against a host whose cache is still warm skips that task and reports one
fewer: ok=22 on a cold cache, ok=21 on a warm one. Both are converged runs —
only the changed= column tells you whether anything happened.
The relay logs its own view of the deployment, which is the quickest way to confirm that the authority and the owner both landed:
make logs
Abridged to the two lines that matter, with the timestamp and target fields
the relay also emits removed:
relay-1 | {"level":"INFO","message":"Deployment community ensured","host":"minime.local:3000","community":"43b71a51-c183-40c9-a6e1-7f223271f2c7"}
relay-1 | {"level":"INFO","message":"Relay owner bootstrapped","pubkey":"6757c2533f5b0…"}
host is the authority derived from RELAY_URL. If it does not read the way
you expect, stop here: every later symptom is downstream of this line.
Every key printed in this article belongs to a throwaway identity generated for the walkthrough, and is shown abridged. A public key is still a real address, and an example is exactly the kind of value that gets pasted into a config without a second thought, so use your own everywhere one appears.
The roster agrees:
make members
ansible relays -m shell -a 'cd ~/buzz/deploy/compose && PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin ./run.sh list-members'
minime | CHANGED | rc=0 >>
pubkey role added_by created_at
----------------------------------------------------------------------------------------------------------------------------------------------------------------
6757c2533f5b0… owner - 2026-08-23T13:07:49Z
added_by is empty because nobody added you. The relay seeded you from
RELAY_OWNER_PUBKEY at first boot, which is the difference between an
Ansible-provisioned relay and the hand-built one in the earlier article: there,
the first join is refused and you add yourself from the relay side. Here you
arrive as owner.
Run both halves once more to confirm the role is idempotent. The client play
skips its whole install block because the pinned version is already installed,
which is what skipped=11 counts:
make install
PLAY RECAP *********************************************************************
devbot5 : ok=2 changed=0 unreachable=0 failed=0 skipped=11 rescued=0 ignored=0
minime : ok=21 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Step 14: Prove the authority binding
Everything up to here could be true of a relay that only works from the machine it runs on. This is the check that the two-Mac split actually holds, and it runs from the workstation.
Point the app at the relay and it joins:
BUZZ_RELAY_URL=ws://minime.local:3000 open -a /Applications/Buzz.app
BUZZ_RELAY_URL only prefills the relay field on the setup screen. Once a
community has been joined the app stores it as a workspace override that takes
precedence over the environment, so setting the variable will not repoint an app
that is already joined. Switch relays from inside the app instead.
The other check is what the relay refuses. Its community is bound to
minime.local:3000, and the Host header of every connection is matched
against that authority. Four spellings of the same machine, from the
workstation, against the same IP and port:
for H in "minime.local:3000" "192.168.1.100:3000" "minime.local" "minime:3000"; do
CODE=$(curl -sS -o /dev/null -w "%{http_code}" --max-time 10 \
-H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: $(openssl rand -base64 16)" \
-H "Host: $H" http://192.168.1.100:3000/)
printf " Host: %-22s -> %s\n" "$H" "$CODE"
done
Host: minime.local:3000 -> 101
Host: 192.168.1.100:3000 -> 404
Host: minime.local -> 404
Host: minime:3000 -> 404
Only the exact authority upgrades. The IP form is refused, the short name is
refused, and dropping the port is refused — 3000 is part of the community’s
identity because the relay strips only :80 and :443. A 404 here is not a
missing route. It is the relay declining to serve a community it has not
provisioned.
The table above dials the IP literal on purpose, so that the only variable is
the Host header. That leaves the other half of Step 8 unproven: whether the
name resolves and whether both listeners are actually up. Two more lines settle
it, from the workstation:
for f in 4 6; do
printf " -%s %s\n" "$f" \
"$(curl -$f -sS -o /dev/null -w '%{http_code}' --max-time 5 http://minime.local:3000/)"
done
-4 200
-6 200
200 rather than 404 because curl sends Host: minime.local:3000 by
default, which is the authority. The point is that both families answer: -6
returning 200 is the only direct evidence in this article that the v6
listener from Step 8 is loaded and reachable. If it returns 000 while -4
returns 200, the IPv6 half of the forward is missing — the exact failure that
hangs real clients while a plain curl check passes.
This is why the hostname in buzz_public_host deserves a moment’s thought
before the first boot, not after. Nothing dramatic happens if you change
it later, which is the trap: the relay provisions a second community under the
new authority and bootstraps you into it, so you arrive in an empty workspace
while the old one sits intact in the same database, reachable only by a Host
header you are no longer sending.
If you built the preflight tool from Install and Use Buzz on macOS, it checks the whole path from the workstation in one command:
BUZZ_OWNER_SECRET=<your owner secret key> \
uv run python src/preflight.py --relay http://minime.local:3000
Buzz relay preflight — http://minime.local:3000
PASS liveness 200 ok
PASS readiness 200 {"status":"ready"}
PASS nip-11 document Buzz Relay · https://github.com/block/buzz · v0.2.1
PASS required nips NIP 1, 42, 50 present
PASS membership enforced NIP-43 advertised: roster gating is on and the relay key is stable
PASS community binding host resolved to a community; AUTH challenge received
PASS owner auth NIP-42 AUTH accepted
PASS authorized read subscription reached EOSE
All 8 checks passed.
Prove it comes back
Everything so far has been proved against a relay that was already running. The
reason for putting it on a mini in the first place was that it should survive the
machine restarting, without a login and without anyone running make start. That
is a claim about a Mac with no screen, so it is worth testing rather than
assuming.
Reboot the relay host and wait:
ssh minime.local 'sudo reboot'
sleep 90
ssh minime.local 'uptime'
10:16 up 1 min, 0 users, load averages: 2.85 1.10 0.43
0 users is the part that matters. Nobody is logged in, there is no GUI session,
and no LaunchAgent has run. Check the stack:
ssh minime.local 'docker ps --format "{{.Names}}\t{{.Status}}"'
buzz-prod-relay-1 Up 37 seconds (healthy)
buzz-prod-postgres-1 Up 37 seconds (healthy)
buzz-prod-redis-1 Up 37 seconds (healthy)
buzz-prod-minio-1 Up 37 seconds (healthy)
Four long-lived services, healthy, 37 seconds after boot. The Colima LaunchDaemon
from the prerequisites started the Docker daemon, and restart: unless-stopped
in the Buzz bundle brought the containers up behind it. Then confirm the forward
came back too, from the workstation:
for f in 4 6; do
printf " -%s %s\n" "$f" \
"$(curl -$f -sS -o /dev/null -w '%{http_code}' --max-time 6 http://minime.local:3000/)"
done
-4 200
-6 200
Both LaunchDaemons reloaded at boot on RunAtLoad, so both socat listeners are
back. That is the whole chain — daemon, containers, forward — with no
intervention at any point.
If you only have one Mac
Nothing in either role assumes the two halves are on different machines. Put one host in both groups and the same playbook runs both plays against it, in order.
Create the file
cp inventory/hosts.yml inventory/hosts.two-macs.yml # keep the original
$EDITOR inventory/hosts.yml
Add the code: inventory/hosts.yml (one-Mac variant)
---
all:
children:
relays:
hosts:
devbot5:
ansible_connection: local
ansible_user: mitch
ansible_python_interpreter: /usr/bin/python3
# Everything is on this machine, so the community authority is a
# loopback name and no bridging is involved: Docker Desktop already
# publishes container ports onto the Mac's own loopback.
buzz_public_host: localhost
buzz_relay_lan_forward: false
buzz_relay_owner_pubkey_hex: "<your 64 hex characters>"
clients:
hosts:
devbot5:
ansible_connection: local
ansible_user: mitch
ansible_python_interpreter: /usr/bin/python3
Detailed breakdown
- The same host appears under both groups. Ansible runs the relay play and
then the client play against it, which is the order that works:
groups['relays'][0]resolves to this machine, so the client half derives its relay URL from the same entry the relay half rendered into.env. buzz_relay_lan_forward: falseskips Step 8 entirely. Docker Desktop publishes container ports onto the Mac’s own loopback, so there is no VM address to bridge and no address family to get wrong.localhostbinds the community tolocalhost:3000, exactly as Install and Use Buzz on macOS does. The authority rules from Step 14 still apply:127.0.0.1:3000will be refused.ansible_connection: localis repeated under both groups because host variables are per-group-entry here. Ansible merges them for the one host, so the duplication is cosmetic rather than a second definition.
You keep the vault, the verified app install, and the idempotence, and you give up only what a laptop cannot do: be there when you are not.
Moving to a second Mac later is an inventory edit plus a migration, because the authority changes and the relay treats a new authority as a new community, not a renamed one. That is the cost of starting on the laptop, and it is better known in advance than discovered once you have history you care about.
Troubleshooting
docker: unknown command: docker compose during the play. The Compose
plugin binary is missing rather than merely undiscoverable. The role only writes
cliPluginsExtraDirs when it finds a plugin at
/opt/homebrew/lib/docker/cli-plugins/docker-compose; install it with
brew install docker-compose and run again.
The play fails at “Confirm the Docker daemon is reachable”. Check PATH
first, then the socket. The role supplies PATH, so a failure here usually
means the daemon is genuinely down: ssh <host> 'colima status'. If Colima is
running and Docker still cannot connect, check that
/var/run/docker.sock points at Colima’s socket. Whatever starts Colima at boot
on your relay host (the prerequisite above) has to restore that symlink as well;
a setup that does not will need DOCKER_HOST set for non-login shells too.
The relay is healthy and a client times out during the WebSocket handshake.
This is the address-family problem from Step 8. Confirm with
nc -6 -z <host> 3000 and nc -4 -z <host> 3000: if IPv4 connects and IPv6
does not, the v6 listener is missing. sudo launchctl print system/com.homelab.buzz-forward-3000-v6 shows whether it is loaded.
Every client gets 404 on connect. The community authority and the Host
header disagree. make logs prints the authority the relay actually
provisioned; compare it to what the client sends. Editing RELAY_URL afterwards
does not move an already-provisioned community, so fixing this on a relay that
has real history in it means exporting first.
The play reports a change on every run. If it is the “Start the relay” task,
check the changed_when against Step 9: minio-init restarts on every up by
design and must be excluded.
launchctl bootstrap fails with something other than 5. Rc 5 is EIO,
launchd’s catch-all, and is tolerated because it is what an already-loaded label
returns. Note what that costs: a malformed plist and a bad path return 5 too, so
they are tolerated as well. If a run is green and the forward still does not
work, check the plist yourself with
plutil -lint /Library/LaunchDaemons/com.homelab.buzz-forward-3000-v4.plist.
The vault’s variables are undefined. Confirm the file is at
inventory/group_vars/all/vault.yml and not group_vars/all/vault.yml. Only
the first is searched, and there is no warning for the second.
Recap
The relay lives on the always-on Mac and the app lives where you sit, which is the split Buzz’s two-program design implies. Ansible owns everything mechanical on both sides and stops at the identity, the one piece that should stay in your hands.
Four things in this build are worth carrying to the next one:
- Group hosts by job, not by hardware.
relaysandclientssurvive the day somebody runs a relay on a laptop;workstationsandheadlessdo not. - Define the authority once. The client half reading
buzz_public_hostout of the relay host’s inventory entry is what makes it impossible for the two to disagree, and disagreeing is the failure mode that produces a404from a relay that is plainly running. - A port forward has to cover every address family the name advertises. An
IPv4-only bridge passes a
curlcheck and hangs real clients, becausecurlraces the families and many clients do not. - Idempotence bugs live in the second run. A one-shot init container that
restarts on every
upwill report a change forever ifchanged_whenis not told about it.
Where to go next: the workspace is empty and the interesting part of Buzz is what runs in it. Workflows are YAML. They trigger on messages, reactions, schedules or webhooks, and can hand work to an agent under its own keypair. Every step is a signed event in the same log as the human messages, so a run is auditable by replaying it. That is the payoff that makes the relay worth standing up.