declarative relay deployment on hetzner relay-eval.waow.tech
atproto relay
14
fork

Configure Feed

Select the types of activity you want to include in your feed.

full-network atproto relay on hetzner + k3s

terraform for infra, helm for workloads, justfile to tie it together.
relay is live at wss://relay.waow.tech consuming ~600 events/sec
from ~1400 PDS hosts on a $15/mo CPX31.

includes a uv script firehose consumer using marshalx/atproto.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

zzstoatzz 4b7ea109

+700
+19
.gitignore
··· 1 + # terraform 2 + infra/.terraform/ 3 + infra/.terraform.lock.hcl 4 + infra/terraform.tfstate 5 + infra/terraform.tfstate.backup 6 + infra/*.tfvars 7 + !infra/terraform.tfvars.example 8 + 9 + # kubeconfig (fetched from server) 10 + kubeconfig.yaml 11 + 12 + # secrets 13 + *.secret 14 + .env 15 + 16 + # sqlite (from tap, local testing) 17 + *.db 18 + *.db-shm 19 + *.db-wal
+150
README.md
··· 1 + # relay.waow.tech 2 + 3 + a full-network [ATProto](https://atproto.com) relay running on a single Hetzner Cloud node with k3s. 4 + 5 + **relay endpoint:** `wss://relay.waow.tech` 6 + 7 + **health check:** [`https://relay.waow.tech/xrpc/_health`](https://relay.waow.tech/xrpc/_health) 8 + 9 + ## try it 10 + 11 + the `firehose` script consumes events from the relay using the [atproto](https://github.com/MarshalX/atproto) python SDK. it's a self-contained [uv script](https://docs.astral.sh/uv/guides/scripts/) — no virtualenv or install needed. 12 + 13 + ```bash 14 + # watch posts scroll by for 10 seconds 15 + ./firehose 16 + 17 + # run longer, filter by collection 18 + ./firehose --duration 30 19 + ./firehose --collection app.bsky.feed.like 20 + ./firehose --duration 0 # forever (ctrl-c to stop) 21 + 22 + # point at a different relay 23 + ./firehose --relay-url wss://bsky.network 24 + ``` 25 + 26 + ## what's here 27 + 28 + ``` 29 + . 30 + ├── firehose # uv script — firehose consumer 31 + ├── justfile # all commands: deploy, status, logs, etc. 32 + ├── infra/ # terraform — hetzner server + k3s 33 + │ ├── main.tf 34 + │ ├── variables.tf 35 + │ ├── versions.tf 36 + │ └── outputs.tf 37 + └── deploy/ # helm values + k8s manifests 38 + ├── relay-values.yaml 39 + ├── postgres-values.yaml 40 + ├── ingress.yaml 41 + └── cluster-issuer.yaml 42 + ``` 43 + 44 + ## why 45 + 46 + the ATProto relay is the piece of infrastructure that aggregates writes from every PDS on the network into a single firehose stream. downstream services (appviews, feed generators, labelers) subscribe to a relay instead of crawling thousands of individual servers. 47 + 48 + running one is [surprisingly cheap](https://whtwnd.com/bnewbold.net/3lo7a2a4qxg2l) — the relay binary uses modest CPU and memory, and storage requirements are manageable. the main cost driver is bandwidth, which is why Hetzner (unlimited 1 Gbps) is a good fit. 49 + 50 + this repo is a template for deploying your own. everything is declarative: terraform for the VM, helm for the workloads, a justfile to tie it together. 51 + 52 + <details> 53 + <summary><strong>deploying your own</strong></summary> 54 + 55 + ### prerequisites 56 + 57 + - [terraform](https://www.terraform.io/) (or [opentofu](https://opentofu.org/)) 58 + - [helm](https://helm.sh/) 59 + - [kubectl](https://kubernetes.io/docs/tasks/tools/) 60 + - [just](https://github.com/casey/just) 61 + - a [Hetzner Cloud](https://www.hetzner.com/cloud/) account 62 + 63 + ### setup 64 + 65 + create a `.env` file: 66 + 67 + ```bash 68 + export HCLOUD_TOKEN="your-hetzner-api-token" 69 + export RELAY_DOMAIN="relay.yourdomain.com" 70 + export RELAY_ADMIN_PASSWORD="something-secure" 71 + export POSTGRES_PASSWORD="something-else-secure" 72 + export LETSENCRYPT_EMAIL="you@example.com" 73 + ``` 74 + 75 + then: 76 + 77 + ```bash 78 + source .env 79 + 80 + just init # terraform init 81 + just infra # creates a CPX31 in Ashburn (~$15/mo) with k3s via cloud-init 82 + just kubeconfig # waits for k3s, pulls kubeconfig (~2 min) 83 + just deploy # installs cert-manager, postgresql, relay, ingress 84 + ``` 85 + 86 + point a DNS A record at the server IP (`just server-ip`) before running deploy, so the Let's Encrypt HTTP-01 challenge succeeds. 87 + 88 + after deploy, seed the relay with the network's PDS hosts: 89 + 90 + ```bash 91 + just bootstrap 92 + ``` 93 + 94 + then restart the relay pod so the slurper picks up the new hosts: 95 + 96 + ```bash 97 + kubectl rollout restart deploy/relay -n relay 98 + ``` 99 + 100 + ### available commands 101 + 102 + ```bash 103 + just status # nodes, pods, health check 104 + just logs # tail relay logs 105 + just health # curl the public health endpoint 106 + just firehose # consume the firehose (passes args through to ./firehose) 107 + just ssh # ssh into the server 108 + just destroy # tear down everything 109 + ``` 110 + 111 + </details> 112 + 113 + <details> 114 + <summary><strong>architecture</strong></summary> 115 + 116 + ### infrastructure 117 + 118 + - **Hetzner Cloud CPX31** — 8 vCPU (AMD), 16 GB RAM, 160 GB NVMe, 20 TB bandwidth @ ~$15/mo 119 + - **k3s** — single-node kubernetes, installed via cloud-init 120 + - **traefik** — ingress controller (ships with k3s) 121 + - **cert-manager** — automatic TLS via Let's Encrypt 122 + 123 + ### workloads 124 + 125 + - **relay** ([bluesky-social/indigo](https://github.com/bluesky-social/indigo)) — the ATProto relay binary, deployed via [bjw-s/app-template](https://github.com/bjw-s-labs/helm-charts) helm chart with `hostNetwork: true` for lower-overhead networking 126 + - **postgresql** — relay's backing database, deployed via [bitnami/postgresql](https://github.com/bitnami/charts/tree/main/bitnami/postgresql) helm chart 127 + 128 + ### relay specs at steady state 129 + 130 + | metric | value | 131 + |--------|-------| 132 + | storage (relay data) | ~21 GB | 133 + | storage (postgres) | ~2.4 GB | 134 + | CPU usage | 5–15% | 135 + | network throughput | ~600 events/sec typical, 2000 peak | 136 + | connected PDS hosts | ~1400 | 137 + 138 + </details> 139 + 140 + <details> 141 + <summary><strong>prior art</strong></summary> 142 + 143 + this setup draws heavily from: 144 + 145 + - [a full-network relay for $34 a month](https://whtwnd.com/bnewbold.net/3lo7a2a4qxg2l) by bryan newbold — the definitive guide 146 + - [atproto relay any% speedrun](https://pdsls.dev/at://did:plc:uu5axsmbm2or2dngy4gwchec/com.whtwnd.blog.entry/3lkubavdilf2m) — proof it runs on a raspberry pi 147 + - [running a PDS in kubernetes](https://hayden.leaflet.pub/3m4vfjkr6gc2p) — the app-template helm pattern 148 + - [firehose.network](https://sri.leaflet.pub/3mddrqk5ays27) — 3 public relays deployed globally 149 + 150 + </details>
+16
deploy/cluster-issuer.yaml
··· 1 + apiVersion: cert-manager.io/v1 2 + kind: ClusterIssuer 3 + metadata: 4 + name: letsencrypt-prod 5 + spec: 6 + acme: 7 + server: https://acme-v02.api.letsencrypt.org/directory 8 + # email set via: kubectl edit clusterissuer letsencrypt-prod 9 + # or pass --set during apply 10 + email: you@example.com 11 + privateKeySecretRef: 12 + name: letsencrypt-prod 13 + solvers: 14 + - http01: 15 + ingress: 16 + class: traefik
+24
deploy/ingress.yaml
··· 1 + apiVersion: networking.k8s.io/v1 2 + kind: Ingress 3 + metadata: 4 + name: relay 5 + namespace: relay 6 + annotations: 7 + cert-manager.io/cluster-issuer: letsencrypt-prod 8 + spec: 9 + ingressClassName: traefik 10 + tls: 11 + - hosts: 12 + - RELAY_DOMAIN_PLACEHOLDER 13 + secretName: relay-tls 14 + rules: 15 + - host: RELAY_DOMAIN_PLACEHOLDER 16 + http: 17 + paths: 18 + - path: / 19 + pathType: Prefix 20 + backend: 21 + service: 22 + name: relay 23 + port: 24 + number: 2470
+16
deploy/postgres-values.yaml
··· 1 + # bitnami/postgresql helm values 2 + auth: 3 + username: relay 4 + database: relay 5 + # password set via --set auth.password=... in justfile 6 + 7 + primary: 8 + persistence: 9 + size: 10Gi 10 + 11 + resources: 12 + requests: 13 + memory: 256Mi 14 + cpu: 100m 15 + limits: 16 + memory: 1Gi
+61
deploy/relay-values.yaml
··· 1 + # bjw-s/app-template helm values for the ATProto relay 2 + # schema: https://github.com/bjw-s-labs/helm-charts/tree/main/charts/other/app-template 3 + 4 + controllers: 5 + relay: 6 + containers: 7 + main: 8 + image: 9 + repository: ghcr.io/bluesky-social/indigo 10 + tag: relay-bf41e2ee75ab75997bf8cdd92b063c0a96db4aaf 11 + env: 12 + # DATABASE_URL injected from secret via envFrom 13 + RELAY_PERSIST_DIR: /data 14 + RELAY_REPLAY_WINDOW: "24h" 15 + RELAY_LENIENT_SYNC_VALIDATION: "true" 16 + LOG_LEVEL: "info" 17 + envFrom: 18 + - secretRef: 19 + name: relay-secret 20 + probes: 21 + liveness: &probes 22 + enabled: true 23 + custom: true 24 + spec: 25 + httpGet: 26 + path: /xrpc/_health 27 + port: &port 2470 28 + initialDelaySeconds: 10 29 + periodSeconds: 10 30 + timeoutSeconds: 3 31 + failureThreshold: 5 32 + readiness: *probes 33 + resources: 34 + requests: 35 + memory: 1Gi 36 + cpu: 500m 37 + limits: 38 + memory: 14Gi 39 + 40 + defaultPodOptions: 41 + # hostNetwork recommended for full-network relays (high packet volume). 42 + # traefik ingress still works: it routes to the pod via the service, 43 + # which resolves to the host IP when hostNetwork is true. 44 + hostNetwork: true 45 + dnsPolicy: ClusterFirstWithHostNet 46 + 47 + service: 48 + relay: 49 + controller: relay 50 + ports: 51 + http: 52 + port: *port 53 + metrics: 54 + port: 2471 55 + 56 + persistence: 57 + data: 58 + enabled: true 59 + type: persistentVolumeClaim 60 + accessMode: ReadWriteOnce 61 + size: 50Gi
+145
firehose
··· 1 + #!/usr/bin/env -S uv run --script --quiet 2 + # /// script 3 + # requires-python = ">=3.12" 4 + # dependencies = ["atproto"] 5 + # /// 6 + """ 7 + consume the firehose from an atproto relay and print events. 8 + 9 + usage: 10 + ./firehose 11 + ./firehose --duration 30 12 + ./firehose --relay-url wss://bsky.network 13 + ./firehose --collection app.bsky.feed.like 14 + """ 15 + 16 + import argparse 17 + import signal 18 + import time 19 + from collections import defaultdict 20 + 21 + from atproto import ( 22 + CAR, 23 + AtUri, 24 + FirehoseSubscribeReposClient, 25 + firehose_models, 26 + models, 27 + parse_subscribe_repos_message, 28 + ) 29 + 30 + 31 + def get_ops_by_type( 32 + commit: models.ComAtprotoSyncSubscribeRepos.Commit, 33 + collections: set[str], 34 + ) -> defaultdict: 35 + ops = defaultdict(lambda: {"created": [], "deleted": []}) 36 + 37 + car = CAR.from_bytes(commit.blocks) 38 + for op in commit.ops: 39 + uri = AtUri.from_str(f"at://{commit.repo}/{op.path}") 40 + 41 + if op.action == "create" and op.cid: 42 + raw = car.blocks.get(op.cid) 43 + if not raw: 44 + continue 45 + 46 + if collections and uri.collection not in collections: 47 + continue 48 + 49 + record = models.get_or_create(raw, strict=False) 50 + ops[uri.collection]["created"].append( 51 + {"record": record, "uri": str(uri), "author": commit.repo} 52 + ) 53 + 54 + elif op.action == "delete": 55 + ops[uri.collection]["deleted"].append({"uri": str(uri)}) 56 + 57 + return ops 58 + 59 + 60 + def main(): 61 + parser = argparse.ArgumentParser(description="consume an atproto relay firehose") 62 + parser.add_argument( 63 + "--relay-url", 64 + default="wss://relay.waow.tech", 65 + help="relay websocket url (default: wss://relay.waow.tech)", 66 + ) 67 + parser.add_argument( 68 + "--duration", 69 + type=int, 70 + default=10, 71 + help="seconds to consume (default: 10, 0 = forever)", 72 + ) 73 + parser.add_argument( 74 + "--collection", 75 + action="append", 76 + default=None, 77 + help="filter by collection (default: app.bsky.feed.post). repeatable.", 78 + ) 79 + args = parser.parse_args() 80 + 81 + collections = set(args.collection) if args.collection else {"app.bsky.feed.post"} 82 + deadline = time.time() + args.duration if args.duration > 0 else float("inf") 83 + 84 + counts: dict[str, int] = defaultdict(int) 85 + total = 0 86 + 87 + base_uri = args.relay_url.rstrip("/") + "/xrpc" 88 + client = FirehoseSubscribeReposClient(base_uri=base_uri) 89 + 90 + def stop(*_): 91 + client.stop() 92 + 93 + signal.signal(signal.SIGINT, stop) 94 + 95 + def on_message(message: firehose_models.MessageFrame) -> None: 96 + nonlocal total 97 + 98 + if time.time() >= deadline: 99 + client.stop() 100 + return 101 + 102 + commit = parse_subscribe_repos_message(message) 103 + if not isinstance(commit, models.ComAtprotoSyncSubscribeRepos.Commit): 104 + return 105 + 106 + if not commit.blocks: 107 + return 108 + 109 + ops = get_ops_by_type(commit, collections) 110 + 111 + for collection, actions in ops.items(): 112 + for item in actions["created"]: 113 + record = item["record"] 114 + author = item["author"] 115 + counts[collection] += 1 116 + total += 1 117 + 118 + if collection == "app.bsky.feed.post": 119 + text = getattr(record, "text", "") 120 + inline = text.replace("\n", " ")[:120] 121 + print(f"[{author}] {inline}") 122 + elif collection == "app.bsky.feed.like": 123 + subject = getattr(record, "subject", None) 124 + uri = getattr(subject, "uri", "?") if subject else "?" 125 + print(f"[{author}] liked {uri}") 126 + elif collection == "app.bsky.graph.follow": 127 + subject = getattr(record, "subject", "?") 128 + print(f"[{author}] followed {subject}") 129 + else: 130 + print(f"[{author}] {collection}") 131 + 132 + print(f"consuming from {args.relay_url} for {args.duration}s...") 133 + print(f"filtering: {', '.join(collections)}") 134 + print() 135 + 136 + client.start(on_message) 137 + 138 + print() 139 + print(f"--- {total} events in {args.duration}s ---") 140 + for col, count in sorted(counts.items()): 141 + print(f" {col}: {count}") 142 + 143 + 144 + if __name__ == "__main__": 145 + main()
+68
infra/main.tf
··· 1 + resource "hcloud_ssh_key" "default" { 2 + name = "${var.server_name}-key" 3 + public_key = file(pathexpand(var.ssh_public_key_path)) 4 + } 5 + 6 + resource "hcloud_firewall" "relay" { 7 + name = "${var.server_name}-fw" 8 + 9 + # ssh 10 + rule { 11 + direction = "in" 12 + protocol = "tcp" 13 + port = "22" 14 + source_ips = ["0.0.0.0/0", "::/0"] 15 + } 16 + 17 + # http 18 + rule { 19 + direction = "in" 20 + protocol = "tcp" 21 + port = "80" 22 + source_ips = ["0.0.0.0/0", "::/0"] 23 + } 24 + 25 + # https 26 + rule { 27 + direction = "in" 28 + protocol = "tcp" 29 + port = "443" 30 + source_ips = ["0.0.0.0/0", "::/0"] 31 + } 32 + 33 + # k3s api (restrict this to your IP in production) 34 + rule { 35 + direction = "in" 36 + protocol = "tcp" 37 + port = "6443" 38 + source_ips = ["0.0.0.0/0", "::/0"] 39 + } 40 + } 41 + 42 + resource "hcloud_server" "relay" { 43 + name = var.server_name 44 + server_type = var.server_type 45 + location = var.location 46 + image = "ubuntu-24.04" 47 + 48 + ssh_keys = [hcloud_ssh_key.default.id] 49 + firewall_ids = [hcloud_firewall.relay.id] 50 + 51 + user_data = <<-CLOUDINIT 52 + #cloud-config 53 + package_update: true 54 + packages: 55 + - curl 56 + - jq 57 + 58 + runcmd: 59 + # grab public IP for k3s TLS SAN 60 + - | 61 + PUBLIC_IP=$(curl -s http://169.254.169.254/hetzner/v1/metadata/public-ipv4) 62 + curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="server --tls-san $PUBLIC_IP" sh - 63 + # wait for k3s to be ready 64 + - while ! kubectl get nodes >/dev/null 2>&1; do sleep 2; done 65 + # signal cloud-init is done 66 + - touch /run/k3s-ready 67 + CLOUDINIT 68 + }
+9
infra/outputs.tf
··· 1 + output "server_ip" { 2 + description = "Public IP of the relay server" 3 + value = hcloud_server.relay.ipv4_address 4 + } 5 + 6 + output "ssh_command" { 7 + description = "SSH into the server" 8 + value = "ssh root@${hcloud_server.relay.ipv4_address}" 9 + }
+29
infra/variables.tf
··· 1 + variable "hcloud_token" { 2 + description = "Hetzner Cloud API token" 3 + type = string 4 + sensitive = true 5 + } 6 + 7 + variable "ssh_public_key_path" { 8 + description = "Path to SSH public key" 9 + type = string 10 + default = "~/.ssh/id_ed25519.pub" 11 + } 12 + 13 + variable "server_type" { 14 + description = "Hetzner server type (cpx31 = 8 vCPU, 16 GB RAM, 160 GB disk)" 15 + type = string 16 + default = "cpx31" 17 + } 18 + 19 + variable "location" { 20 + description = "Hetzner datacenter location (ash = Ashburn VA, fsn1 = Germany, hel1 = Finland)" 21 + type = string 22 + default = "ash" 23 + } 24 + 25 + variable "server_name" { 26 + description = "Name for the server" 27 + type = string 28 + default = "relay" 29 + }
+14
infra/versions.tf
··· 1 + terraform { 2 + required_version = ">= 1.0" 3 + 4 + required_providers { 5 + hcloud = { 6 + source = "hetznercloud/hcloud" 7 + version = "~> 1.49" 8 + } 9 + } 10 + } 11 + 12 + provider "hcloud" { 13 + token = var.hcloud_token 14 + }
+149
justfile
··· 1 + # ATProto relay deployment 2 + # required env vars: HCLOUD_TOKEN, RELAY_DOMAIN, RELAY_ADMIN_PASSWORD, POSTGRES_PASSWORD, LETSENCRYPT_EMAIL 3 + 4 + export KUBECONFIG := justfile_directory() / "kubeconfig.yaml" 5 + 6 + # show available recipes 7 + default: 8 + @just --list 9 + 10 + # --- infrastructure --- 11 + 12 + # initialize terraform 13 + init: 14 + terraform -chdir=infra init 15 + 16 + # create the hetzner server with k3s 17 + infra: 18 + terraform -chdir=infra apply -var="hcloud_token=$HCLOUD_TOKEN" 19 + 20 + # destroy all infrastructure 21 + destroy: 22 + terraform -chdir=infra destroy -var="hcloud_token=$HCLOUD_TOKEN" 23 + 24 + # get the server IP from terraform 25 + server-ip: 26 + @terraform -chdir=infra output -raw server_ip 27 + 28 + # ssh into the server 29 + ssh: 30 + ssh root@$(just server-ip) 31 + 32 + # --- cluster access --- 33 + 34 + # fetch kubeconfig from the server (run after cloud-init finishes, ~2 min) 35 + kubeconfig: 36 + #!/usr/bin/env bash 37 + set -euo pipefail 38 + IP=$(just server-ip) 39 + echo "fetching kubeconfig from $IP..." 40 + 41 + # wait for k3s to be ready 42 + until ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new root@$IP test -f /run/k3s-ready 2>/dev/null; do 43 + echo " waiting for k3s..." 44 + sleep 5 45 + done 46 + 47 + scp root@$IP:/etc/rancher/k3s/k3s.yaml kubeconfig.yaml 48 + # replace localhost with public IP 49 + if [[ "$(uname)" == "Darwin" ]]; then 50 + sed -i '' "s|127.0.0.1|$IP|g" kubeconfig.yaml 51 + else 52 + sed -i "s|127.0.0.1|$IP|g" kubeconfig.yaml 53 + fi 54 + chmod 600 kubeconfig.yaml 55 + echo "kubeconfig written to kubeconfig.yaml" 56 + kubectl get nodes 57 + 58 + # --- deployment --- 59 + 60 + # add required helm repos 61 + helm-repos: 62 + helm repo add bjw-s https://bjw-s-labs.github.io/helm-charts 63 + helm repo add bitnami https://charts.bitnami.com/bitnami 64 + helm repo add jetstack https://charts.jetstack.io 65 + helm repo update 66 + 67 + # deploy everything to the cluster 68 + deploy: helm-repos 69 + #!/usr/bin/env bash 70 + set -euo pipefail 71 + 72 + : "${RELAY_DOMAIN:?set RELAY_DOMAIN}" 73 + : "${RELAY_ADMIN_PASSWORD:?set RELAY_ADMIN_PASSWORD}" 74 + : "${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}" 75 + : "${LETSENCRYPT_EMAIL:?set LETSENCRYPT_EMAIL}" 76 + 77 + echo "==> creating namespace" 78 + kubectl create namespace relay --dry-run=client -o yaml | kubectl apply -f - 79 + 80 + echo "==> installing cert-manager" 81 + helm upgrade --install cert-manager jetstack/cert-manager \ 82 + --namespace cert-manager --create-namespace \ 83 + --set crds.enabled=true \ 84 + --wait 85 + 86 + echo "==> applying cluster issuer" 87 + sed "s|you@example.com|$LETSENCRYPT_EMAIL|g" deploy/cluster-issuer.yaml \ 88 + | kubectl apply -f - 89 + 90 + echo "==> installing postgresql" 91 + helm upgrade --install relay-db bitnami/postgresql \ 92 + --namespace relay \ 93 + --values deploy/postgres-values.yaml \ 94 + --set auth.password="$POSTGRES_PASSWORD" \ 95 + --wait 96 + 97 + echo "==> creating relay secret" 98 + kubectl create secret generic relay-secret \ 99 + --namespace relay \ 100 + --from-literal=DATABASE_URL="postgres://relay:${POSTGRES_PASSWORD}@relay-db-postgresql.relay.svc.cluster.local:5432/relay" \ 101 + --from-literal=RELAY_ADMIN_PASSWORD="$RELAY_ADMIN_PASSWORD" \ 102 + --dry-run=client -o yaml | kubectl apply -f - 103 + 104 + echo "==> installing relay" 105 + helm upgrade --install relay bjw-s/app-template \ 106 + --namespace relay \ 107 + --values deploy/relay-values.yaml \ 108 + --wait --timeout 5m 109 + 110 + echo "==> applying ingress" 111 + sed "s|RELAY_DOMAIN_PLACEHOLDER|$RELAY_DOMAIN|g" deploy/ingress.yaml \ 112 + | kubectl apply -f - 113 + 114 + echo "" 115 + echo "done. point DNS for $RELAY_DOMAIN -> $(just server-ip)" 116 + echo "then check: curl https://$RELAY_DOMAIN/xrpc/_health" 117 + 118 + # seed the relay with hosts from the network 119 + bootstrap: 120 + kubectl exec -n relay deploy/relay -- relay pull-hosts --relay-host https://relay1.us-west.bsky.network 121 + 122 + # --- status --- 123 + 124 + # check the state of everything 125 + status: 126 + @echo "==> nodes" 127 + @kubectl get nodes 128 + @echo "" 129 + @echo "==> pods" 130 + @kubectl get pods -n relay 131 + @echo "" 132 + @echo "==> relay health (in-cluster)" 133 + @kubectl exec -n relay deploy/relay -- curl -sf localhost:2470/xrpc/_health 2>/dev/null || echo "(relay not ready yet)" 134 + 135 + # tail relay logs 136 + logs: 137 + kubectl logs -n relay deploy/relay -f 138 + 139 + # check relay health via public endpoint 140 + health: 141 + #!/usr/bin/env bash 142 + : "${RELAY_DOMAIN:?set RELAY_DOMAIN}" 143 + curl -sf "https://$RELAY_DOMAIN/xrpc/_health" | jq . 144 + 145 + # --- firehose --- 146 + 147 + # consume the firehose (default: 10s of bsky posts) 148 + firehose *args: 149 + ./firehose {{ args }}