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-vault file, 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

RequirementVersion used hereNotes
macOS on the controller26.5.2, Apple SiliconThe Mac you run ansible-playbook from
macOS on the relay host26.6.1, Apple SiliconHeadless Mac mini, reachable over SSH
Command Line Tools on both Macsxcode-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 hostA LaunchDaemon, not brew servicesbrew 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
Ansibleansible-core 2.21.1brew install ansible
ansible-lint26.6.0Optional, but make lint uses it
A Docker daemon on the relay hostDocker 28.4.0 via Colima 0.10.1Docker Desktop needs a GUI session, so a headless Mac wants Colima
Docker Compose5.1.1 on the relay hostMust resolve as docker compose, not only docker-compose; Step 6 is about why
Docker Engine on the workstation29.7.2 (Docker Desktop 4.87.0)Only if you also run a relay there
Buzz desktop0.5.18Installed by the client role
Buzz relay imageghcr.io/block/buzz:main, reporting v0.2.1Pulled by the relay role
SSH to the relay hostKey-based, with passwordless sudoThe port-forward tasks write to /Library/LaunchDaemons
A fixed LAN address for the relay hostStatic, or a DHCP reservationThe 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:

HalfOwnerWhy
Relay checkout, secrets, environment, port forward, startupAnsiblePure configuration, no session needed
Desktop app bundle: download, signature, notarization, installAnsibleMechanical, and the checks are the part people skip
Nostr identity: keypair, keychain, backupYouGUI 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-pass is 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.
  • .env and *.env because 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.yml fixes the inventory path so every ansible and ansible-playbook invocation agrees without a flag. It also makes inventory/ the directory Ansible searches for group_vars, which matters in Step 4.
  • host_key_checking = False is 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-pass means ansible-playbook decrypts the vault without prompting and without --vault-password-file on every command. The file it points at is the one .gitignore excludes.
  • become = False globally, 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/LaunchDaemons needs 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 = True cuts one SSH round trip per task. It is safe here because no target uses requiretty in sudoers.

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: local on 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.local rather than a short name or an address. mDNS resolves .local on 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/python3 on both. The system python3 is 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_host is 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_hex is 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_forward and buzz_relay_lan_bind_address turn 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_authority reaches into the relay host’s inventory entry. hostvars[groups['relays'][0]].buzz_public_host is 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 :80 and :443, as scheme defaults, so 3000 is part of the community’s identity. Every refusal in Step 14 comes from a client disagreeing with this line.
  • ws, not wss. 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. :main and the main branch 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 that ansible.builtin.git reports a change whenever upstream moves, so the zero-change second run holds only until the next push to main. Pin buzz_image to a :sha-<7> or release tag and buzz_repo_version to 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: true is 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: true governs the HTTP/NIP-98 bridge rather than reads. With it false, a dev-mode X-Pubkey header 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: true lets the relay run its own schema migrations on first boot. Without it, an empty database waits for a hand-run buzz-admin migrate on 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_id is Block’s Developer ID team identifier, read off codesign output. Step 10 asserts on it, so a bundle from anywhere else fails the play instead of landing in /Applications.
  • buzz_verify_signature exists 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 to main.yml. That is what makes it load for every play with no vars_files entry and no ordering to remember. It is also why main.yml is a file inside an all/ directory rather than an all.yml beside it: a group cannot be both.
  • openssl rand -hex 24 for the passphrase, written straight to a file .gitignore already excludes. It never appears in shell history as a literal.
  • vault-init refuses 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_URL provisions the community. The relay takes its authority as the community host the first time it boots and matches the Host header of every later connection against it. Everything else in the identity block is downstream of this line.
  • BUZZ_DOMAIN is 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_DOMAIN is read by nothing in the repository at all, and is present only for parity with .env.example.
  • RELAY_OWNER_PUBKEY has no BUZZ_ 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 to owner and demotes any other owner in the community to admin (the roster has three roles: owner, admin, and member). Changing it and restarting is therefore how you transfer ownership, and upstream says so — add-member refuses the owner role and points at this variable instead.
  • The six vault values are interpolated directly, and the task that renders this template sets no_log: true so the diff never reaches the terminal or a CI log.
  • The dev and Caddy port variables from .env.example are omitted. They are read only by compose.dev.yml and compose.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

  • item is the address family, v4 or v6, 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-LISTEN binds one interface. Naming the LAN address rather than 0.0.0.0 makes the exposure a deliberate act on one interface. The IPv6 listener uses ipv6only=1 on 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 a bind= to the host’s own address if you want the exposure to match.
  • fork so each connection gets its own child. Without it socat serves one connection and exits, and KeepAlive turns that into a restart loop.
  • KeepAlive here, 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. RunAtLoad brings 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_dir points at the repository, and the compose directory is derived from it. deploy/compose 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, and the two are easy to confuse.
  • buzz_relay_env_path is not optional. A non-interactive SSH session does not source a login shell, so Homebrew’s bin is absent from PATH and docker is 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_forward defaults 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_profile is named, not assumed. Colima supports several VMs side by side and colima ls --json prints 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_timeout guards a specific failure, not startup in general. run.sh start already 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 none rather than the bare filter, because regex_search returns None on no match and ansible-core 2.21 refuses a conditional whose result is not a boolean instead of treating None as false. The failure message is specific about wanting the hex form rather than the npub, because that is the mistake people actually make.
  • The secrets assert checks lengths, never values. assert prints the failing expression, not what it evaluated to, so this stays safe without no_log — and keeping no_log off means the guidance in fail_msg is actually visible when it fires.
  • The Compose plugin block reads the existing config and merges. slurp with failed_when: false handles a host that has no config.json at all, and the combine with union adds the plugin directory without disturbing auths or currentContext. The verification task afterwards is what turns “we wrote a file” into “the plugin resolves”.
  • ansible.builtin.git with force: false will not throw away local modifications in the checkout. The environment file it renders lives inside that checkout, and the repository’s own .gitignore already 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 --json is 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 bootstrap tolerates 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 is EIO numerically, not EBUSY; the number is what matters here, the name is not.) Anything else still fails.
  • The changed_when on the start task excludes minio-init. This is the one piece of the role whose behavior only shows up on a second run. minio-init 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. A naive match on Started reports a change forever. So the match is on Started|Created|Recreated with minio-init filtered out first; a converged stack reports only Running and Healthy for the four long-lived services, and therefore matches nothing.
  • meta: flush_handlers runs 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 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 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

  • bootout then bootstrap, not kickstart. launchctl kickstart -k restarts 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 discovers buzz_relay_docker_host_address from colima 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. Only bootout followed by bootstrap loads a definition from disk.
  • bootout tolerates any exit code, because the handler has to work on a job nobody has bootstrapped yet. bootstrap keeps the rc 5 tolerance for the already-loaded case.
  • Both handlers share a listen topic, so one notify runs 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. aarch64 on Apple Silicon and x64 on Intel, read from ansible_architecture, so a mixed inventory needs no per-host override.
  • buzz_client_dmg_sha256 is 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 -sh on /Applications/Buzz.app reports 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 read decides 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/always pair 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.

  • codesign output 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 where buzz_expected_team_id comes 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=EYF346PHUG
    
  • stapler validate answers the notarization question directly. codesign reports Notarization Ticket=stapled when a ticket is embedded, but stapler is 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.quarantine on 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 -R rather than ansible.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 debug prints 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: relays and hosts: clients let --limit target either half without conditionals inside the roles, and a machine in both groups gets both plays in order.
  • gather_facts: true on both. The client role reads ansible_architecture to pick a disk image and both roles read ansible_env.HOME, so neither can skip fact gathering.
  • No vars_files. The vault loads on its own because it lives under inventory/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 := help with a grep/awk pair over the ## comments, so the help screen is generated from the targets, not maintained beside them.
  • RPATH on every ad-hoc target. The same PATH problem the role solves with environment: applies to ansible -m shell, and forgetting it produces docker: command not found on a host running Docker.
  • logs uses docker compose, not ./run.sh logs. The wrapper’s logs subcommand is compose logs -f, which follows forever. That is right at a terminal and wrong in a target that has to return.
  • vault-init refuses to overwrite an existing vault and requires .vault-pass to exist first. Both guards protect the same thing: the six values that must never rotate.
  • install-relay and install-client are --limit shorthands, 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: false skips 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.
  • localhost binds the community to localhost:3000, exactly as Install and Use Buzz on macOS does. The authority rules from Step 14 still apply: 127.0.0.1:3000 will be refused.
  • ansible_connection: local is 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. relays and clients survive the day somebody runs a relay on a laptop; workstations and headless do not.
  • Define the authority once. The client half reading buzz_public_host out 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 a 404 from a relay that is plainly running.
  • A port forward has to cover every address family the name advertises. An IPv4-only bridge passes a curl check and hangs real clients, because curl races the families and many clients do not.
  • Idempotence bugs live in the second run. A one-shot init container that restarts on every up will report a change forever if changed_when is 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.