diff --git a/.github/workflows/helm-chart.yml b/.github/workflows/helm-chart.yml
new file mode 100644
index 0000000000..4d6d476175
--- /dev/null
+++ b/.github/workflows/helm-chart.yml
@@ -0,0 +1,97 @@
+name: helm chart
+
+on:
+  workflow_dispatch:
+  push:
+    branches: [main]
+    paths:
+      - "deploy/charts/buzz/**"
+      - ".github/workflows/helm-chart.yml"
+      - "ct.yaml"
+  pull_request:
+    paths:
+      - "deploy/charts/buzz/**"
+      - ".github/workflows/helm-chart.yml"
+      - "ct.yaml"
+
+jobs:
+  lint-and-unittest:
+    name: lint + unittest + render matrix
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+
+      - name: Set up Helm
+        uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4  # v4.3.1
+        with:
+          version: v3.16.4
+
+      - name: Install helm-unittest plugin
+        run: helm plugin install --version 0.8.2 https://github.com/helm-unittest/helm-unittest
+
+      - name: Set up Python (for chart-testing)
+        uses: actions/setup-python@v5
+        with:
+          python-version: "3.12"
+
+      - name: Set up chart-testing
+        uses: helm/chart-testing-action@0d28d3144d3a25ea2cc349d6e59901c4ff469b3b  # v2.7.0
+
+      - name: Build chart dependencies
+        run: helm dependency build deploy/charts/buzz
+
+      - name: ct lint
+        run: ct lint --config ct.yaml --all
+
+      - name: helm-unittest
+        run: helm unittest deploy/charts/buzz
+
+      - name: helm template (render every fixture)
+        run: |
+          set -euo pipefail
+          for f in deploy/charts/buzz/ci/*-values.yaml deploy/charts/buzz/tests/fixtures/*-values.yaml; do
+            echo "::group::render $f"
+            helm template buzz deploy/charts/buzz -f "$f"
+            echo "::endgroup::"
+          done
+
+  install-on-kind:
+    # Full end-to-end install requires the public ghcr.io/block/buzz image to
+    # exist and to embed Max's startup migrations. Runs only after Sami's
+    # image PR merges (`workflow_dispatch`) or on a schedule once main carries
+    # both prerequisites. Render/lint above is the per-PR signal.
+    name: install on kind (gated)
+    if: github.event_name == 'workflow_dispatch'
+    runs-on: ubuntu-latest
+    needs: lint-and-unittest
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+
+      - name: Set up Helm
+        uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4  # v4.3.1
+        with:
+          version: v3.16.4
+
+      - name: Set up Python (for chart-testing)
+        uses: actions/setup-python@v5
+        with:
+          python-version: "3.12"
+
+      - name: Set up chart-testing
+        uses: helm/chart-testing-action@0d28d3144d3a25ea2cc349d6e59901c4ff469b3b  # v2.7.0
+
+      - name: Create kind cluster
+        uses: helm/kind-action@0025e74a8c7512023d06dc019c617aa3cf561fde  # v1.10.0
+        with:
+          version: v0.24.0
+          node_image: kindest/node:v1.31.0
+
+      - name: Build chart dependencies
+        run: helm dependency build deploy/charts/buzz
+
+      - name: ct install (quickstart profile)
+        run: ct install --config ct.yaml --charts deploy/charts/buzz --helm-extra-args "--timeout 600s"
diff --git a/.gitignore b/.gitignore
index 9a7436439e..3fdd5d7c3e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -63,3 +63,6 @@ identity.key
 
 # mesh-llm build cache
 .cache/
+
+# Helm dependency tarballs — regenerable from Chart.lock via `helm dependency build`
+deploy/charts/*/charts/*.tgz
diff --git a/ct.yaml b/ct.yaml
new file mode 100644
index 0000000000..23d1467a15
--- /dev/null
+++ b/ct.yaml
@@ -0,0 +1,9 @@
+chart-dirs:
+  - deploy/charts
+chart-repos: []
+helm-extra-args: --timeout 600s
+target-branch: main
+# Skip OCI subchart fetches that need docker.io anonymous access — ct
+# resolves these locally via `helm dependency build` in the workflow.
+remote: origin
+validate-maintainers: false
diff --git a/deploy/charts/buzz/Chart.lock b/deploy/charts/buzz/Chart.lock
new file mode 100644
index 0000000000..9987bcf850
--- /dev/null
+++ b/deploy/charts/buzz/Chart.lock
@@ -0,0 +1,9 @@
+dependencies:
+- name: postgres
+  repository: oci://registry-1.docker.io/cloudpirates
+  version: 0.19.5
+- name: redis
+  repository: oci://registry-1.docker.io/cloudpirates
+  version: 0.30.3
+digest: sha256:9c0df32008f782064104ecec8552f348841f120852d49ace66966a56ba904348
+generated: "2026-06-11T15:45:41.764379-04:00"
diff --git a/deploy/charts/buzz/Chart.yaml b/deploy/charts/buzz/Chart.yaml
new file mode 100644
index 0000000000..0a1a32d3c6
--- /dev/null
+++ b/deploy/charts/buzz/Chart.yaml
@@ -0,0 +1,42 @@
+apiVersion: v2
+name: buzz
+description: |
+  Buzz — a Nostr-based messaging platform for human–agent collaboration.
+
+  A single relay binary serving WebSocket + REST + web UI, backed by
+  PostgreSQL, Redis, and Typesense. Configurable for single-node evaluation
+  (subcharts on) and HA production (external services, existingSecret).
+type: application
+version: 0.1.0
+appVersion: "0.1.0"
+home: https://github.com/block/buzz
+sources:
+  - https://github.com/block/buzz
+keywords:
+  - nostr
+  - relay
+  - messaging
+  - websocket
+  - chat
+maintainers:
+  - name: Block
+    url: https://github.com/block
+annotations:
+  artifacthub.io/changes: |
+    - kind: added
+      description: Initial chart for Buzz relay.
+  artifacthub.io/license: Apache-2.0
+
+# Optional eval-only subcharts. Production deploys disable both and point
+# externalPostgresql / externalRedis (or secrets.existingSecret) at managed
+# services.
+dependencies:
+  - name: postgres
+    version: "0.19.x"
+    repository: oci://registry-1.docker.io/cloudpirates
+    condition: postgresql.enabled
+    alias: postgresql
+  - name: redis
+    version: "0.30.x"
+    repository: oci://registry-1.docker.io/cloudpirates
+    condition: redis.enabled
diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md
new file mode 100644
index 0000000000..ab08200b27
--- /dev/null
+++ b/deploy/charts/buzz/README.md
@@ -0,0 +1,112 @@
+# Buzz Helm Chart
+
+[Buzz](https://github.com/block/buzz) is a Nostr-based messaging platform for human–agent collaboration: a single relay binary serving WebSocket + REST + web UI, backed by PostgreSQL, Redis, Typesense, and S3-compatible object storage.
+
+This chart has two operating profiles selected by values:
+
+| Profile | When | What you get |
+|---|---|---|
+| **Production** (default) | Self-hosted multi-tenant, regulated, or GitOps-managed | External managed Postgres/Redis/Typesense/S3, `secrets.existingSecret:`, no chart-side autogen, HA-capable (`replicaCount ≥ 2`) |
+| **Quickstart** (eval) | Eval, single-node, one-off demo | In-cluster Postgres + Redis + MinIO + Typesense subcharts/Deployments, chart auto-generates relay + service secrets, single replica |
+
+## Quickstart (eval only)
+
+```sh
+helm install buzz oci://ghcr.io/block/buzz/charts/buzz --version 0.1.0 \
+  --create-namespace --namespace buzz \
+  --set quickstart=true \
+  --set postgresql.enabled=true \
+  --set redis.enabled=true \
+  --set minio.enabled=true \
+  --set typesense.enabled=true \
+  --set relayUrl=wss://buzz.example.com \
+  --set ownerPubkey=<64-char-hex-pubkey>
+```
+
+This brings up **everything in-cluster** — Postgres, Redis, MinIO (with its
+bucket created by a post-install Job), and Typesense — and composes the relay's
+`BUZZ_S3_ENDPOINT` / `TYPESENSE_URL` plus autogenerated credentials
+automatically. No external services required. The `quickstart=true` flag is an
+intent marker surfaced in NOTES.txt; the bundled services are opted in via the
+four `*.enabled` flags above (see `ci/quickstart-values.yaml` for the exact set
+CI installs). Eval-only: every bundled service is a single replica with no HA.
+
+## Production (GitOps)
+
+The chart is designed for ArgoCD and Flux. Both render charts with `helm template`, in which mode Helm's `lookup` function returns empty — any chart-side `randAlphaNum` call would regenerate secrets on every sync. The chart-managed Secret path is **only** safe for `helm install` / `helm upgrade`.
+
+Production deploys MUST use `secrets.existingSecret:`. The Secret is consumed for any keys present and ignored for keys missing — extras are harmless.
+
+See:
+
+- [`examples/argocd-app.yaml`](examples/argocd-app.yaml) — ArgoCD Application
+- [`examples/flux-helmrelease.yaml`](examples/flux-helmrelease.yaml) — Flux HelmRelease v2
+- [`examples/secret-sample.yaml`](examples/secret-sample.yaml) — Secret schema
+
+## Required inputs
+
+| Key | What | When required |
+|---|---|---|
+| `relayUrl` | Public `wss://` URL clients connect to | Always |
+| `ownerPubkey` | 64-char lowercase hex Nostr pubkey of the relay operator | When `relay.requireRelayMembership=true` (default) |
+| `secrets.existingSecret` | Name of pre-created Secret | Production / GitOps |
+| `externalPostgresql.url` / `externalRedis.url` / `typesense.url` / `s3.endpoint` | External service URLs | Production — when the matching bundled service is disabled (the default) |
+
+The chart fails at `helm install` / `helm template` time with a clear message if any of these are missing or malformed (see `templates/_validate.tpl`).
+
+## HA (production)
+
+`replicaCount > 1` hard-requires both:
+
+- Redis (`redis.enabled=true`, `externalRedis.url`, or `REDIS_URL` in `existingSecret`) — for `buzz-pubsub` fan-out
+- ReadWriteMany git PVC — `persistence.git.accessMode: ReadWriteMany` with a RWX storage class (e.g. `efs-sc` on AWS, `azurefile-csi` on Azure)
+
+The chart **template-fails** if either invariant is broken. No silent degradation.
+
+## Upgrades
+
+Schema migrations are embedded in the relay binary via `sqlx::migrate!` and run at startup, gated by `BUZZ_AUTO_MIGRATE` (default `true`). Multiple replicas race-safely behind a Postgres advisory lock. `helm upgrade` is the entire upgrade procedure.
+
+If you prefer decoupling migrations from serving, set `migrate.autoMigrate=false`. **In that mode the chart does not run migrations for you** — you own running `buzz-admin migrate` (separate Pod / one-shot Job) against the database before every `helm install` / `helm upgrade`. Readiness probes only verify DB connectivity, not schema freshness, so a pod will appear healthy against an unmigrated schema and fail under load. A pre-upgrade Helm Job for this is on the chart roadmap; the values knob `migrate.preUpgradeJob.enabled` is reserved.
+
+## Backups
+
+Save these. Losing any of them is data loss. See NOTES.txt printed by `helm install` for the live list:
+
+1. `BUZZ_RELAY_PRIVATE_KEY` — relay identity. Rotating it = new identity (federation peers will not recognize the relay).
+2. PostgreSQL database — the canonical event store.
+3. S3 bucket — media blobs (chart default bucket: `buzz-media`).
+4. Git PVC — repo on-disk state served by the relay's git endpoint.
+5. Owner private key — held by the operator, not by this chart. Restore by re-installing with the same `ownerPubkey`.
+
+## Honest limitations (v1)
+
+- **Bundled MinIO + Typesense are eval-only.** The quickstart profile runs an
+  in-cluster MinIO and Typesense (single replica, no HA, `lookup`-autogenerated
+  credentials) so the relay starts with zero external services. Production
+  leaves `minio.enabled` / `typesense.enabled` off and points `s3.endpoint` +
+  `typesense.url` (or `BUZZ_S3_*` / `TYPESENSE_URL` in `existingSecret`) at
+  managed S3-compatible storage and Typesense. The bundled Deployments are not
+  GitOps-safe and are not intended for production traffic.
+- **Minimal-mode is not yet supported.** The relay's `BUZZ_PUBSUB=local` /
+  `BUZZ_SEARCH=pg` / filesystem media paths are upstream work in progress —
+  even quickstart currently stands up real Redis, Typesense, and S3 rather than
+  the relay's single-node fallbacks.
+- **OCI publish to GHCR + cosign signing** is a follow-up PR. For now, install the chart from source: `helm install buzz ./deploy/charts/buzz` after cloning the repo.
+
+## Development
+
+```sh
+# Render every fixture
+for f in ci/*-values.yaml tests/fixtures/*-values.yaml; do
+  helm template buzz . -f "$f" >/dev/null && echo "ok: $f"
+done
+
+# Unit tests
+helm plugin install https://github.com/helm-unittest/helm-unittest
+helm unittest .
+
+# Lint
+helm dependency build .
+ct lint --config ../../../ct.yaml --charts .
+```
diff --git a/deploy/charts/buzz/ci/quickstart-values.yaml b/deploy/charts/buzz/ci/quickstart-values.yaml
new file mode 100644
index 0000000000..71d4e76a4f
--- /dev/null
+++ b/deploy/charts/buzz/ci/quickstart-values.yaml
@@ -0,0 +1,21 @@
+# Quickstart / eval: subcharts on, autogen secrets, single replica.
+# This is the scenario `ct install` exercises against a kind cluster — it
+# spins up postgres + redis + minio + typesense in-cluster so the relay can
+# actually start and pass its S3 conformance probe.
+quickstart: true
+postgresql:
+  enabled: true
+redis:
+  enabled: true
+minio:
+  enabled: true
+typesense:
+  enabled: true
+relayUrl: wss://buzz.test.local
+ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000001"
+relay:
+  # Don't enforce membership in CI — we're testing the chart renders and the
+  # Pod starts, not relay business logic.
+  requireRelayMembership: false
+podDisruptionBudget:
+  enabled: false
diff --git a/deploy/charts/buzz/examples/argocd-app.yaml b/deploy/charts/buzz/examples/argocd-app.yaml
new file mode 100644
index 0000000000..612f6e4b2b
--- /dev/null
+++ b/deploy/charts/buzz/examples/argocd-app.yaml
@@ -0,0 +1,76 @@
+# ArgoCD Application — GitOps-safe Buzz install.
+#
+# Prerequisite: a Secret named `buzz-secrets` in namespace `buzz` containing
+# (any subset of) the keys consumed by `secrets.existingSecret`. See
+# `secret-sample.yaml` for the schema.
+#
+# Why `existingSecret` and not chart autogen: ArgoCD renders manifests with
+# `helm template`, in which mode Helm's `lookup` function returns empty and
+# any chart-side `randAlphaNum` call regenerates on every sync. The
+# chart-managed Secret path is for `helm install` / `helm upgrade` only.
+apiVersion: argoproj.io/v1alpha1
+kind: Application
+metadata:
+  name: buzz
+  namespace: argocd
+spec:
+  project: default
+  source:
+    repoURL: oci://ghcr.io/block/buzz/charts
+    chart: buzz
+    targetRevision: 0.1.0
+    helm:
+      releaseName: buzz
+      values: |
+        relayUrl: wss://buzz.example.com
+        ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"  # replace
+        replicaCount: 3
+
+        secrets:
+          existingSecret: buzz-secrets
+
+        externalPostgresql:
+          # DATABASE_URL also lives in buzz-secrets; this is here only if you
+          # prefer the URL stored in values vs. the Secret. Pick one.
+          url: ""
+
+        externalRedis:
+          url: ""
+
+        typesense:
+          url: "http://typesense.buzz.svc.cluster.local:8108"
+          # apiKey lives in buzz-secrets
+
+        s3:
+          endpoint: "https://s3.us-east-1.amazonaws.com"
+          bucket: "buzz-media"
+          # accessKey / secretKey live in buzz-secrets
+
+        persistence:
+          git:
+            enabled: true
+            accessMode: ReadWriteMany     # required: replicaCount > 1
+            storageClass: efs-sc           # provider-specific RWX class
+            size: 50Gi
+
+        ingress:
+          enabled: true
+          className: nginx
+          annotations:
+            cert-manager.io/cluster-issuer: letsencrypt-prod
+            # WebSocket: long-lived; raise proxy timeouts.
+            nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
+            nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
+          tls:
+            - hosts: [buzz.example.com]
+              secretName: buzz-tls
+  destination:
+    server: https://kubernetes.default.svc
+    namespace: buzz
+  syncPolicy:
+    automated:
+      prune: true
+      selfHeal: true
+    syncOptions:
+      - CreateNamespace=true
+      - ServerSideApply=true
diff --git a/deploy/charts/buzz/examples/flux-helmrelease.yaml b/deploy/charts/buzz/examples/flux-helmrelease.yaml
new file mode 100644
index 0000000000..a6b6276c58
--- /dev/null
+++ b/deploy/charts/buzz/examples/flux-helmrelease.yaml
@@ -0,0 +1,64 @@
+# Flux HelmRelease — GitOps-safe Buzz install.
+#
+# Prerequisite: a Secret named `buzz-secrets` in namespace `buzz`. See
+# `secret-sample.yaml`. Flux renders the chart server-side via the
+# helm-controller, which (like ArgoCD) treats chart-side `randAlphaNum`
+# autogen as non-idempotent. `existingSecret` is the only safe path.
+---
+apiVersion: source.toolkit.fluxcd.io/v1
+kind: HelmRepository
+metadata:
+  name: buzz
+  namespace: buzz
+spec:
+  type: oci
+  url: oci://ghcr.io/block/buzz/charts
+  interval: 10m
+---
+apiVersion: helm.toolkit.fluxcd.io/v2
+kind: HelmRelease
+metadata:
+  name: buzz
+  namespace: buzz
+spec:
+  interval: 10m
+  chart:
+    spec:
+      chart: buzz
+      version: "0.1.0"
+      sourceRef:
+        kind: HelmRepository
+        name: buzz
+  values:
+    relayUrl: wss://buzz.example.com
+    ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"  # replace
+    replicaCount: 3
+
+    secrets:
+      existingSecret: buzz-secrets
+
+    typesense:
+      url: "http://typesense.buzz.svc.cluster.local:8108"
+      # apiKey lives in buzz-secrets
+
+    s3:
+      endpoint: "https://s3.us-east-1.amazonaws.com"
+      bucket: "buzz-media"
+
+    persistence:
+      git:
+        enabled: true
+        accessMode: ReadWriteMany
+        storageClass: efs-sc
+        size: 50Gi
+
+    ingress:
+      enabled: true
+      className: nginx
+      annotations:
+        cert-manager.io/cluster-issuer: letsencrypt-prod
+        nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
+        nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
+      tls:
+        - hosts: [buzz.example.com]
+          secretName: buzz-tls
diff --git a/deploy/charts/buzz/examples/ingress-cert-manager.yaml b/deploy/charts/buzz/examples/ingress-cert-manager.yaml
new file mode 100644
index 0000000000..e2df24796d
--- /dev/null
+++ b/deploy/charts/buzz/examples/ingress-cert-manager.yaml
@@ -0,0 +1,69 @@
+# Ingress + automatic TLS via cert-manager (Let's Encrypt HTTP-01).
+#
+# This file is BOTH:
+#   1. A values fragment for the chart (the `ingress:` block) — pass with `-f`.
+#   2. A ClusterIssuer manifest at the bottom — apply ONCE per cluster with
+#      `kubectl apply -f`. The chart does not manage it.
+#
+# Helm's value-file parser reads ONLY the first YAML document; the second
+# document (the ClusterIssuer) is ignored by Helm. That is intentional — the
+# ClusterIssuer is cluster-scoped and outlives any single release.
+#
+# Prerequisite: cert-manager installed in the cluster. The chart does not
+# depend on it — that is a cluster operator decision.
+#
+#   helm install cert-manager cert-manager \
+#     --repo https://charts.jetstack.io \
+#     --namespace cert-manager --create-namespace \
+#     --set crds.enabled=true
+#
+# Apply this file in two passes:
+#
+#   # 1. Install the cluster-scoped ClusterIssuer (idempotent):
+#   kubectl apply -f deploy/charts/buzz/examples/ingress-cert-manager.yaml
+#
+#   # 2. Install/upgrade the chart with the values fragment:
+#   helm upgrade --install buzz ./deploy/charts/buzz \
+#     -f values-production.yaml \
+#     -f deploy/charts/buzz/examples/ingress-cert-manager.yaml \
+#     --set relayUrl=wss://buzz.example.com
+#
+# Why HTTP-01: works for any public-DNS host without DNS-API credentials.
+# Switch to DNS-01 if your relay is on a private/split-DNS host or you want
+# wildcard certs.
+
+# ── Values fragment (Helm reads this document) ───────────────────────────────
+ingress:
+  enabled: true
+  className: nginx
+  annotations:
+    cert-manager.io/cluster-issuer: letsencrypt-prod
+    # Long-lived WebSocket connections — generous timeouts.
+    nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
+    nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
+  hosts:
+    - host: buzz.example.com         # replace; must match relayUrl host
+      paths:
+        - path: /
+          pathType: Prefix
+  tls:
+    - hosts:
+        - buzz.example.com           # replace
+      secretName: buzz-tls           # cert-manager creates this Secret
+
+---
+# ── ClusterIssuer (kubectl apply, NOT consumed by Helm) ──────────────────────
+apiVersion: cert-manager.io/v1
+kind: ClusterIssuer
+metadata:
+  name: letsencrypt-prod
+spec:
+  acme:
+    server: https://acme-v02.api.letsencrypt.org/directory
+    email: ops@example.com           # replace
+    privateKeySecretRef:
+      name: letsencrypt-prod-account-key
+    solvers:
+      - http01:
+          ingress:
+            class: nginx             # match your ingress controller's ingressClass
diff --git a/deploy/charts/buzz/examples/secret-sample.yaml b/deploy/charts/buzz/examples/secret-sample.yaml
new file mode 100644
index 0000000000..c842038b4a
--- /dev/null
+++ b/deploy/charts/buzz/examples/secret-sample.yaml
@@ -0,0 +1,30 @@
+# Sample Secret matching `secrets.existingSecret: buzz-secrets`.
+#
+# Manage this via SealedSecrets / SOPS / External Secrets / Vault — anything
+# that keeps the unencrypted form out of git. The chart reads keys it finds
+# and leaves the rest as Pod env vars marked `optional: true`.
+#
+# Keys consumed by the relay (all optional unless required by relay config):
+#   BUZZ_RELAY_PRIVATE_KEY        — 64-char hex; relay identity (do NOT rotate)
+#   BUZZ_GIT_HOOK_HMAC_SECRET     — 32+ chars; required when replicaCount > 1
+#   DATABASE_URL                  — postgres://...
+#   REDIS_URL                     — redis://...  (required when replicaCount > 1)
+#   TYPESENSE_URL
+#   TYPESENSE_API_KEY
+#   BUZZ_S3_ACCESS_KEY
+#   BUZZ_S3_SECRET_KEY
+apiVersion: v1
+kind: Secret
+metadata:
+  name: buzz-secrets
+  namespace: buzz
+type: Opaque
+stringData:
+  BUZZ_RELAY_PRIVATE_KEY: "REPLACE_WITH_64_HEX"
+  BUZZ_GIT_HOOK_HMAC_SECRET: "REPLACE_WITH_RANDOM_64_CHARS"
+  DATABASE_URL: "postgres://buzz:REPLACE@postgres.buzz.svc.cluster.local:5432/buzz?sslmode=require"
+  REDIS_URL: "redis://:REPLACE@redis.buzz.svc.cluster.local:6379"
+  TYPESENSE_URL: "http://typesense.buzz.svc.cluster.local:8108"
+  TYPESENSE_API_KEY: "REPLACE"
+  BUZZ_S3_ACCESS_KEY: "REPLACE"
+  BUZZ_S3_SECRET_KEY: "REPLACE"
diff --git a/deploy/charts/buzz/templates/NOTES.txt b/deploy/charts/buzz/templates/NOTES.txt
new file mode 100644
index 0000000000..35a1080ad8
--- /dev/null
+++ b/deploy/charts/buzz/templates/NOTES.txt
@@ -0,0 +1,98 @@
+══════════════════════════════════════════════════════════════════════════════
+  Buzz {{ .Chart.AppVersion }} — release "{{ .Release.Name }}" (namespace {{ .Release.Namespace }})
+══════════════════════════════════════════════════════════════════════════════
+
+▶ Relay URL
+  {{ .Values.relayUrl }}
+
+▶ Owner pubkey
+  {{ .Values.ownerPubkey }}
+  {{- if not .Values.ownerPubkey }}
+  ⚠ ownerPubkey is empty — this is only valid when relay.requireRelayMembership=false.
+  {{- end }}
+
+▶ Health
+  kubectl -n {{ .Release.Namespace }} port-forward svc/{{ include "buzz.fullname" . }} 8080:{{ .Values.service.healthPort }}
+  curl http://localhost:8080/_readiness
+
+{{ if not .Values.ingress.enabled }}{{ if not .Values.httproute.enabled }}
+▶ Networking
+  Neither ingress nor Gateway API HTTPRoute is enabled. Expose the relay
+  through your own gateway, then ensure clients reach .Values.relayUrl
+  ({{ .Values.relayUrl }}) over wss://. Long-lived WebSocket connections
+  require generous proxy read/send timeouts (≥ 1h).
+{{ end }}{{ end }}
+
+──────────────────────────────────────────────────────────────────────────────
+  Profile
+──────────────────────────────────────────────────────────────────────────────
+{{ if or .Values.postgresql.enabled .Values.redis.enabled .Values.minio.enabled .Values.typesense.enabled }}
+⚠ QUICKSTART / EVALUATION PROFILE
+  {{ if .Values.postgresql.enabled }}- In-cluster Postgres subchart (CloudPirates){{ end }}
+  {{ if .Values.redis.enabled }}- In-cluster Redis subchart (CloudPirates){{ end }}
+  {{ if .Values.minio.enabled }}- In-cluster MinIO (eval-only, single replica; bucket "{{ .Values.s3.bucket }}" created by post-install Job){{ end }}
+  {{ if .Values.typesense.enabled }}- In-cluster Typesense (eval-only, single replica){{ end }}
+  - Chart auto-generates secrets via the `lookup` pattern. This is NOT
+    GitOps-safe — secrets will silently rotate under ArgoCD/Flux. For
+    production, see examples/argocd-app.yaml or examples/flux-helmrelease.yaml.
+
+{{ else }}
+✓ PRODUCTION PROFILE
+  External Postgres, Redis (if enabled), Typesense, S3.
+  {{ if .Values.secrets.existingSecret }}- Secrets sourced from: {{ .Values.secrets.existingSecret }}{{ end }}
+{{ end }}
+
+──────────────────────────────────────────────────────────────────────────────
+  Backups — save these
+──────────────────────────────────────────────────────────────────────────────
+  1. BUZZ_RELAY_PRIVATE_KEY — relay identity. Rotating it = identity change;
+     federation peers will treat the relay as a new identity.
+  2. PostgreSQL database{{ if .Values.postgresql.enabled }} ({{ .Release.Name }}-postgresql PVC){{ end }}
+  3. S3 bucket "{{ .Values.s3.bucket }}" — media blobs
+  4. Git PVC ({{ include "buzz.fullname" . }}-git) — repo on-disk state
+  5. Owner private key (held by the operator, NOT the chart) — restore by
+     re-installing with the same ownerPubkey.
+
+──────────────────────────────────────────────────────────────────────────────
+  Degradation warnings
+──────────────────────────────────────────────────────────────────────────────
+{{- if not .Values.relay.requireAuthToken }}
+  ⚠ relay.requireAuthToken=false — REST API bypasses token auth. Production
+    should set this to true.
+{{- end }}
+{{- if not .Values.relay.requireRelayMembership }}
+  ⚠ relay.requireRelayMembership=false — relay is OPEN. Anyone can publish.
+{{- end }}
+{{- if not .Values.migrate.autoMigrate }}
+  ⚠ migrate.autoMigrate=false — relay startup will NOT run sqlx migrations.
+    You must run `buzz-admin migrate` against the database before every
+    `helm install` / `helm upgrade`, or pods will start against an unmigrated
+    schema. Readiness probes only verify DB connectivity, not schema freshness.
+{{- end }}
+{{- if or .Values.secrets.relayPrivateKey .Values.secrets.gitHookHmacSecret }}
+  ⚠ Inline secret values are set in values.yaml
+    ({{ if .Values.secrets.relayPrivateKey }}secrets.relayPrivateKey{{ end }}{{ if and .Values.secrets.relayPrivateKey .Values.secrets.gitHookHmacSecret }}, {{ end }}{{ if .Values.secrets.gitHookHmacSecret }}secrets.gitHookHmacSecret{{ end }}).
+    Inline overrides leak secrets into git history and CI logs. Move them to a
+    Kubernetes Secret and reference it via secrets.existingSecret — see
+    examples/secret-sample.yaml.
+{{- end }}
+{{- if and (gt (.Values.replicaCount | int) 1) (eq .Values.persistence.git.accessMode "ReadWriteOnce") }}
+  ⚠ replicaCount > 1 with ReadWriteOnce git PVC will fail at template time
+    (this message should never appear — file a bug).
+{{- end }}
+{{- if not .Values.secrets.existingSecret }}
+{{- if not (or .Values.postgresql.enabled .Values.redis.enabled) }}
+  ⚠ Chart-managed Secret is in use (no secrets.existingSecret). This is fine
+    for `helm install` / `helm upgrade` but NOT safe under GitOps tools that
+    `helm template` to render manifests — the `lookup` function returns empty
+    in that mode and secrets will silently rotate. Use existingSecret for
+    ArgoCD / Flux.
+{{- end }}
+{{- end }}
+
+──────────────────────────────────────────────────────────────────────────────
+  Useful commands
+──────────────────────────────────────────────────────────────────────────────
+  kubectl -n {{ .Release.Namespace }} get pods -l app.kubernetes.io/instance={{ .Release.Name }}
+  kubectl -n {{ .Release.Namespace }} logs -l app.kubernetes.io/instance={{ .Release.Name }} --tail=200
+  kubectl -n {{ .Release.Namespace }} rollout status deployment/{{ include "buzz.fullname" . }}
diff --git a/deploy/charts/buzz/templates/_helpers.tpl b/deploy/charts/buzz/templates/_helpers.tpl
new file mode 100644
index 0000000000..e7ac0ed163
--- /dev/null
+++ b/deploy/charts/buzz/templates/_helpers.tpl
@@ -0,0 +1,126 @@
+{{/* Standard naming/labels helpers. */}}
+
+{{- define "buzz.name" -}}
+{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
+{{- end -}}
+
+{{- define "buzz.fullname" -}}
+{{- if .Values.fullnameOverride -}}
+{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
+{{- else -}}
+{{- $name := default .Chart.Name .Values.nameOverride -}}
+{{- if contains $name .Release.Name -}}
+{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
+{{- else -}}
+{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
+{{- end -}}
+{{- end -}}
+{{- end -}}
+
+{{- define "buzz.chart" -}}
+{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
+{{- end -}}
+
+{{- define "buzz.labels" -}}
+helm.sh/chart: {{ include "buzz.chart" . }}
+{{ include "buzz.selectorLabels" . }}
+{{- if .Chart.AppVersion }}
+app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
+{{- end }}
+app.kubernetes.io/managed-by: {{ .Release.Service }}
+app.kubernetes.io/part-of: buzz
+{{- end -}}
+
+{{- define "buzz.selectorLabels" -}}
+app.kubernetes.io/name: {{ include "buzz.name" . }}
+app.kubernetes.io/instance: {{ .Release.Name }}
+{{- end -}}
+
+{{/* Relay-specific selector: scopes the relay Deployment + Service so they do
+     not also match the quickstart MinIO/Typesense pods, which share the base
+     selectorLabels but carry their own component label. */}}
+{{- define "buzz.relaySelectorLabels" -}}
+{{ include "buzz.selectorLabels" . }}
+app.kubernetes.io/component: relay
+{{- end -}}
+
+{{- define "buzz.serviceAccountName" -}}
+{{- if .Values.serviceAccount.create -}}
+{{- default (include "buzz.fullname" .) .Values.serviceAccount.name -}}
+{{- else -}}
+{{- default "default" .Values.serviceAccount.name -}}
+{{- end -}}
+{{- end -}}
+
+{{- define "buzz.image" -}}
+{{- $tag := default .Chart.AppVersion .Values.image.tag -}}
+{{- printf "%s:%s" .Values.image.repository $tag -}}
+{{- end -}}
+
+{{/*
+Name of the chart-managed Secret holding relay-identity material and any
+chart-composed connection strings.
+*/}}
+{{- define "buzz.chartSecretName" -}}
+{{- printf "%s-relay" (include "buzz.fullname" .) -}}
+{{- end -}}
+
+{{/*
+The Secret name the relay should pull env from. If the operator supplied
+secrets.existingSecret, use that. Otherwise use the chart-managed one.
+*/}}
+{{- define "buzz.envSecretName" -}}
+{{- if .Values.secrets.existingSecret -}}
+{{- .Values.secrets.existingSecret -}}
+{{- else -}}
+{{- include "buzz.chartSecretName" . -}}
+{{- end -}}
+{{- end -}}
+
+{{/* Host derived from relayUrl, used as ingress default + media domain. */}}
+{{- define "buzz.relayHost" -}}
+{{- $url := required "relayUrl is required: set --set relayUrl=wss://your.domain" .Values.relayUrl -}}
+{{- $stripped := $url | replace "wss://" "" | replace "ws://" "" | replace "https://" "" | replace "http://" "" -}}
+{{- first (splitList "/" $stripped) -}}
+{{- end -}}
+
+{{/* Default media base URL: https://<host>/media derived from relayUrl. */}}
+{{- define "buzz.mediaBaseUrl" -}}
+{{- if .Values.mediaBaseUrl -}}
+{{- .Values.mediaBaseUrl -}}
+{{- else -}}
+{{- printf "https://%s/media" (include "buzz.relayHost" .) -}}
+{{- end -}}
+{{- end -}}
+
+{{/* Quickstart-only in-cluster service hostnames (eval profile). */}}
+{{- define "buzz.minioFullname" -}}
+{{- printf "%s-minio" (include "buzz.fullname" .) -}}
+{{- end -}}
+
+{{- define "buzz.typesenseFullname" -}}
+{{- printf "%s-typesense" (include "buzz.fullname" .) -}}
+{{- end -}}
+
+{{/* In-cluster MinIO endpoint, used when minio.enabled and s3.endpoint unset. */}}
+{{- define "buzz.minioEndpoint" -}}
+{{- printf "http://%s.%s.svc.cluster.local:9000" (include "buzz.minioFullname" .) .Release.Namespace -}}
+{{- end -}}
+
+{{/* Effective S3 endpoint: explicit s3.endpoint wins, else bundled MinIO. */}}
+{{- define "buzz.s3Endpoint" -}}
+{{- if .Values.s3.endpoint -}}
+{{- .Values.s3.endpoint -}}
+{{- else if .Values.minio.enabled -}}
+{{- include "buzz.minioEndpoint" . -}}
+{{- end -}}
+{{- end -}}
+
+{{/* In-cluster Typesense URL, used when typesense.enabled and url unset. */}}
+{{- define "buzz.typesenseUrl" -}}
+{{- if .Values.typesense.url -}}
+{{- .Values.typesense.url -}}
+{{- else if .Values.typesense.enabled -}}
+{{- printf "http://%s.%s.svc.cluster.local:8108" (include "buzz.typesenseFullname" .) .Release.Namespace -}}
+{{- end -}}
+{{- end -}}
diff --git a/deploy/charts/buzz/templates/_validate.tpl b/deploy/charts/buzz/templates/_validate.tpl
new file mode 100644
index 0000000000..a6435f606a
--- /dev/null
+++ b/deploy/charts/buzz/templates/_validate.tpl
@@ -0,0 +1,64 @@
+{{/*
+Hard fail guards. Included from every rendered template so misconfigs
+surface at template time regardless of which manifest helm renders first.
+*/}}
+
+{{- define "buzz.validate" -}}
+
+{{/* relayUrl is required */}}
+{{- if not .Values.relayUrl -}}
+  {{- fail "relayUrl is required: set --set relayUrl=wss://your.domain" -}}
+{{- end -}}
+
+{{/* replicaCount > 1 requires Redis */}}
+{{- if gt (.Values.replicaCount | int) 1 -}}
+  {{- if and (not .Values.redis.enabled) (not .Values.externalRedis.url) (not .Values.secrets.existingSecret) -}}
+    {{- fail (printf "replicaCount=%d requires Redis for buzz-pubsub. Enable redis.enabled=true, set externalRedis.url, or provide secrets.existingSecret with key REDIS_URL." (.Values.replicaCount | int)) -}}
+  {{- end -}}
+{{- end -}}
+
+{{/* replicaCount > 1 requires ReadWriteMany git storage */}}
+{{- if gt (.Values.replicaCount | int) 1 -}}
+  {{- if and .Values.persistence.git.enabled (not .Values.persistence.git.existingClaim) -}}
+    {{- if ne .Values.persistence.git.accessMode "ReadWriteMany" -}}
+      {{- fail (printf "replicaCount=%d requires persistence.git.accessMode=ReadWriteMany (got %q). The relay's git on-disk state must be shared across replicas." (.Values.replicaCount | int) .Values.persistence.git.accessMode) -}}
+    {{- end -}}
+  {{- end -}}
+{{- end -}}
+
+{{/* Owner pubkey required when requireRelayMembership */}}
+{{- if .Values.relay.requireRelayMembership -}}
+  {{- if not .Values.ownerPubkey -}}
+    {{- fail "ownerPubkey is required when relay.requireRelayMembership=true. Set ownerPubkey to the 64-char lowercase hex Nostr pubkey of the relay operator, or set relay.requireRelayMembership=false for an open relay." -}}
+  {{- end -}}
+{{- end -}}
+
+{{/* ownerPubkey format check */}}
+{{- if .Values.ownerPubkey -}}
+  {{- if not (regexMatch "^[0-9a-f]{64}$" .Values.ownerPubkey) -}}
+    {{- fail (printf "ownerPubkey must be 64 lowercase hex characters (got %d chars; must match ^[0-9a-f]{64}$)." (len .Values.ownerPubkey)) -}}
+  {{- end -}}
+{{- end -}}
+
+{{/* ingress + httproute mutually exclusive */}}
+{{- if and .Values.ingress.enabled .Values.httproute.enabled -}}
+  {{- fail "ingress.enabled and httproute.enabled cannot both be true — choose one." -}}
+{{- end -}}
+
+{{/* Postgres source must exist somewhere */}}
+{{- if not (or .Values.postgresql.enabled .Values.externalPostgresql.url .Values.secrets.existingSecret) -}}
+  {{- fail "Postgres source missing: enable postgresql.enabled=true, set externalPostgresql.url, or provide secrets.existingSecret with key DATABASE_URL." -}}
+{{- end -}}
+
+{{/* Typesense source must exist somewhere */}}
+{{- if not (or .Values.typesense.enabled .Values.typesense.url .Values.secrets.existingSecret) -}}
+  {{- fail "Typesense source missing: enable typesense.enabled=true (quickstart in-cluster), set typesense.url + typesense.apiKey, or provide secrets.existingSecret with keys TYPESENSE_URL + TYPESENSE_API_KEY." -}}
+{{- end -}}
+
+{{/* S3 / object-storage source must exist somewhere (relay hard-fails its
+     startup conformance probe without a reachable bucket). */}}
+{{- if not (or .Values.minio.enabled .Values.s3.endpoint .Values.secrets.existingSecret) -}}
+  {{- fail "S3/object-storage source missing: enable minio.enabled=true (quickstart in-cluster), set s3.endpoint + s3.bucket + credentials, or provide secrets.existingSecret with keys BUZZ_S3_ACCESS_KEY + BUZZ_S3_SECRET_KEY. The relay runs a startup S3 conformance probe and exits if storage is unreachable." -}}
+{{- end -}}
+
+{{- end -}}
diff --git a/deploy/charts/buzz/templates/deployment.yaml b/deploy/charts/buzz/templates/deployment.yaml
new file mode 100644
index 0000000000..e234fb8e4b
--- /dev/null
+++ b/deploy/charts/buzz/templates/deployment.yaml
@@ -0,0 +1,184 @@
+{{- include "buzz.validate" . -}}
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: {{ include "buzz.fullname" . }}
+  labels:
+    {{- include "buzz.labels" . | nindent 4 }}
+spec:
+  replicas: {{ .Values.replicaCount }}
+  strategy:
+    type: RollingUpdate
+    rollingUpdate:
+      maxSurge: 1
+      maxUnavailable: 0
+  selector:
+    matchLabels:
+      {{- include "buzz.relaySelectorLabels" . | nindent 6 }}
+  template:
+    metadata:
+      labels:
+        {{- include "buzz.relaySelectorLabels" . | nindent 8 }}
+        {{- with .Values.relay.podLabels }}
+        {{- toYaml . | nindent 8 }}
+        {{- end }}
+      annotations:
+        # Roll pods when the chart-managed Secret changes.
+        checksum/secret: {{ include (print $.Template.BasePath "/secret-chart.yaml") . | sha256sum }}
+        {{- with .Values.relay.podAnnotations }}
+        {{- toYaml . | nindent 8 }}
+        {{- end }}
+    spec:
+      serviceAccountName: {{ include "buzz.serviceAccountName" . }}
+      securityContext:
+        {{- toYaml .Values.relay.securityContext | nindent 8 }}
+      terminationGracePeriodSeconds: {{ .Values.relay.terminationGracePeriodSeconds }}
+      {{- with .Values.image.pullSecrets }}
+      imagePullSecrets:
+        {{- toYaml . | nindent 8 }}
+      {{- end }}
+      {{- with .Values.relay.nodeSelector }}
+      nodeSelector:
+        {{- toYaml . | nindent 8 }}
+      {{- end }}
+      {{- with .Values.relay.tolerations }}
+      tolerations:
+        {{- toYaml . | nindent 8 }}
+      {{- end }}
+      {{- with .Values.relay.affinity }}
+      affinity:
+        {{- toYaml . | nindent 8 }}
+      {{- end }}
+      {{- with .Values.relay.topologySpreadConstraints }}
+      topologySpreadConstraints:
+        {{- toYaml . | nindent 8 }}
+      {{- end }}
+      containers:
+        - name: relay
+          image: {{ include "buzz.image" . }}
+          imagePullPolicy: {{ .Values.image.pullPolicy }}
+          securityContext:
+            {{- toYaml .Values.relay.containerSecurityContext | nindent 12 }}
+          ports:
+            - { name: app,     containerPort: 3000, protocol: TCP }
+            - { name: health,  containerPort: {{ .Values.service.healthPort }}, protocol: TCP }
+            - { name: metrics, containerPort: {{ .Values.service.metricsPort }}, protocol: TCP }
+          env:
+            # ── Networking ───────────────────────────────────────────
+            - { name: BUZZ_BIND_ADDR,     value: {{ .Values.relay.bindAddr | quote }} }
+            - { name: BUZZ_HEALTH_PORT,   value: {{ .Values.service.healthPort | quote }} }
+            - { name: BUZZ_METRICS_PORT,  value: {{ .Values.service.metricsPort | quote }} }
+            - { name: RELAY_URL,          value: {{ .Values.relayUrl | quote }} }
+            - { name: BUZZ_MEDIA_BASE_URL, value: {{ include "buzz.mediaBaseUrl" . | quote }} }
+            - { name: BUZZ_MEDIA_SERVER_DOMAIN, value: {{ include "buzz.relayHost" . | quote }} }
+
+            # ── Behavior ─────────────────────────────────────────────
+            - { name: BUZZ_MAX_CONNECTIONS,        value: {{ .Values.relay.maxConnections | quote }} }
+            - { name: BUZZ_MAX_CONCURRENT_HANDLERS, value: {{ .Values.relay.maxConcurrentHandlers | quote }} }
+            - { name: BUZZ_SEND_BUFFER,            value: {{ .Values.relay.sendBuffer | quote }} }
+            - { name: BUZZ_REQUIRE_AUTH_TOKEN,     value: {{ .Values.relay.requireAuthToken | quote }} }
+            - { name: BUZZ_REQUIRE_RELAY_MEMBERSHIP, value: {{ .Values.relay.requireRelayMembership | quote }} }
+            - { name: BUZZ_ALLOW_NIP_OA_AUTH,      value: {{ .Values.relay.allowNipOaAuth | quote }} }
+            - { name: BUZZ_PUBKEY_ALLOWLIST,       value: {{ .Values.relay.pubkeyAllowlist | quote }} }
+            {{- if .Values.relay.corsOrigins }}
+            - { name: BUZZ_CORS_ORIGINS, value: {{ join "," .Values.relay.corsOrigins | quote }} }
+            {{- end }}
+            {{- if gt (.Values.relay.ephemeralTtlOverride | int) 0 }}
+            - { name: BUZZ_EPHEMERAL_TTL_OVERRIDE, value: {{ .Values.relay.ephemeralTtlOverride | quote }} }
+            {{- end }}
+
+            # ── Owner ────────────────────────────────────────────────
+            - { name: RELAY_OWNER_PUBKEY, value: {{ .Values.ownerPubkey | quote }} }
+
+            # ── Migrations ───────────────────────────────────────────
+            - { name: BUZZ_AUTO_MIGRATE, value: {{ .Values.migrate.autoMigrate | quote }} }
+
+            # ── Git ──────────────────────────────────────────────────
+            - { name: BUZZ_GIT_REPO_PATH,         value: {{ .Values.persistence.git.mountPath | quote }} }
+            - { name: BUZZ_GIT_MAX_PACK_BYTES,    value: {{ .Values.git.maxPackBytes | quote }} }
+            - { name: BUZZ_GIT_MAX_REPOS_PER_PUBKEY, value: {{ .Values.git.maxReposPerPubkey | quote }} }
+            - { name: BUZZ_GIT_MAX_CONCURRENT_OPS, value: {{ .Values.git.maxConcurrentOps | quote }} }
+
+            # ── S3 (non-secret) ──────────────────────────────────────
+            {{- $s3Endpoint := include "buzz.s3Endpoint" . }}
+            {{- if $s3Endpoint }}
+            - { name: BUZZ_S3_ENDPOINT, value: {{ $s3Endpoint | quote }} }
+            {{- end }}
+            - { name: BUZZ_S3_BUCKET, value: {{ .Values.s3.bucket | quote }} }
+
+            # ── Secrets (from chart-managed or existing) ─────────────
+            - name: BUZZ_RELAY_PRIVATE_KEY
+              valueFrom:
+                secretKeyRef:
+                  name: {{ include "buzz.envSecretName" . }}
+                  key: BUZZ_RELAY_PRIVATE_KEY
+                  optional: true
+            - name: BUZZ_GIT_HOOK_HMAC_SECRET
+              valueFrom:
+                secretKeyRef:
+                  name: {{ include "buzz.envSecretName" . }}
+                  key: BUZZ_GIT_HOOK_HMAC_SECRET
+                  optional: true
+            - name: DATABASE_URL
+              valueFrom:
+                secretKeyRef:
+                  name: {{ include "buzz.envSecretName" . }}
+                  key: DATABASE_URL
+            - name: REDIS_URL
+              valueFrom:
+                secretKeyRef:
+                  name: {{ include "buzz.envSecretName" . }}
+                  key: REDIS_URL
+                  optional: {{ and (eq (.Values.replicaCount | int) 1) (not .Values.redis.enabled) (not .Values.externalRedis.url) }}
+            - name: TYPESENSE_URL
+              valueFrom:
+                secretKeyRef:
+                  name: {{ include "buzz.envSecretName" . }}
+                  key: TYPESENSE_URL
+            - name: TYPESENSE_API_KEY
+              valueFrom:
+                secretKeyRef:
+                  name: {{ include "buzz.envSecretName" . }}
+                  key: TYPESENSE_API_KEY
+            - name: BUZZ_S3_ACCESS_KEY
+              valueFrom:
+                secretKeyRef:
+                  name: {{ include "buzz.envSecretName" . }}
+                  key: BUZZ_S3_ACCESS_KEY
+                  optional: true
+            - name: BUZZ_S3_SECRET_KEY
+              valueFrom:
+                secretKeyRef:
+                  name: {{ include "buzz.envSecretName" . }}
+                  key: BUZZ_S3_SECRET_KEY
+                  optional: true
+
+            {{- with .Values.relay.extraEnv }}
+            {{- toYaml . | nindent 12 }}
+            {{- end }}
+          {{- with .Values.relay.extraEnvFrom }}
+          envFrom:
+            {{- toYaml . | nindent 12 }}
+          {{- end }}
+
+          livenessProbe:
+            {{- toYaml .Values.relay.livenessProbe | nindent 12 }}
+          readinessProbe:
+            {{- toYaml .Values.relay.readinessProbe | nindent 12 }}
+          startupProbe:
+            {{- toYaml .Values.relay.startupProbe | nindent 12 }}
+
+          resources:
+            {{- toYaml .Values.relay.resources | nindent 12 }}
+
+          volumeMounts:
+            {{- if .Values.persistence.git.enabled }}
+            - { name: git-repos, mountPath: {{ .Values.persistence.git.mountPath | quote }} }
+            {{- end }}
+
+      volumes:
+        {{- if .Values.persistence.git.enabled }}
+        - name: git-repos
+          persistentVolumeClaim:
+            claimName: {{ default (printf "%s-git" (include "buzz.fullname" .)) .Values.persistence.git.existingClaim }}
+        {{- end }}
diff --git a/deploy/charts/buzz/templates/httproute.yaml b/deploy/charts/buzz/templates/httproute.yaml
new file mode 100644
index 0000000000..2c0b77d69c
--- /dev/null
+++ b/deploy/charts/buzz/templates/httproute.yaml
@@ -0,0 +1,28 @@
+{{- include "buzz.validate" . -}}
+{{- if .Values.httproute.enabled -}}
+apiVersion: gateway.networking.k8s.io/v1
+kind: HTTPRoute
+metadata:
+  name: {{ include "buzz.fullname" . }}
+  labels:
+    {{- include "buzz.labels" . | nindent 4 }}
+spec:
+  parentRefs:
+    {{- toYaml .Values.httproute.parentRefs | nindent 4 }}
+  {{- with .Values.httproute.hostnames }}
+  hostnames:
+    {{- toYaml . | nindent 4 }}
+  {{- end }}
+  rules:
+    {{- if .Values.httproute.rules }}
+    {{- toYaml .Values.httproute.rules | nindent 4 }}
+    {{- else }}
+    - matches:
+        - path:
+            type: PathPrefix
+            value: /
+      backendRefs:
+        - name: {{ include "buzz.fullname" . }}
+          port: {{ .Values.service.port }}
+    {{- end }}
+{{- end }}
diff --git a/deploy/charts/buzz/templates/ingress.yaml b/deploy/charts/buzz/templates/ingress.yaml
new file mode 100644
index 0000000000..4f678eb41b
--- /dev/null
+++ b/deploy/charts/buzz/templates/ingress.yaml
@@ -0,0 +1,43 @@
+{{- include "buzz.validate" . -}}
+{{- if .Values.ingress.enabled -}}
+{{- $fullName := include "buzz.fullname" . -}}
+{{- $svcPort := .Values.service.port -}}
+{{- $defaultHost := include "buzz.relayHost" . -}}
+{{- $hosts := .Values.ingress.hosts -}}
+{{- if not $hosts -}}
+{{- $hosts = list (dict "host" $defaultHost "paths" (list (dict "path" "/" "pathType" "Prefix"))) -}}
+{{- end -}}
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+  name: {{ $fullName }}
+  labels:
+    {{- include "buzz.labels" . | nindent 4 }}
+  {{- with .Values.ingress.annotations }}
+  annotations:
+    {{- toYaml . | nindent 4 }}
+  {{- end }}
+spec:
+  {{- if .Values.ingress.className }}
+  ingressClassName: {{ .Values.ingress.className }}
+  {{- end }}
+  {{- with .Values.ingress.tls }}
+  tls:
+    {{- toYaml . | nindent 4 }}
+  {{- end }}
+  rules:
+    {{- range $hosts }}
+    - host: {{ .host | default $defaultHost | quote }}
+      http:
+        paths:
+          {{- range .paths }}
+          - path: {{ .path | default "/" }}
+            pathType: {{ .pathType | default "Prefix" }}
+            backend:
+              service:
+                name: {{ $fullName }}
+                port:
+                  number: {{ $svcPort }}
+          {{- end }}
+    {{- end }}
+{{- end }}
diff --git a/deploy/charts/buzz/templates/pdb.yaml b/deploy/charts/buzz/templates/pdb.yaml
new file mode 100644
index 0000000000..ce2bc4d8e9
--- /dev/null
+++ b/deploy/charts/buzz/templates/pdb.yaml
@@ -0,0 +1,18 @@
+{{- include "buzz.validate" . -}}
+{{- if and .Values.podDisruptionBudget.enabled (gt (.Values.replicaCount | int) 1) -}}
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+  name: {{ include "buzz.fullname" . }}
+  labels:
+    {{- include "buzz.labels" . | nindent 4 }}
+spec:
+  selector:
+    matchLabels:
+      {{- include "buzz.relaySelectorLabels" . | nindent 6 }}
+  {{- if .Values.podDisruptionBudget.minAvailable }}
+  minAvailable: {{ .Values.podDisruptionBudget.minAvailable }}
+  {{- else if .Values.podDisruptionBudget.maxUnavailable }}
+  maxUnavailable: {{ .Values.podDisruptionBudget.maxUnavailable }}
+  {{- end }}
+{{- end }}
diff --git a/deploy/charts/buzz/templates/pvc-git.yaml b/deploy/charts/buzz/templates/pvc-git.yaml
new file mode 100644
index 0000000000..81b7ba9f2a
--- /dev/null
+++ b/deploy/charts/buzz/templates/pvc-git.yaml
@@ -0,0 +1,22 @@
+{{- include "buzz.validate" . -}}
+{{- if and .Values.persistence.git.enabled (not .Values.persistence.git.existingClaim) -}}
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+  name: {{ include "buzz.fullname" . }}-git
+  labels:
+    {{- include "buzz.labels" . | nindent 4 }}
+  {{- with .Values.persistence.git.annotations }}
+  annotations:
+    {{- toYaml . | nindent 4 }}
+  {{- end }}
+spec:
+  accessModes:
+    - {{ .Values.persistence.git.accessMode }}
+  resources:
+    requests:
+      storage: {{ .Values.persistence.git.size }}
+  {{- if .Values.persistence.git.storageClass }}
+  storageClassName: {{ .Values.persistence.git.storageClass | quote }}
+  {{- end }}
+{{- end -}}
diff --git a/deploy/charts/buzz/templates/quickstart-minio-init.yaml b/deploy/charts/buzz/templates/quickstart-minio-init.yaml
new file mode 100644
index 0000000000..044f4c5843
--- /dev/null
+++ b/deploy/charts/buzz/templates/quickstart-minio-init.yaml
@@ -0,0 +1,51 @@
+{{- /*
+Creates the media bucket in the bundled MinIO after install/upgrade. Mirrors
+the docker-compose `minio-init` step. Hook-managed so it re-runs on upgrade
+and is garbage-collected. Quickstart-only.
+*/ -}}
+{{- if .Values.minio.enabled -}}
+apiVersion: batch/v1
+kind: Job
+metadata:
+  name: {{ include "buzz.minioFullname" . }}-init
+  labels:
+    {{- include "buzz.labels" . | nindent 4 }}
+    app.kubernetes.io/component: minio-init
+  annotations:
+    helm.sh/hook: post-install,post-upgrade
+    helm.sh/hook-weight: "0"
+    helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
+spec:
+  backoffLimit: 10
+  template:
+    metadata:
+      labels:
+        {{- include "buzz.selectorLabels" . | nindent 8 }}
+        app.kubernetes.io/component: minio-init
+    spec:
+      restartPolicy: OnFailure
+      containers:
+        - name: mc
+          image: {{ .Values.minio.mcImage | quote }}
+          env:
+            - name: S3_ACCESS_KEY
+              valueFrom:
+                secretKeyRef:
+                  name: {{ include "buzz.chartSecretName" . }}
+                  key: BUZZ_S3_ACCESS_KEY
+            - name: S3_SECRET_KEY
+              valueFrom:
+                secretKeyRef:
+                  name: {{ include "buzz.chartSecretName" . }}
+                  key: BUZZ_S3_SECRET_KEY
+          command: ["/bin/sh", "-c"]
+          args:
+            - |
+              set -e
+              until mc alias set local {{ include "buzz.minioEndpoint" . }} "$S3_ACCESS_KEY" "$S3_SECRET_KEY"; do
+                echo "waiting for MinIO..."; sleep 3
+              done
+              mc mb --ignore-existing local/{{ .Values.s3.bucket }}
+              mc anonymous set none local/{{ .Values.s3.bucket }}
+              echo "bucket {{ .Values.s3.bucket }} ready"
+{{- end -}}
diff --git a/deploy/charts/buzz/templates/quickstart-minio.yaml b/deploy/charts/buzz/templates/quickstart-minio.yaml
new file mode 100644
index 0000000000..9685a7ff34
--- /dev/null
+++ b/deploy/charts/buzz/templates/quickstart-minio.yaml
@@ -0,0 +1,99 @@
+{{- /*
+Eval-only in-cluster MinIO for the quickstart profile. NOT for production —
+single replica, no TLS, credentials from the chart-managed Secret. Production
+deploys leave minio.enabled=false and point s3.* at managed object storage.
+*/ -}}
+{{- if .Values.minio.enabled -}}
+{{- if .Values.secrets.existingSecret -}}
+{{- fail "minio.enabled=true (quickstart) is incompatible with secrets.existingSecret. Quickstart autogenerates MinIO credentials in the chart-managed Secret; for external S3 set minio.enabled=false and provide BUZZ_S3_ACCESS_KEY/BUZZ_S3_SECRET_KEY." -}}
+{{- end -}}
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: {{ include "buzz.minioFullname" . }}
+  labels:
+    {{- include "buzz.labels" . | nindent 4 }}
+    app.kubernetes.io/component: minio
+spec:
+  replicas: 1
+  strategy:
+    type: Recreate
+  selector:
+    matchLabels:
+      {{- include "buzz.selectorLabels" . | nindent 6 }}
+      app.kubernetes.io/component: minio
+  template:
+    metadata:
+      labels:
+        {{- include "buzz.selectorLabels" . | nindent 8 }}
+        app.kubernetes.io/component: minio
+    spec:
+      containers:
+        - name: minio
+          image: {{ .Values.minio.image | quote }}
+          args: ["server", "/data", "--console-address", ":9001"]
+          env:
+            - name: MINIO_ROOT_USER
+              valueFrom:
+                secretKeyRef:
+                  name: {{ include "buzz.chartSecretName" . }}
+                  key: BUZZ_S3_ACCESS_KEY
+            - name: MINIO_ROOT_PASSWORD
+              valueFrom:
+                secretKeyRef:
+                  name: {{ include "buzz.chartSecretName" . }}
+                  key: BUZZ_S3_SECRET_KEY
+          ports:
+            - { name: api, containerPort: 9000 }
+            - { name: console, containerPort: 9001 }
+          readinessProbe:
+            httpGet: { path: /minio/health/ready, port: api }
+            initialDelaySeconds: 3
+            periodSeconds: 5
+          livenessProbe:
+            httpGet: { path: /minio/health/live, port: api }
+            initialDelaySeconds: 5
+            periodSeconds: 10
+          volumeMounts:
+            - { name: data, mountPath: /data }
+      volumes:
+        - name: data
+          {{- if .Values.minio.persistence.enabled }}
+          persistentVolumeClaim:
+            claimName: {{ include "buzz.minioFullname" . }}
+          {{- else }}
+          emptyDir: {}
+          {{- end }}
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: {{ include "buzz.minioFullname" . }}
+  labels:
+    {{- include "buzz.labels" . | nindent 4 }}
+    app.kubernetes.io/component: minio
+spec:
+  selector:
+    {{- include "buzz.selectorLabels" . | nindent 4 }}
+    app.kubernetes.io/component: minio
+  ports:
+    - { name: api, port: 9000, targetPort: api }
+    - { name: console, port: 9001, targetPort: console }
+{{- if .Values.minio.persistence.enabled }}
+---
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+  name: {{ include "buzz.minioFullname" . }}
+  labels:
+    {{- include "buzz.labels" . | nindent 4 }}
+    app.kubernetes.io/component: minio
+  annotations:
+    helm.sh/resource-policy: keep
+spec:
+  accessModes: ["ReadWriteOnce"]
+  resources:
+    requests:
+      storage: {{ .Values.minio.persistence.size | quote }}
+{{- end }}
+{{- end -}}
diff --git a/deploy/charts/buzz/templates/quickstart-typesense.yaml b/deploy/charts/buzz/templates/quickstart-typesense.yaml
new file mode 100644
index 0000000000..7184d5c5f4
--- /dev/null
+++ b/deploy/charts/buzz/templates/quickstart-typesense.yaml
@@ -0,0 +1,96 @@
+{{- /*
+Eval-only in-cluster Typesense for the quickstart profile. NOT for production —
+single replica, no TLS, API key from the chart-managed Secret. Production
+deploys leave typesense.enabled=false and point typesense.url/apiKey (or
+secrets.existingSecret) at a managed Typesense service.
+*/ -}}
+{{- if .Values.typesense.enabled -}}
+{{- if .Values.secrets.existingSecret -}}
+{{- fail "typesense.enabled=true (quickstart) is incompatible with secrets.existingSecret. Quickstart autogenerates the Typesense key in the chart-managed Secret; for external Typesense set typesense.enabled=false and provide TYPESENSE_URL/TYPESENSE_API_KEY." -}}
+{{- end -}}
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: {{ include "buzz.typesenseFullname" . }}
+  labels:
+    {{- include "buzz.labels" . | nindent 4 }}
+    app.kubernetes.io/component: typesense
+spec:
+  replicas: 1
+  strategy:
+    type: Recreate
+  selector:
+    matchLabels:
+      {{- include "buzz.selectorLabels" . | nindent 6 }}
+      app.kubernetes.io/component: typesense
+  template:
+    metadata:
+      labels:
+        {{- include "buzz.selectorLabels" . | nindent 8 }}
+        app.kubernetes.io/component: typesense
+    spec:
+      containers:
+        - name: typesense
+          image: {{ .Values.typesense.image | quote }}
+          args:
+            - "--data-dir=/data"
+            - "--api-key=$(TYPESENSE_API_KEY)"
+            - "--enable-cors"
+          env:
+            - name: TYPESENSE_API_KEY
+              valueFrom:
+                secretKeyRef:
+                  name: {{ include "buzz.chartSecretName" . }}
+                  key: TYPESENSE_API_KEY
+          ports:
+            - { name: http, containerPort: 8108 }
+          readinessProbe:
+            httpGet: { path: /health, port: http }
+            initialDelaySeconds: 3
+            periodSeconds: 5
+          livenessProbe:
+            httpGet: { path: /health, port: http }
+            initialDelaySeconds: 5
+            periodSeconds: 10
+          volumeMounts:
+            - { name: data, mountPath: /data }
+      volumes:
+        - name: data
+          {{- if .Values.typesense.persistence.enabled }}
+          persistentVolumeClaim:
+            claimName: {{ include "buzz.typesenseFullname" . }}
+          {{- else }}
+          emptyDir: {}
+          {{- end }}
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: {{ include "buzz.typesenseFullname" . }}
+  labels:
+    {{- include "buzz.labels" . | nindent 4 }}
+    app.kubernetes.io/component: typesense
+spec:
+  selector:
+    {{- include "buzz.selectorLabels" . | nindent 4 }}
+    app.kubernetes.io/component: typesense
+  ports:
+    - { name: http, port: 8108, targetPort: http }
+{{- if .Values.typesense.persistence.enabled }}
+---
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+  name: {{ include "buzz.typesenseFullname" . }}
+  labels:
+    {{- include "buzz.labels" . | nindent 4 }}
+    app.kubernetes.io/component: typesense
+  annotations:
+    helm.sh/resource-policy: keep
+spec:
+  accessModes: ["ReadWriteOnce"]
+  resources:
+    requests:
+      storage: {{ .Values.typesense.persistence.size | quote }}
+{{- end }}
+{{- end -}}
diff --git a/deploy/charts/buzz/templates/secret-chart.yaml b/deploy/charts/buzz/templates/secret-chart.yaml
new file mode 100644
index 0000000000..0a7677badb
--- /dev/null
+++ b/deploy/charts/buzz/templates/secret-chart.yaml
@@ -0,0 +1,127 @@
+{{- include "buzz.validate" . -}}
+{{- /*
+Chart-managed Secret.
+
+Renders only when at least one chart-managed value is needed (no
+secrets.existingSecret provided OR in-cluster Postgres composes DATABASE_URL
+here). Persists across upgrades via the `lookup` pattern. Not GitOps-safe —
+ArgoCD/Flux users should provide secrets.existingSecret instead.
+*/ -}}
+
+{{- if not .Values.secrets.existingSecret -}}
+{{- $existing := (lookup "v1" "Secret" .Release.Namespace (include "buzz.chartSecretName" .)) | default dict -}}
+{{- $existingData := (get $existing "data") | default dict -}}
+
+apiVersion: v1
+kind: Secret
+metadata:
+  name: {{ include "buzz.chartSecretName" . }}
+  labels:
+    {{- include "buzz.labels" . | nindent 4 }}
+  annotations:
+    helm.sh/resource-policy: keep
+type: Opaque
+data:
+
+  {{- /* Relay private key (relay identity; rotation = identity change) */}}
+  {{- if .Values.secrets.relayPrivateKey }}
+  BUZZ_RELAY_PRIVATE_KEY: {{ .Values.secrets.relayPrivateKey | b64enc | quote }}
+  {{- else if (index $existingData "BUZZ_RELAY_PRIVATE_KEY") }}
+  BUZZ_RELAY_PRIVATE_KEY: {{ index $existingData "BUZZ_RELAY_PRIVATE_KEY" | quote }}
+  {{- else }}
+  BUZZ_RELAY_PRIVATE_KEY: {{ randAlphaNum 64 | sha256sum | b64enc | quote }}
+  {{- end }}
+
+  {{- /* Git hook HMAC (required when replicaCount > 1) */}}
+  {{- if .Values.secrets.gitHookHmacSecret }}
+  BUZZ_GIT_HOOK_HMAC_SECRET: {{ .Values.secrets.gitHookHmacSecret | b64enc | quote }}
+  {{- else if (index $existingData "BUZZ_GIT_HOOK_HMAC_SECRET") }}
+  BUZZ_GIT_HOOK_HMAC_SECRET: {{ index $existingData "BUZZ_GIT_HOOK_HMAC_SECRET" | quote }}
+  {{- else }}
+  BUZZ_GIT_HOOK_HMAC_SECRET: {{ randAlphaNum 64 | b64enc | quote }}
+  {{- end }}
+
+  {{- /* In-cluster Postgres: compose DATABASE_URL + postgres-password */}}
+  {{- if .Values.postgresql.enabled }}
+  {{- $pgHost := printf "%s-postgresql" .Release.Name }}
+  {{- $pgDb := .Values.postgresql.auth.database }}
+  {{- $pgUser := .Values.postgresql.auth.username }}
+  {{- $pgPass := "" }}
+  {{- if (index $existingData "postgres-password") }}
+    {{- $pgPass = index $existingData "postgres-password" | b64dec }}
+  {{- else }}
+    {{- $pgPass = randAlphaNum 24 }}
+  {{- end }}
+  postgres-password: {{ $pgPass | b64enc | quote }}
+  DATABASE_URL: {{ printf "postgres://%s:%s@%s:5432/%s" $pgUser $pgPass $pgHost $pgDb | b64enc | quote }}
+  {{- else if .Values.externalPostgresql.url }}
+  DATABASE_URL: {{ .Values.externalPostgresql.url | b64enc | quote }}
+  {{- end }}
+
+  {{- /* In-cluster Redis: compose REDIS_URL */}}
+  {{- if .Values.redis.enabled }}
+  {{- $redisHost := printf "%s-redis" .Release.Name }}
+  {{- $redisPass := "" }}
+  {{- if (index $existingData "redis-password") }}
+    {{- $redisPass = index $existingData "redis-password" | b64dec }}
+  {{- else }}
+    {{- $redisPass = randAlphaNum 24 }}
+  {{- end }}
+  redis-password: {{ $redisPass | b64enc | quote }}
+  REDIS_URL: {{ printf "redis://:%s@%s:6379" $redisPass $redisHost | b64enc | quote }}
+  {{- else if .Values.externalRedis.url }}
+  REDIS_URL: {{ .Values.externalRedis.url | b64enc | quote }}
+  {{- end }}
+
+  {{- /* Typesense — bundled (quickstart) composes URL + autogen key; else
+         pass through external values. */}}
+  {{- if .Values.typesense.enabled }}
+  {{- $tsKey := "" }}
+  {{- if (index $existingData "TYPESENSE_API_KEY") }}
+    {{- $tsKey = index $existingData "TYPESENSE_API_KEY" | b64dec }}
+  {{- else if .Values.typesense.apiKey }}
+    {{- $tsKey = .Values.typesense.apiKey }}
+  {{- else }}
+    {{- $tsKey = randAlphaNum 32 }}
+  {{- end }}
+  TYPESENSE_URL: {{ include "buzz.typesenseUrl" . | b64enc | quote }}
+  TYPESENSE_API_KEY: {{ $tsKey | b64enc | quote }}
+  {{- else }}
+  {{- if .Values.typesense.url }}
+  TYPESENSE_URL: {{ .Values.typesense.url | b64enc | quote }}
+  {{- end }}
+  {{- if .Values.typesense.apiKey }}
+  TYPESENSE_API_KEY: {{ .Values.typesense.apiKey | b64enc | quote }}
+  {{- end }}
+  {{- end }}
+
+  {{- /* S3 creds — bundled MinIO (quickstart) autogenerates; else pass
+         through external values. */}}
+  {{- if .Values.minio.enabled }}
+  {{- $s3Access := "" }}
+  {{- if (index $existingData "BUZZ_S3_ACCESS_KEY") }}
+    {{- $s3Access = index $existingData "BUZZ_S3_ACCESS_KEY" | b64dec }}
+  {{- else if .Values.s3.accessKey }}
+    {{- $s3Access = .Values.s3.accessKey }}
+  {{- else }}
+    {{- $s3Access = printf "buzz-%s" (randAlphaNum 12 | lower) }}
+  {{- end }}
+  {{- $s3Secret := "" }}
+  {{- if (index $existingData "BUZZ_S3_SECRET_KEY") }}
+    {{- $s3Secret = index $existingData "BUZZ_S3_SECRET_KEY" | b64dec }}
+  {{- else if .Values.s3.secretKey }}
+    {{- $s3Secret = .Values.s3.secretKey }}
+  {{- else }}
+    {{- $s3Secret = randAlphaNum 32 }}
+  {{- end }}
+  BUZZ_S3_ACCESS_KEY: {{ $s3Access | b64enc | quote }}
+  BUZZ_S3_SECRET_KEY: {{ $s3Secret | b64enc | quote }}
+  {{- else }}
+  {{- if .Values.s3.accessKey }}
+  BUZZ_S3_ACCESS_KEY: {{ .Values.s3.accessKey | b64enc | quote }}
+  {{- end }}
+  {{- if .Values.s3.secretKey }}
+  BUZZ_S3_SECRET_KEY: {{ .Values.s3.secretKey | b64enc | quote }}
+  {{- end }}
+  {{- end }}
+{{- end -}}
diff --git a/deploy/charts/buzz/templates/service.yaml b/deploy/charts/buzz/templates/service.yaml
new file mode 100644
index 0000000000..5e73754328
--- /dev/null
+++ b/deploy/charts/buzz/templates/service.yaml
@@ -0,0 +1,19 @@
+{{- include "buzz.validate" . -}}
+apiVersion: v1
+kind: Service
+metadata:
+  name: {{ include "buzz.fullname" . }}
+  labels:
+    {{- include "buzz.labels" . | nindent 4 }}
+  {{- with .Values.service.annotations }}
+  annotations:
+    {{- toYaml . | nindent 4 }}
+  {{- end }}
+spec:
+  type: {{ .Values.service.type }}
+  selector:
+    {{- include "buzz.relaySelectorLabels" . | nindent 4 }}
+  ports:
+    - { name: app,     port: {{ .Values.service.port }},        targetPort: app,     protocol: TCP }
+    - { name: health,  port: {{ .Values.service.healthPort }},  targetPort: health,  protocol: TCP }
+    - { name: metrics, port: {{ .Values.service.metricsPort }}, targetPort: metrics, protocol: TCP }
diff --git a/deploy/charts/buzz/templates/serviceaccount.yaml b/deploy/charts/buzz/templates/serviceaccount.yaml
new file mode 100644
index 0000000000..60be80038f
--- /dev/null
+++ b/deploy/charts/buzz/templates/serviceaccount.yaml
@@ -0,0 +1,13 @@
+{{- include "buzz.validate" . -}}
+{{- if .Values.serviceAccount.create -}}
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+  name: {{ include "buzz.serviceAccountName" . }}
+  labels:
+    {{- include "buzz.labels" . | nindent 4 }}
+  {{- with .Values.serviceAccount.annotations }}
+  annotations:
+    {{- toYaml . | nindent 4 }}
+  {{- end }}
+{{- end }}
diff --git a/deploy/charts/buzz/tests/fixtures/ha-values.yaml b/deploy/charts/buzz/tests/fixtures/ha-values.yaml
new file mode 100644
index 0000000000..c1e014ae12
--- /dev/null
+++ b/deploy/charts/buzz/tests/fixtures/ha-values.yaml
@@ -0,0 +1,22 @@
+# HA shape: replicas=3 + Redis + RWX. Render-only check.
+relayUrl: wss://buzz.example.com
+ownerPubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
+replicaCount: 3
+secrets:
+  existingSecret: buzz-secrets
+externalPostgresql:
+  url: "postgres://buzz:pw@postgres.example.com:5432/buzz"
+externalRedis:
+  url: "redis://:pw@redis.example.com:6379"
+typesense:
+  url: "https://typesense.example.com"
+s3:
+  bucket: "buzz-media"
+persistence:
+  git:
+    enabled: true
+    accessMode: ReadWriteMany
+    size: 50Gi
+podDisruptionBudget:
+  enabled: true
+  minAvailable: 2
diff --git a/deploy/charts/buzz/tests/fixtures/production-existing-secret-values.yaml b/deploy/charts/buzz/tests/fixtures/production-existing-secret-values.yaml
new file mode 100644
index 0000000000..c875c84e98
--- /dev/null
+++ b/deploy/charts/buzz/tests/fixtures/production-existing-secret-values.yaml
@@ -0,0 +1,27 @@
+# Production / GitOps shape: external services, existingSecret. Renders only;
+# `ct install` is not asked to satisfy the external services.
+relayUrl: wss://buzz.example.com
+ownerPubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
+secrets:
+  existingSecret: buzz-secrets
+externalPostgresql:
+  url: "postgres://buzz:pw@postgres.example.com:5432/buzz"
+externalRedis:
+  url: "redis://:pw@redis.example.com:6379"
+typesense:
+  url: "https://typesense.example.com"
+s3:
+  endpoint: "https://s3.us-east-1.amazonaws.com"
+  bucket: "buzz-media"
+persistence:
+  git:
+    enabled: true
+    accessMode: ReadWriteMany
+    size: 50Gi
+ingress:
+  enabled: true
+  className: nginx
+  annotations:
+    nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
+podDisruptionBudget:
+  enabled: false
diff --git a/deploy/charts/buzz/tests/networking_test.yaml b/deploy/charts/buzz/tests/networking_test.yaml
new file mode 100644
index 0000000000..e925bbb2d3
--- /dev/null
+++ b/deploy/charts/buzz/tests/networking_test.yaml
@@ -0,0 +1,87 @@
+suite: networking
+templates:
+  - templates/ingress.yaml
+  - templates/httproute.yaml
+  - templates/service.yaml
+tests:
+  - it: Service exposes app/health/metrics ports
+    set:
+      relayUrl: wss://buzz.example.com
+      ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
+      externalPostgresql.url: postgres://u:p@h:5432/d
+      typesense.url: http://ts:8108
+      typesense.apiKey: k
+      s3.endpoint: http://minio:9000
+      s3.accessKey: a
+      s3.secretKey: s
+    asserts:
+      - equal:
+          path: spec.ports[0].name
+          value: app
+        template: templates/service.yaml
+      - equal:
+          path: spec.ports[0].port
+          value: 3000
+        template: templates/service.yaml
+
+  - it: Ingress disabled by default
+    set:
+      relayUrl: wss://buzz.example.com
+      ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
+      externalPostgresql.url: postgres://u:p@h:5432/d
+      typesense.url: http://ts:8108
+      typesense.apiKey: k
+      s3.endpoint: http://minio:9000
+      s3.accessKey: a
+      s3.secretKey: s
+    asserts:
+      - hasDocuments:
+          count: 0
+        template: templates/ingress.yaml
+      - hasDocuments:
+          count: 0
+        template: templates/httproute.yaml
+
+  - it: Ingress renders with derived host when relayUrl provided
+    set:
+      relayUrl: wss://buzz.example.com
+      ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
+      externalPostgresql.url: postgres://u:p@h:5432/d
+      typesense.url: http://ts:8108
+      typesense.apiKey: k
+      s3.endpoint: http://minio:9000
+      s3.accessKey: a
+      s3.secretKey: s
+      ingress.enabled: true
+      ingress.className: nginx
+    asserts:
+      - hasDocuments:
+          count: 1
+        template: templates/ingress.yaml
+      - equal:
+          path: spec.ingressClassName
+          value: nginx
+        template: templates/ingress.yaml
+
+  - it: HTTPRoute renders when enabled
+    set:
+      relayUrl: wss://buzz.example.com
+      ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
+      externalPostgresql.url: postgres://u:p@h:5432/d
+      typesense.url: http://ts:8108
+      typesense.apiKey: k
+      s3.endpoint: http://minio:9000
+      s3.accessKey: a
+      s3.secretKey: s
+      httproute.enabled: true
+      httproute.parentRefs:
+        - name: my-gateway
+          namespace: gateway-system
+    asserts:
+      - hasDocuments:
+          count: 1
+        template: templates/httproute.yaml
+      - equal:
+          path: kind
+          value: HTTPRoute
+        template: templates/httproute.yaml
diff --git a/deploy/charts/buzz/tests/quickstart_bundled_test.yaml b/deploy/charts/buzz/tests/quickstart_bundled_test.yaml
new file mode 100644
index 0000000000..7acb8ab753
--- /dev/null
+++ b/deploy/charts/buzz/tests/quickstart_bundled_test.yaml
@@ -0,0 +1,112 @@
+suite: quickstart bundled services
+# The dev quickstart must stand up MinIO + Typesense in-cluster so the relay's
+# startup S3 conformance probe passes with zero external dependencies.
+templates:
+  - templates/quickstart-minio.yaml
+  - templates/quickstart-minio-init.yaml
+  - templates/quickstart-typesense.yaml
+  - templates/deployment.yaml
+  - templates/secret-chart.yaml
+  - templates/service.yaml
+tests:
+  - it: renders the in-cluster MinIO Deployment when minio.enabled
+    release:
+      name: rel
+    set:
+      relayUrl: wss://buzz.example.com
+      ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
+      postgresql.enabled: true
+      redis.enabled: true
+      typesense.enabled: true
+      minio.enabled: true
+    asserts:
+      - containsDocument:
+          kind: Deployment
+          apiVersion: apps/v1
+          name: rel-buzz-minio
+        template: templates/quickstart-minio.yaml
+        documentIndex: 0
+      - containsDocument:
+          kind: Job
+          apiVersion: batch/v1
+          name: rel-buzz-minio-init
+        template: templates/quickstart-minio-init.yaml
+
+  - it: renders the in-cluster Typesense Deployment when typesense.enabled
+    release:
+      name: rel
+    set:
+      relayUrl: wss://buzz.example.com
+      ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
+      postgresql.enabled: true
+      redis.enabled: true
+      typesense.enabled: true
+      minio.enabled: true
+    asserts:
+      - containsDocument:
+          kind: Deployment
+          apiVersion: apps/v1
+          name: rel-buzz-typesense
+        template: templates/quickstart-typesense.yaml
+        documentIndex: 0
+
+  - it: relay S3 endpoint resolves to the bundled MinIO Service
+    release:
+      name: rel
+    set:
+      relayUrl: wss://buzz.example.com
+      ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
+      postgresql.enabled: true
+      redis.enabled: true
+      typesense.enabled: true
+      minio.enabled: true
+    asserts:
+      - contains:
+          path: spec.template.spec.containers[0].env
+          content:
+            name: BUZZ_S3_ENDPOINT
+            value: "http://rel-buzz-minio.NAMESPACE.svc.cluster.local:9000"
+        template: templates/deployment.yaml
+
+  - it: chart Secret composes TYPESENSE_URL at the bundled Typesense Service
+    release:
+      name: rel
+    set:
+      relayUrl: wss://buzz.example.com
+      ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
+      postgresql.enabled: true
+      redis.enabled: true
+      typesense.enabled: true
+      minio.enabled: true
+    asserts:
+      - matchRegex:
+          path: data.TYPESENSE_URL
+          decodeBase64: true
+          pattern: "^http://rel-buzz-typesense\\..+:8108$"
+        template: templates/secret-chart.yaml
+        documentIndex: 0
+      # MinIO creds the relay reads must exist for the conformance probe.
+      - isNotNullOrEmpty:
+          path: data.BUZZ_S3_ACCESS_KEY
+        template: templates/secret-chart.yaml
+        documentIndex: 0
+      - isNotNullOrEmpty:
+          path: data.BUZZ_S3_SECRET_KEY
+        template: templates/secret-chart.yaml
+        documentIndex: 0
+
+  - it: relay Service selector is scoped to component=relay (must NOT match bundled MinIO/Typesense pods)
+    release:
+      name: rel
+    set:
+      relayUrl: wss://buzz.example.com
+      ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
+      postgresql.enabled: true
+      redis.enabled: true
+      typesense.enabled: true
+      minio.enabled: true
+    asserts:
+      - equal:
+          path: spec.selector["app.kubernetes.io/component"]
+          value: relay
+        template: templates/service.yaml
diff --git a/deploy/charts/buzz/tests/quickstart_guards_test.yaml b/deploy/charts/buzz/tests/quickstart_guards_test.yaml
new file mode 100644
index 0000000000..9054cf5be3
--- /dev/null
+++ b/deploy/charts/buzz/tests/quickstart_guards_test.yaml
@@ -0,0 +1,35 @@
+suite: quickstart guards
+# Quickstart autogenerates MinIO/Typesense creds in the chart-managed Secret,
+# so it is mutually exclusive with secrets.existingSecret. Isolated single-
+# template suites so the fail-guard is the only document under assertion.
+tests:
+  - it: minio.enabled is incompatible with existingSecret
+    templates:
+      - templates/quickstart-minio.yaml
+    set:
+      relayUrl: wss://buzz.example.com
+      ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
+      postgresql.enabled: true
+      typesense.url: http://ts:8108
+      typesense.apiKey: k
+      minio.enabled: true
+      secrets.existingSecret: "buzz-secrets"
+    asserts:
+      - failedTemplate:
+          errorPattern: "incompatible with secrets.existingSecret"
+
+  - it: typesense.enabled is incompatible with existingSecret
+    templates:
+      - templates/quickstart-typesense.yaml
+    set:
+      relayUrl: wss://buzz.example.com
+      ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
+      postgresql.enabled: true
+      s3.endpoint: http://minio:9000
+      s3.accessKey: a
+      s3.secretKey: s
+      typesense.enabled: true
+      secrets.existingSecret: "buzz-secrets"
+    asserts:
+      - failedTemplate:
+          errorPattern: "incompatible with secrets.existingSecret"
diff --git a/deploy/charts/buzz/tests/render_test.yaml b/deploy/charts/buzz/tests/render_test.yaml
new file mode 100644
index 0000000000..970aeeab86
--- /dev/null
+++ b/deploy/charts/buzz/tests/render_test.yaml
@@ -0,0 +1,57 @@
+suite: production render
+# Multi-template scope: needed so $.Template.BasePath lookups in deployment.yaml
+# (e.g. checksum/secret include of secret-chart.yaml) resolve at render time.
+templates:
+  - templates/deployment.yaml
+  - templates/secret-chart.yaml
+  - templates/serviceaccount.yaml
+  - templates/service.yaml
+  - templates/pvc-git.yaml
+tests:
+  - it: renders cleanly in production profile (external pg/redis/typesense)
+    set:
+      relayUrl: wss://buzz.example.com
+      ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
+      externalPostgresql.url: postgres://u:p@h:5432/d
+      externalRedis.url: redis://h:6379
+      typesense.url: http://ts:8108
+      typesense.apiKey: k
+      s3.endpoint: http://minio:9000
+      s3.accessKey: a
+      s3.secretKey: s
+    asserts:
+      - equal:
+          path: kind
+          value: Deployment
+        template: templates/deployment.yaml
+      - equal:
+          path: kind
+          value: ServiceAccount
+        template: templates/serviceaccount.yaml
+      - equal:
+          path: kind
+          value: Service
+        template: templates/service.yaml
+
+  - it: renders HA cleanly with replicaCount=3 + RWX + Redis
+    set:
+      relayUrl: wss://buzz.example.com
+      ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
+      externalPostgresql.url: postgres://u:p@h:5432/d
+      externalRedis.url: redis://h:6379
+      typesense.url: http://ts:8108
+      typesense.apiKey: k
+      s3.endpoint: http://minio:9000
+      s3.accessKey: a
+      s3.secretKey: s
+      replicaCount: 3
+      persistence.git.accessMode: ReadWriteMany
+    asserts:
+      - equal:
+          path: spec.replicas
+          value: 3
+        template: templates/deployment.yaml
+      - equal:
+          path: spec.accessModes[0]
+          value: ReadWriteMany
+        template: templates/pvc-git.yaml
diff --git a/deploy/charts/buzz/tests/secrets_test.yaml b/deploy/charts/buzz/tests/secrets_test.yaml
new file mode 100644
index 0000000000..1a3690c1f1
--- /dev/null
+++ b/deploy/charts/buzz/tests/secrets_test.yaml
@@ -0,0 +1,151 @@
+suite: secrets wiring
+templates:
+  - templates/secret-chart.yaml
+  - templates/deployment.yaml
+tests:
+  - it: chart-managed Secret is rendered when existingSecret is empty
+    set:
+      relayUrl: wss://buzz.example.com
+      ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
+      externalPostgresql.url: postgres://u:p@h:5432/d
+      typesense.url: http://ts:8108
+      typesense.apiKey: k
+      s3.endpoint: http://minio:9000
+      s3.accessKey: a
+      s3.secretKey: s
+    asserts:
+      - hasDocuments:
+          count: 1
+        template: templates/secret-chart.yaml
+      - equal:
+          path: kind
+          value: Secret
+        template: templates/secret-chart.yaml
+      - equal:
+          path: metadata.annotations["helm.sh/resource-policy"]
+          value: keep
+        template: templates/secret-chart.yaml
+
+  - it: chart-managed Secret is NOT rendered when existingSecret is set
+    set:
+      relayUrl: wss://buzz.example.com
+      ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
+      externalPostgresql.url: postgres://u:p@h:5432/d
+      typesense.url: http://ts:8108
+      typesense.apiKey: k
+      s3.endpoint: http://minio:9000
+      s3.accessKey: a
+      s3.secretKey: s
+      secrets.existingSecret: "buzz-secrets"
+    asserts:
+      - hasDocuments:
+          count: 0
+        template: templates/secret-chart.yaml
+
+  - it: Deployment env points BUZZ_RELAY_PRIVATE_KEY at existingSecret when set
+    set:
+      relayUrl: wss://buzz.example.com
+      ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
+      externalPostgresql.url: postgres://u:p@h:5432/d
+      typesense.url: http://ts:8108
+      typesense.apiKey: k
+      s3.endpoint: http://minio:9000
+      s3.accessKey: a
+      s3.secretKey: s
+      secrets.existingSecret: "buzz-secrets"
+    asserts:
+      - contains:
+          path: spec.template.spec.containers[0].env
+          content:
+            name: BUZZ_RELAY_PRIVATE_KEY
+            valueFrom:
+              secretKeyRef:
+                name: buzz-secrets
+                key: BUZZ_RELAY_PRIVATE_KEY
+                optional: true
+        template: templates/deployment.yaml
+
+  - it: RELAY_OWNER_PUBKEY env is set (not BUZZ_RELAY_OWNER_PUBKEY)
+    set:
+      relayUrl: wss://buzz.example.com
+      ownerPubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
+      externalPostgresql.url: postgres://u:p@h:5432/d
+      typesense.url: http://ts:8108
+      typesense.apiKey: k
+      s3.endpoint: http://minio:9000
+      s3.accessKey: a
+      s3.secretKey: s
+    asserts:
+      - contains:
+          path: spec.template.spec.containers[0].env
+          content:
+            name: RELAY_OWNER_PUBKEY
+            value: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
+        template: templates/deployment.yaml
+      - notContains:
+          path: spec.template.spec.containers[0].env
+          content:
+            name: BUZZ_RELAY_OWNER_PUBKEY
+        template: templates/deployment.yaml
+
+  - it: BUZZ_AUTO_MIGRATE defaults to "true"
+    set:
+      relayUrl: wss://buzz.example.com
+      ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
+      externalPostgresql.url: postgres://u:p@h:5432/d
+      typesense.url: http://ts:8108
+      typesense.apiKey: k
+      s3.endpoint: http://minio:9000
+      s3.accessKey: a
+      s3.secretKey: s
+    asserts:
+      - contains:
+          path: spec.template.spec.containers[0].env
+          content:
+            name: BUZZ_AUTO_MIGRATE
+            value: "true"
+        template: templates/deployment.yaml
+
+  - it: quickstart composes DATABASE_URL/REDIS_URL at the actual subchart Service hosts
+    release:
+      name: rel
+    set:
+      relayUrl: wss://buzz.example.com
+      ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
+      postgresql.enabled: true
+      redis.enabled: true
+      typesense.url: http://ts:8108
+      typesense.apiKey: k
+      s3.endpoint: http://minio:9000
+      s3.accessKey: a
+      s3.secretKey: s
+    asserts:
+      # Postgres Service is "<release>-postgresql"; Redis (standalone) is
+      # "<release>-redis" — NOT "-redis-master". A host mismatch here is the
+      # connection-refused class of bug this guards against.
+      - matchRegex:
+          path: data.DATABASE_URL
+          decodeBase64: true
+          pattern: "@rel-postgresql:5432/buzz$"
+        template: templates/secret-chart.yaml
+      - matchRegex:
+          path: data.REDIS_URL
+          decodeBase64: true
+          pattern: "@rel-redis:6379$"
+        template: templates/secret-chart.yaml
+        documentIndex: 0
+      - notMatchRegex:
+          path: data.REDIS_URL
+          decodeBase64: true
+          pattern: "redis-master"
+        template: templates/secret-chart.yaml
+        documentIndex: 0
+      # Autogenerated relay key MUST be a valid Nostr secret key: 64 lowercase
+      # hex chars. randAlphaNum produces letters g-z that fail nostr::Keys::parse
+      # and crash relay startup with "invalid BUZZ_RELAY_PRIVATE_KEY".
+      - matchRegex:
+          path: data.BUZZ_RELAY_PRIVATE_KEY
+          decodeBase64: true
+          pattern: "^[0-9a-f]{64}$"
+        template: templates/secret-chart.yaml
+        documentIndex: 0
diff --git a/deploy/charts/buzz/tests/validation_test.yaml b/deploy/charts/buzz/tests/validation_test.yaml
new file mode 100644
index 0000000000..56e656ae85
--- /dev/null
+++ b/deploy/charts/buzz/tests/validation_test.yaml
@@ -0,0 +1,105 @@
+suite: validation
+templates:
+  - templates/deployment.yaml
+tests:
+  - it: fails when relayUrl is missing
+    set:
+      relayUrl: ""
+      ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
+      externalPostgresql.url: postgres://u:p@h:5432/d
+      typesense.url: http://ts:8108
+      typesense.apiKey: k
+    asserts:
+      - failedTemplate:
+          errorMessage: "relayUrl is required: set --set relayUrl=wss://your.domain"
+
+  - it: fails when ownerPubkey is missing and requireRelayMembership is true
+    set:
+      relayUrl: wss://buzz.example.com
+      ownerPubkey: ""
+      externalPostgresql.url: postgres://u:p@h:5432/d
+      typesense.url: http://ts:8108
+      typesense.apiKey: k
+    asserts:
+      - failedTemplate:
+          errorPattern: "ownerPubkey is required when relay.requireRelayMembership=true"
+
+  - it: fails when ownerPubkey is not 64 lowercase hex (schema-level)
+    set:
+      relayUrl: wss://buzz.example.com
+      ownerPubkey: "NOTAHEX"
+      externalPostgresql.url: postgres://u:p@h:5432/d
+      typesense.url: http://ts:8108
+      typesense.apiKey: k
+    asserts:
+      - failedTemplate:
+          errorPattern: "ownerPubkey: Does not match pattern"
+
+  - it: fails when replicaCount>1 without Redis
+    set:
+      relayUrl: wss://buzz.example.com
+      ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
+      externalPostgresql.url: postgres://u:p@h:5432/d
+      typesense.url: http://ts:8108
+      typesense.apiKey: k
+      replicaCount: 3
+    asserts:
+      - failedTemplate:
+          errorPattern: "replicaCount=3 requires Redis"
+
+  - it: fails when replicaCount>1 with RWO git PVC
+    set:
+      relayUrl: wss://buzz.example.com
+      ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
+      externalPostgresql.url: postgres://u:p@h:5432/d
+      externalRedis.url: redis://h:6379
+      typesense.url: http://ts:8108
+      typesense.apiKey: k
+      replicaCount: 3
+      persistence.git.accessMode: ReadWriteOnce
+    asserts:
+      - failedTemplate:
+          errorPattern: "requires persistence.git.accessMode=ReadWriteMany"
+
+  - it: fails when ingress and httproute both enabled
+    set:
+      relayUrl: wss://buzz.example.com
+      ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
+      externalPostgresql.url: postgres://u:p@h:5432/d
+      typesense.url: http://ts:8108
+      typesense.apiKey: k
+      ingress.enabled: true
+      httproute.enabled: true
+    asserts:
+      - failedTemplate:
+          errorPattern: "cannot both be true"
+
+  - it: fails when Postgres source is missing
+    set:
+      relayUrl: wss://buzz.example.com
+      ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
+      typesense.url: http://ts:8108
+      typesense.apiKey: k
+    asserts:
+      - failedTemplate:
+          errorPattern: "Postgres source missing"
+
+  - it: fails when Typesense source is missing
+    set:
+      relayUrl: wss://buzz.example.com
+      ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
+      externalPostgresql.url: postgres://u:p@h:5432/d
+    asserts:
+      - failedTemplate:
+          errorPattern: "Typesense source missing"
+
+  - it: fails when S3/object-storage source is missing
+    set:
+      relayUrl: wss://buzz.example.com
+      ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
+      externalPostgresql.url: postgres://u:p@h:5432/d
+      typesense.url: http://ts:8108
+      typesense.apiKey: k
+    asserts:
+      - failedTemplate:
+          errorPattern: "S3/object-storage source missing"
diff --git a/deploy/charts/buzz/values.schema.json b/deploy/charts/buzz/values.schema.json
new file mode 100644
index 0000000000..38f7b204dc
--- /dev/null
+++ b/deploy/charts/buzz/values.schema.json
@@ -0,0 +1,251 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "title": "Buzz Helm chart values",
+  "description": "Schema for values.yaml. Catches misconfiguration at `helm install` time before _validate.tpl runtime fails. Both layers are intentional: schema rejects malformed inputs; templates reject inconsistent combinations.",
+  "type": "object",
+  "additionalProperties": true,
+  "properties": {
+    "quickstart": {
+      "type": "boolean",
+      "description": "Master toggle for evaluation profile (enables postgresql + redis subcharts and chart-side autogen). Not GitOps-safe."
+    },
+    "image": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "repository": { "type": "string", "minLength": 1 },
+        "tag": { "type": "string" },
+        "pullPolicy": { "type": "string", "enum": ["Always", "IfNotPresent", "Never"] },
+        "pullSecrets": {
+          "type": "array",
+          "items": { "type": "object", "required": ["name"], "properties": { "name": { "type": "string" } } }
+        }
+      },
+      "required": ["repository", "pullPolicy"]
+    },
+    "replicaCount": {
+      "type": "integer",
+      "minimum": 1,
+      "description": "Replica count for the relay Deployment. replicaCount > 1 requires Redis (for buzz-pubsub) and ReadWriteMany git storage — enforced by _validate.tpl."
+    },
+    "relayUrl": {
+      "type": "string",
+      "pattern": "^(wss?://.+)?$",
+      "description": "Public wss:// URL clients connect to. Required (validated by _validate.tpl). Drives RELAY_URL, default mediaBaseUrl, default ingress host."
+    },
+    "mediaBaseUrl": {
+      "type": "string",
+      "pattern": "^(https?://.+)?$"
+    },
+    "ownerPubkey": {
+      "type": "string",
+      "pattern": "^([0-9a-f]{64})?$",
+      "description": "64-char lowercase hex Nostr pubkey of the relay operator. Required when relay.requireRelayMembership=true."
+    },
+    "secrets": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "existingSecret": { "type": "string", "description": "Name of an externally managed Secret. Production / GitOps path." },
+        "relayPrivateKey": { "type": "string" },
+        "gitHookHmacSecret": { "type": "string" }
+      }
+    },
+    "relay": {
+      "type": "object",
+      "additionalProperties": true,
+      "properties": {
+        "bindAddr": { "type": "string", "minLength": 1 },
+        "maxConnections": { "type": "integer", "minimum": 1 },
+        "maxConcurrentHandlers": { "type": "integer", "minimum": 1 },
+        "sendBuffer": { "type": "integer", "minimum": 1 },
+        "requireAuthToken": { "type": "boolean" },
+        "requireRelayMembership": { "type": "boolean" },
+        "allowNipOaAuth": { "type": "boolean" },
+        "pubkeyAllowlist": { "type": "boolean" },
+        "corsOrigins": {
+          "type": "array",
+          "items": { "type": "string" }
+        },
+        "ephemeralTtlOverride": { "type": "integer", "minimum": 0 }
+      }
+    },
+    "service": {
+      "type": "object",
+      "additionalProperties": true,
+      "properties": {
+        "type": { "type": "string", "enum": ["ClusterIP", "NodePort", "LoadBalancer"] },
+        "port": { "type": "integer", "minimum": 1, "maximum": 65535 },
+        "healthPort": { "type": "integer", "minimum": 1, "maximum": 65535 },
+        "metricsPort": { "type": "integer", "minimum": 1, "maximum": 65535 }
+      }
+    },
+    "serviceAccount": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "create": { "type": "boolean" },
+        "name": { "type": "string" },
+        "annotations": { "type": "object" }
+      }
+    },
+    "podDisruptionBudget": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "enabled": { "type": "boolean" },
+        "minAvailable": { "oneOf": [{ "type": "integer" }, { "type": "string" }] },
+        "maxUnavailable": { "oneOf": [{ "type": "integer" }, { "type": "string" }] }
+      }
+    },
+    "ingress": {
+      "type": "object",
+      "additionalProperties": true,
+      "properties": {
+        "enabled": { "type": "boolean" },
+        "className": { "type": "string" },
+        "annotations": { "type": "object" },
+        "hosts": { "type": "array" },
+        "tls": { "type": "array" }
+      }
+    },
+    "httproute": {
+      "type": "object",
+      "additionalProperties": true,
+      "properties": {
+        "enabled": { "type": "boolean" },
+        "parentRefs": { "type": "array" },
+        "hostnames": { "type": "array", "items": { "type": "string" } },
+        "rules": { "type": "array" }
+      }
+    },
+    "persistence": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "git": {
+          "type": "object",
+          "additionalProperties": false,
+          "properties": {
+            "enabled": { "type": "boolean" },
+            "mountPath": { "type": "string", "minLength": 1 },
+            "storageClass": { "type": "string" },
+            "accessMode": { "type": "string", "enum": ["ReadWriteOnce", "ReadWriteMany", "ReadOnlyMany", "ReadWriteOncePod"] },
+            "size": { "type": "string", "pattern": "^[0-9]+(\\.[0-9]+)?(E|P|T|G|M|K|Ei|Pi|Ti|Gi|Mi|Ki)?$" },
+            "annotations": { "type": "object" },
+            "existingClaim": { "type": "string" }
+          }
+        }
+      }
+    },
+    "postgresql": {
+      "type": "object",
+      "additionalProperties": true,
+      "properties": { "enabled": { "type": "boolean" } }
+    },
+    "externalPostgresql": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "url": { "type": "string", "pattern": "^(postgres(ql)?://.+)?$" }
+      }
+    },
+    "redis": {
+      "type": "object",
+      "additionalProperties": true,
+      "properties": { "enabled": { "type": "boolean" } }
+    },
+    "externalRedis": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "url": { "type": "string", "pattern": "^(rediss?://.+)?$" }
+      }
+    },
+    "typesense": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "enabled": { "type": "boolean" },
+        "url": { "type": "string", "pattern": "^(https?://.+)?$" },
+        "apiKey": { "type": "string" },
+        "image": { "type": "string", "minLength": 1 },
+        "persistence": {
+          "type": "object",
+          "additionalProperties": false,
+          "properties": {
+            "enabled": { "type": "boolean" },
+            "size": { "type": "string", "pattern": "^[0-9]+(\\.[0-9]+)?(E|P|T|G|M|K|Ei|Pi|Ti|Gi|Mi|Ki)?$" }
+          }
+        }
+      }
+    },
+    "s3": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "endpoint": { "type": "string", "pattern": "^(https?://.+)?$" },
+        "bucket": { "type": "string", "minLength": 1 },
+        "accessKey": { "type": "string" },
+        "secretKey": { "type": "string" }
+      }
+    },
+    "minio": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "enabled": { "type": "boolean" },
+        "image": { "type": "string", "minLength": 1 },
+        "mcImage": { "type": "string", "minLength": 1 },
+        "persistence": {
+          "type": "object",
+          "additionalProperties": false,
+          "properties": {
+            "enabled": { "type": "boolean" },
+            "size": { "type": "string", "pattern": "^[0-9]+(\\.[0-9]+)?(E|P|T|G|M|K|Ei|Pi|Ti|Gi|Mi|Ki)?$" }
+          }
+        }
+      }
+    },
+    "git": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "maxPackBytes": { "type": "integer", "minimum": 1 },
+        "maxReposPerPubkey": { "type": "integer", "minimum": 1 },
+        "maxConcurrentOps": { "type": "integer", "minimum": 1 }
+      }
+    },
+    "migrate": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "autoMigrate": { "type": "boolean" },
+        "preUpgradeJob": {
+          "type": "object",
+          "additionalProperties": false,
+          "properties": {
+            "enabled": { "type": "boolean" },
+            "resources": { "type": "object" },
+            "backoffLimit": { "type": "integer", "minimum": 0 },
+            "activeDeadlineSeconds": { "type": "integer", "minimum": 1 }
+          }
+        }
+      }
+    },
+    "serviceMonitor": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "enabled": { "type": "boolean" },
+        "namespace": { "type": "string" },
+        "interval": { "type": "string" },
+        "scrapeTimeout": { "type": "string" },
+        "labels": { "type": "object" }
+      }
+    },
+    "extraManifests": {
+      "type": "array"
+    }
+  }
+}
diff --git a/deploy/charts/buzz/values.yaml b/deploy/charts/buzz/values.yaml
new file mode 100644
index 0000000000..15a70ec288
--- /dev/null
+++ b/deploy/charts/buzz/values.yaml
@@ -0,0 +1,275 @@
+# Default values for buzz.
+#
+# Two supported tiers:
+#
+#   PRODUCTION (default) — external Postgres/Redis/Typesense/S3, existingSecret
+#   refs everywhere, no chart-side autogeneration, GitOps-safe (ArgoCD/Flux).
+#   HA-ready: replicaCount >= 2 (requires Redis and RWX storage for git).
+#
+#   QUICKSTART — bundles in-cluster Postgres + Redis + MinIO + Typesense and
+#   auto-generates relay secrets via the `lookup` pattern (NOT GitOps-safe —
+#   see README), single replica, evaluation only. Opt in by enabling each
+#   bundled service: postgresql.enabled, redis.enabled, minio.enabled,
+#   typesense.enabled. See ci/quickstart-values.yaml and the README.
+#
+# See examples/argocd-app.yaml and examples/flux-helmrelease.yaml for the
+# canonical GitOps configurations.
+
+# Intent marker for the evaluation profile, surfaced in NOTES.txt. It does NOT
+# by itself enable any bundled service — set the per-service .enabled flags
+# (postgresql / redis / minio / typesense) to bring them up in-cluster.
+quickstart: false
+
+# ── Image ────────────────────────────────────────────────────────────────────
+image:
+  repository: ghcr.io/block/buzz
+  tag: ""                        # empty → .Chart.AppVersion
+  pullPolicy: IfNotPresent
+  pullSecrets: []
+
+# ── Topology ────────────────────────────────────────────────────────────────
+# replicaCount > 1 hard-requires:
+#   - Redis for buzz-pubsub (in-cluster or external)
+#   - ReadWriteMany storage for the git PVC (or shared FS via existingClaim)
+replicaCount: 1
+
+# ── Public URL ───────────────────────────────────────────────────────────────
+# Required. The wss:// URL clients use to connect. Drives:
+#   - RELAY_URL env (relay-side)
+#   - Default mediaBaseUrl (https://<host>/media)
+#   - Default ingress host
+relayUrl: ""
+mediaBaseUrl: ""
+
+# ── Owner ────────────────────────────────────────────────────────────────────
+# 64-char lowercase hex Nostr pubkey of the relay operator. Required when
+# relay.requireRelayMembership=true (the production default).
+ownerPubkey: ""
+
+# ── Chart-managed secrets ────────────────────────────────────────────────────
+# Production / GitOps path: create a Secret out-of-band with these keys and
+# point `secrets.existingSecret` at it. Any key omitted from the existing
+# Secret falls back to chart-side autogen (only effective at first install).
+#
+# Expected keys (all optional unless required by relay config):
+#   BUZZ_RELAY_PRIVATE_KEY        — 64-char hex; relay identity (rotation = identity change)
+#   BUZZ_GIT_HOOK_HMAC_SECRET     — 32+ chars; required when replicaCount > 1
+#   DATABASE_URL                  — full Postgres URL (preferred over externalPostgresql.url)
+#   REDIS_URL                     — full Redis URL with auth
+#   TYPESENSE_URL                 — Typesense base URL
+#   TYPESENSE_API_KEY             — Typesense API key
+#   BUZZ_S3_ACCESS_KEY            — S3 access key
+#   BUZZ_S3_SECRET_KEY            — S3 secret key
+secrets:
+  existingSecret: ""
+  # Inline overrides (NOT recommended for production; they land in values).
+  relayPrivateKey: ""
+  gitHookHmacSecret: ""
+
+# ── Relay behavior ───────────────────────────────────────────────────────────
+relay:
+  bindAddr: "0.0.0.0:3000"
+  maxConnections: 10000
+  maxConcurrentHandlers: 1024
+  sendBuffer: 1000
+  requireAuthToken: true
+  requireRelayMembership: true
+  allowNipOaAuth: true
+  pubkeyAllowlist: false
+  corsOrigins: []
+  ephemeralTtlOverride: 0
+
+  livenessProbe:
+    httpGet:
+      path: /_liveness
+      port: health
+    initialDelaySeconds: 5
+    periodSeconds: 10
+    timeoutSeconds: 3
+    failureThreshold: 3
+  readinessProbe:
+    httpGet:
+      path: /_readiness
+      port: health
+    initialDelaySeconds: 5
+    periodSeconds: 5
+    timeoutSeconds: 3
+    failureThreshold: 3
+  startupProbe:
+    httpGet:
+      path: /_liveness
+      port: health
+    failureThreshold: 60
+    periodSeconds: 2
+
+  resources:
+    requests:
+      cpu: "500m"
+      memory: "512Mi"
+    limits:
+      cpu: "2"
+      memory: "2Gi"
+
+  podAnnotations: {}
+  podLabels: {}
+  nodeSelector: {}
+  tolerations: []
+  affinity: {}
+  topologySpreadConstraints: []
+  securityContext:
+    runAsNonRoot: true
+    runAsUser: 65532
+    runAsGroup: 65532
+    fsGroup: 65532
+    seccompProfile:
+      type: RuntimeDefault
+  containerSecurityContext:
+    allowPrivilegeEscalation: false
+    capabilities:
+      drop: [ALL]
+    readOnlyRootFilesystem: false  # git writes need a writable repo path
+  terminationGracePeriodSeconds: 60
+
+  extraEnv: []
+  extraEnvFrom: []
+
+# ── Service ──────────────────────────────────────────────────────────────────
+service:
+  type: ClusterIP
+  port: 3000
+  healthPort: 8080
+  metricsPort: 9102
+  annotations: {}
+
+serviceAccount:
+  create: true
+  name: ""
+  annotations: {}
+
+podDisruptionBudget:
+  enabled: true
+  minAvailable: 1
+  maxUnavailable: ""
+
+# ── Ingress (classic) ────────────────────────────────────────────────────────
+# Mutually exclusive with httproute.enabled.
+ingress:
+  enabled: false
+  className: ""
+  annotations: {}
+  hosts: []                      # empty → derived from relayUrl
+  tls: []                        # [{hosts: [...], secretName: "..."}]
+
+# ── Gateway API (HTTPRoute) ──────────────────────────────────────────────────
+httproute:
+  enabled: false
+  parentRefs: []
+  hostnames: []
+  rules: []                      # empty → default match-all → service
+
+# ── Git on-disk state ────────────────────────────────────────────────────────
+persistence:
+  git:
+    enabled: true
+    mountPath: /var/lib/buzz/git
+    storageClass: ""
+    accessMode: ReadWriteOnce    # MUST be ReadWriteMany if replicaCount > 1
+    size: 10Gi
+    annotations: {}
+    existingClaim: ""
+
+# ── Postgres ─────────────────────────────────────────────────────────────────
+# Eval-only CloudPirates subchart. The relay's DATABASE_URL is composed in the
+# chart-managed Secret with a chart-generated password; auth.existingSecret
+# points this subchart at that same Secret/key so server and client agree.
+postgresql:
+  enabled: false
+  auth:
+    database: buzz
+    username: buzz
+    existingSecret: '{{ if contains "buzz" .Release.Name }}{{ .Release.Name }}-relay{{ else }}{{ .Release.Name }}-buzz-relay{{ end }}'
+    secretKeys:
+      adminPasswordKey: postgres-password
+  persistence:
+    enabled: true
+    size: 10Gi
+externalPostgresql:
+  url: ""                        # postgres://user:pass@host:5432/db
+
+# ── Redis ────────────────────────────────────────────────────────────────────
+# Eval-only CloudPirates subchart (standalone). REDIS_URL is composed in the
+# chart-managed Secret; auth.existingSecret points the subchart at that Secret
+# so the server password matches the URL the relay dials.
+redis:
+  enabled: false
+  auth:
+    existingSecret: '{{ if contains "buzz" .Release.Name }}{{ .Release.Name }}-relay{{ else }}{{ .Release.Name }}-buzz-relay{{ end }}'
+    existingSecretPasswordKey: redis-password
+  persistence:
+    enabled: true
+    size: 4Gi
+externalRedis:
+  url: ""                        # redis://:pass@host:6379
+
+# ── Typesense ────────────────────────────────────────────────────────────────
+# Production: point url/apiKey at an external Typesense service (or supply
+# TYPESENSE_URL/TYPESENSE_API_KEY via secrets.existingSecret).
+# Quickstart (`enabled: true`): the chart runs an in-cluster, eval-only
+# Typesense Deployment and composes TYPESENSE_URL with an autogenerated key.
+typesense:
+  enabled: false                 # quickstart: set true for bundled in-cluster Typesense
+  url: ""
+  apiKey: ""
+  image: typesense/typesense:30.2
+  persistence:
+    enabled: true
+    size: 4Gi
+
+# ── S3 / object storage (media) ──────────────────────────────────────────────
+# Production: point endpoint/bucket at an external S3-compatible service and
+# supply credentials (inline below or via secrets.existingSecret).
+# Quickstart (`minio.enabled: true`): the chart runs an in-cluster, eval-only
+# MinIO Deployment, creates the bucket via a post-install Job, and composes
+# the endpoint + autogenerated credentials automatically.
+s3:
+  endpoint: ""
+  bucket: "buzz-media"
+  accessKey: ""
+  secretKey: ""
+
+# In-cluster MinIO for the quickstart profile only. Production deploys leave
+# this disabled and use s3.* (or secrets.existingSecret) against managed S3.
+minio:
+  enabled: false                 # quickstart: set true for bundled in-cluster MinIO
+  image: minio/minio:RELEASE.2025-09-07T16-13-09Z
+  mcImage: minio/mc:RELEASE.2025-08-13T08-35-41Z
+  persistence:
+    enabled: true
+    size: 10Gi
+
+# ── Git server config ────────────────────────────────────────────────────────
+git:
+  maxPackBytes: 524288000        # 500 MiB
+  maxReposPerPubkey: 100
+  maxConcurrentOps: 20
+
+# ── Migrations ───────────────────────────────────────────────────────────────
+# Relay runs sqlx migrations at startup via BUZZ_AUTO_MIGRATE=true.
+migrate:
+  autoMigrate: true
+  preUpgradeJob:
+    enabled: false
+    resources: {}
+    backoffLimit: 3
+    activeDeadlineSeconds: 600
+
+# ── Monitoring ───────────────────────────────────────────────────────────────
+serviceMonitor:
+  enabled: false
+  namespace: ""
+  interval: 30s
+  scrapeTimeout: 10s
+  labels: {}
+
+# ── Free-form extra manifests ────────────────────────────────────────────────
+extraManifests: []
