diff --git a/.github/workflows/auto-tag-on-release-pr-merge.yml b/.github/workflows/auto-tag-on-release-pr-merge.yml
index 0a86d289d6..2add6e003f 100644
--- a/.github/workflows/auto-tag-on-release-pr-merge.yml
+++ b/.github/workflows/auto-tag-on-release-pr-merge.yml
@@ -1,11 +1,14 @@
 name: Auto-tag on Release PR Merge
 
-# Four release lanes share this one workflow — adding a lane is one more branch
-# prefix, never a forked copy:
+# Four release lanes share this one workflow. Three use an explicit branch
+# prefix; the chart lane also auto-detects a Chart.yaml version bump so a chart
+# feature PR can publish its own new version when merged:
 #
 #   version-bump/<v>   → tag v<v>        → dispatch release.yml (desktop app)
 #   relay-release/<v>  → tag relay-v<v>  → dispatch docker.yml (relay image)
 #   chart-release/<v>  → tag chart-v<v>  → dispatch helm-chart.yml (helm chart)
+#   any internal PR that bumps deploy/charts/buzz/Chart.yaml `version`
+#                       → tag chart-v<v>  → dispatch helm-chart.yml (helm chart)
 #   mobile-release/<v> → tag mobile-v<v> → (manual sprout_ref for buzz-releases build — see below)
 #
 # The desktop, relay, and chart lanes dispatch their build workflow rather than
@@ -36,10 +39,6 @@ jobs:
   auto-tag:
     if: >
       github.event.pull_request.merged == true &&
-      (startsWith(github.event.pull_request.head.ref, 'version-bump/') ||
-       startsWith(github.event.pull_request.head.ref, 'relay-release/') ||
-       startsWith(github.event.pull_request.head.ref, 'chart-release/') ||
-       startsWith(github.event.pull_request.head.ref, 'mobile-release/')) &&
       github.event.pull_request.head.repo.full_name == github.repository
     runs-on: ubuntu-latest
     steps:
@@ -48,12 +47,14 @@ jobs:
           ref: ${{ github.event.pull_request.merge_commit_sha }}
           fetch-depth: 0
 
-      - name: Resolve lane and version from branch name
+      - name: Resolve release lane and version
+        id: release
         env:
           BRANCH: ${{ github.event.pull_request.head.ref }}
         run: |
-          # Lane is decided by branch prefix: the tag prefix and whether a
-          # downstream workflow_dispatch is needed both follow from it.
+          # Explicit release branches retain their established behavior. For an
+          # ordinary internal PR, publish only when Chart.yaml itself changed
+          # and its version differs from the PR's base commit.
           case "$BRANCH" in
             version-bump/*)
               VERSION="${BRANCH#version-bump/}"
@@ -72,21 +73,33 @@ jobs:
               TAG_PREFIX="mobile-v"
               DISPATCH="" ;;
             *)
-              echo "::error::Unhandled branch prefix: '$BRANCH'"
-              exit 1 ;;
+              parent_sha="$(git rev-parse HEAD^)"
+              old_version="$(git show "${parent_sha}:deploy/charts/buzz/Chart.yaml" 2>/dev/null | awk '/^version:/ {print $2}')"
+              VERSION="$(awk '/^version:/ {print $2}' deploy/charts/buzz/Chart.yaml)"
+              if [ -z "$old_version" ] || [ -z "$VERSION" ] || [ "$old_version" = "$VERSION" ]; then
+                echo "No release branch or chart version bump — nothing to tag"
+                echo "enabled=false" >> "$GITHUB_OUTPUT"
+                exit 0
+              fi
+              TAG_PREFIX="chart-v"
+              DISPATCH="helm-chart" ;;
           esac
           if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
-            echo "::error::Invalid version in branch name: '$VERSION'"
+            echo "::error::Invalid release version: '$VERSION'"
             exit 1
           fi
-          echo "version=$VERSION" >> "$GITHUB_ENV"
-          echo "tag=${TAG_PREFIX}${VERSION}" >> "$GITHUB_ENV"
-          echo "dispatch=$DISPATCH" >> "$GITHUB_ENV"
+          {
+            echo "enabled=true"
+            echo "version=$VERSION"
+            echo "tag=${TAG_PREFIX}${VERSION}"
+            echo "dispatch=$DISPATCH"
+          } >> "$GITHUB_OUTPUT"
           echo "Tagging ${TAG_PREFIX}${VERSION}"
 
       - name: Create and push tag
+        if: steps.release.outputs.enabled == 'true'
         env:
-          TAG: ${{ env.tag }}
+          TAG: ${{ steps.release.outputs.tag }}
         run: |
           EXISTING_SHA="$(git ls-remote --tags origin "refs/tags/$TAG" | awk '{print $1}')"
           if [ -n "$EXISTING_SHA" ]; then
@@ -104,16 +117,16 @@ jobs:
           git push origin "$TAG"
 
       - name: Trigger release build
-        # The desktop and relay lanes dispatch their build workflow because the
-        # consumer's on:push:tags trigger is dead for auto-pushed tags (default
-        # GITHUB_TOKEN, recursion guard — see header comment). Mobile has no
+        # The desktop, relay, and chart lanes dispatch their build workflow
+        # because the consumer's on:push:tags trigger is dead for tags pushed
+        # by GITHUB_TOKEN (recursion guard — see header comment). Mobile has no
         # consumer yet, so dispatch="" skips this step entirely.
-        if: ${{ env.dispatch != '' }}
+        if: steps.release.outputs.enabled == 'true' && steps.release.outputs.dispatch != ''
         env:
           GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          DISPATCH: ${{ env.dispatch }}
-          VERSION: ${{ env.version }}
-          TAG: ${{ env.tag }}
+          DISPATCH: ${{ steps.release.outputs.dispatch }}
+          VERSION: ${{ steps.release.outputs.version }}
+          TAG: ${{ steps.release.outputs.tag }}
         run: |
           # Both workflows take the bare version (for the build) and the tag ref
           # (so the dispatch builds the tagged commit, not main).
diff --git a/Dockerfile b/Dockerfile
index df8f29214c..6043b22732 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -45,8 +45,10 @@ RUN cargo chef cook --release --recipe-path recipe.json
 COPY . .
 RUN cargo build --release --locked -p buzz-relay --bin buzz-relay \
                                    -p buzz-admin --bin buzz-admin \
+                                   -p buzz-pair-relay --bin buzz-pair-relay \
     && strip target/release/buzz-relay \
-    && strip target/release/buzz-admin
+    && strip target/release/buzz-admin \
+    && strip target/release/buzz-pair-relay
 
 # ─── Stage 4: web bundle (pnpm + vite) ──────────────────────────────────────
 # Independent of the Rust layers so a CSS change doesn't bust Rust cache and
@@ -87,6 +89,7 @@ RUN apt-get update \
 
 COPY --from=builder    /build/target/release/buzz-relay /usr/local/bin/buzz-relay
 COPY --from=builder    /build/target/release/buzz-admin /usr/local/bin/buzz-admin
+COPY --from=builder    /build/target/release/buzz-pair-relay /usr/local/bin/buzz-pair-relay
 COPY --from=web-builder /build/web/dist                 /srv/buzz/web
 
 ENV BUZZ_WEB_DIR=/srv/buzz/web
diff --git a/crates/buzz-pair-relay/src/main.rs b/crates/buzz-pair-relay/src/main.rs
index 1e81756e3b..4e10d0940f 100644
--- a/crates/buzz-pair-relay/src/main.rs
+++ b/crates/buzz-pair-relay/src/main.rs
@@ -6,7 +6,15 @@ use tokio::net::TcpListener;
 
 #[tokio::main]
 async fn main() {
-    let addr: SocketAddr = ([127, 0, 0, 1], 5000).into();
+    let addr_raw =
+        std::env::var("BUZZ_PAIR_RELAY_BIND_ADDR").unwrap_or_else(|_| "127.0.0.1:5000".to_string());
+    let addr: SocketAddr = match addr_raw.parse() {
+        Ok(addr) => addr,
+        Err(e) => {
+            eprintln!("fatal: invalid BUZZ_PAIR_RELAY_BIND_ADDR {addr_raw:?}: {e}");
+            std::process::exit(1);
+        }
+    };
     let listener = match TcpListener::bind(addr).await {
         Ok(l) => l,
         Err(e) => {
diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs
index 24e9604f6c..c9624fac7d 100644
--- a/crates/buzz-relay/src/config.rs
+++ b/crates/buzz-relay/src/config.rs
@@ -33,6 +33,8 @@ pub struct Config {
     pub redis_url: String,
     /// Public WebSocket URL of this relay, advertised in NIP-11.
     pub relay_url: String,
+    /// Public WebSocket URL of the dedicated device-pairing relay, when configured.
+    pub pairing_relay_url: Option<String>,
     /// Maximum number of concurrent WebSocket connections.
     pub max_connections: usize,
     /// Maximum number of concurrently executing message handlers.
@@ -234,6 +236,25 @@ impl Config {
         let relay_url =
             std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string());
 
+        let pairing_relay_url = std::env::var("BUZZ_PAIRING_RELAY_URL")
+            .ok()
+            .map(|value| value.trim().to_string())
+            .filter(|value| !value.is_empty())
+            .map(|value| {
+                let parsed = url::Url::parse(&value).map_err(|e| {
+                    ConfigError::InvalidValue(format!(
+                        "BUZZ_PAIRING_RELAY_URL must be a valid ws:// or wss:// URL: {e}"
+                    ))
+                })?;
+                if !matches!(parsed.scheme(), "ws" | "wss") || parsed.host_str().is_none() {
+                    return Err(ConfigError::InvalidValue(
+                        "BUZZ_PAIRING_RELAY_URL must be a valid ws:// or wss:// URL".to_string(),
+                    ));
+                }
+                Ok(value)
+            })
+            .transpose()?;
+
         let max_connections = std::env::var("BUZZ_MAX_CONNECTIONS")
             .ok()
             .and_then(|v| v.parse().ok())
@@ -508,6 +529,7 @@ impl Config {
             database_url,
             redis_url,
             relay_url,
+            pairing_relay_url,
             max_connections,
             max_concurrent_handlers,
             send_buffer_size,
@@ -676,6 +698,25 @@ mod tests {
         ));
     }
 
+    #[test]
+    fn pairing_relay_url_accepts_websocket_urls_and_rejects_http() {
+        let _guard = ENV_MUTEX.lock().unwrap();
+        std::env::set_var("BUZZ_PAIRING_RELAY_URL", "wss://pairing.buzz.xyz");
+        let config = Config::from_env().expect("config");
+        assert_eq!(
+            config.pairing_relay_url.as_deref(),
+            Some("wss://pairing.buzz.xyz")
+        );
+
+        std::env::set_var("BUZZ_PAIRING_RELAY_URL", "https://pairing.buzz.xyz");
+        let result = Config::from_env();
+        std::env::remove_var("BUZZ_PAIRING_RELAY_URL");
+        assert!(matches!(
+            result,
+            Err(ConfigError::InvalidValue(ref msg)) if msg.contains("BUZZ_PAIRING_RELAY_URL")
+        ));
+    }
+
     #[test]
     fn max_frame_bytes_can_be_configured() {
         let _guard = ENV_MUTEX.lock().unwrap();
diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs
index 32e178b168..6241bfb2b4 100644
--- a/crates/buzz-relay/src/nip11.rs
+++ b/crates/buzz-relay/src/nip11.rs
@@ -46,6 +46,9 @@ pub struct RelayInfo {
     pub version: String,
     /// Protocol and resource limits advertised to clients.
     pub limitation: Option<RelayLimitation>,
+    /// Public WebSocket URL of the dedicated NIP-AB device-pairing relay.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub pairing_relay_url: Option<String>,
     /// Relay's own signing pubkey (NIP-11 `self` field, NIP-43).
     #[serde(rename = "self", skip_serializing_if = "Option::is_none")]
     pub relay_self: Option<String>,
@@ -132,6 +135,7 @@ impl RelayInfo {
         icon: Option<&str>,
         advertise_nip43: bool,
         max_message_length: usize,
+        pairing_relay_url: Option<&str>,
     ) -> Self {
         debug_assert!(
             !advertise_nip43 || relay_self.is_some(),
@@ -154,6 +158,7 @@ impl RelayInfo {
             software: "https://github.com/block/buzz".to_string(),
             version: env!("CARGO_PKG_VERSION").to_string(),
             limitation: Some(relay_limitation(max_message_length)),
+            pairing_relay_url: pairing_relay_url.map(str::to_string),
             relay_self: relay_self.map(|s| s.to_string()),
         }
     }
@@ -185,6 +190,7 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st
         icon.as_deref(),
         advertise_nip43,
         state.config.max_frame_bytes,
+        state.config.pairing_relay_url.as_deref(),
     )
 }
 
@@ -248,11 +254,13 @@ pub(crate) fn nip11_facts(state: &crate::state::AppState) -> (Option<String>, bo
 /// hard build break, the same way a deny-lint would. If you must change this
 /// signature, you are changing the conformance contract: update the conformance
 /// doc and prove the new input is host-scoped, not unscoped, first.
+#[allow(clippy::type_complexity)]
 const _RELAY_INFO_BUILD_STATIC_INPUT_FENCE: fn(
     Option<&str>,
     Option<&str>,
     bool,
     usize,
+    Option<&str>,
 ) -> RelayInfo = RelayInfo::build;
 
 #[cfg(test)]
@@ -291,10 +299,31 @@ mod tests {
 
     #[test]
     fn build_advertises_buzz_repository_url() {
-        let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES);
+        let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None);
         assert_eq!(info.software, "https://github.com/block/buzz");
     }
 
+    #[test]
+    fn configured_pairing_relay_is_advertised_and_unset_value_is_omitted() {
+        let info = RelayInfo::build(
+            None,
+            None,
+            false,
+            DEFAULT_MAX_FRAME_BYTES,
+            Some("wss://pairing.buzz.xyz"),
+        );
+        let json = serde_json::to_value(&info).expect("serialize");
+        assert_eq!(
+            json.get("pairing_relay_url")
+                .and_then(|value| value.as_str()),
+            Some("wss://pairing.buzz.xyz")
+        );
+
+        let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None);
+        let json = serde_json::to_value(&info).expect("serialize");
+        assert!(json.get("pairing_relay_url").is_none());
+    }
+
     /// NIP-WP → NIP-11 mirror: a set workspace icon is served in the standard
     /// `icon` field; no icon (or a cleared, empty icon) omits the field
     /// entirely so the JSON matches pre-icon documents byte-for-byte.
@@ -305,6 +334,7 @@ mod tests {
             Some("data:image/webp;base64,UklGRg=="),
             false,
             DEFAULT_MAX_FRAME_BYTES,
+            None,
         );
         assert_eq!(
             info.icon.as_deref(),
@@ -317,7 +347,7 @@ mod tests {
         );
 
         for icon in [None, Some("")] {
-            let info = RelayInfo::build(None, icon, false, DEFAULT_MAX_FRAME_BYTES);
+            let info = RelayInfo::build(None, icon, false, DEFAULT_MAX_FRAME_BYTES, None);
             assert!(info.icon.is_none());
             let json = serde_json::to_value(&info).expect("serialize");
             assert!(
@@ -337,7 +367,7 @@ mod tests {
 
     #[test]
     fn max_message_length_uses_configured_frame_limit() {
-        let info = RelayInfo::build(None, None, false, 262_144);
+        let info = RelayInfo::build(None, None, false, 262_144, None);
         let limitation = info.limitation.expect("limitation");
         assert_eq!(limitation.max_message_length, Some(262_144));
     }
@@ -368,7 +398,7 @@ mod tests {
     /// Open relay, ephemeral key — both `self` and NIP-43 are absent.
     #[test]
     fn build_open_relay_ephemeral_key_omits_self_and_nip43() {
-        let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES);
+        let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None);
         assert!(info.relay_self.is_none());
         assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP));
     }
@@ -381,7 +411,7 @@ mod tests {
     #[test]
     fn build_open_relay_stable_key_advertises_self_but_not_nip43() {
         let pk = "0000000000000000000000000000000000000000000000000000000000000001";
-        let info = RelayInfo::build(Some(pk), None, false, DEFAULT_MAX_FRAME_BYTES);
+        let info = RelayInfo::build(Some(pk), None, false, DEFAULT_MAX_FRAME_BYTES, None);
         assert_eq!(info.relay_self.as_deref(), Some(pk));
         assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP));
     }
@@ -390,7 +420,7 @@ mod tests {
     #[test]
     fn build_membership_relay_advertises_self_and_nip43() {
         let pk = "0000000000000000000000000000000000000000000000000000000000000001";
-        let info = RelayInfo::build(Some(pk), None, true, DEFAULT_MAX_FRAME_BYTES);
+        let info = RelayInfo::build(Some(pk), None, true, DEFAULT_MAX_FRAME_BYTES, None);
         assert_eq!(info.relay_self.as_deref(), Some(pk));
         assert!(info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP));
     }
@@ -401,6 +431,6 @@ mod tests {
     #[test]
     #[should_panic(expected = "advertise_nip43=true requires relay_self=Some")]
     fn build_nip43_without_self_panics_in_debug() {
-        let _ = RelayInfo::build(None, None, true, DEFAULT_MAX_FRAME_BYTES);
+        let _ = RelayInfo::build(None, None, true, DEFAULT_MAX_FRAME_BYTES, None);
     }
 }
diff --git a/deploy/charts/buzz/Chart.yaml b/deploy/charts/buzz/Chart.yaml
index b3ad26a5db..68743d8505 100644
--- a/deploy/charts/buzz/Chart.yaml
+++ b/deploy/charts/buzz/Chart.yaml
@@ -7,7 +7,7 @@ description: |
   PostgreSQL and Redis. Configurable for single-node evaluation
   (subcharts on) and HA production (external services, existingSecret).
 type: application
-version: 0.1.2
+version: 0.1.3
 appVersion: "0.1.0"
 home: https://github.com/block/buzz
 sources:
diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md
index 010b3dffde..81ab7ca096 100644
--- a/deploy/charts/buzz/README.md
+++ b/deploy/charts/buzz/README.md
@@ -52,6 +52,22 @@ See:
 
 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`).
 
+## Device pairing relay
+
+The chart can run Buzz's stateless pairing WebSocket relay as an independent
+Deployment and Service using the same image as the main relay:
+
+```yaml
+pairingRelay:
+  enabled: true
+  url: wss://pairing.example.com
+```
+
+`pairingRelay.url` is advertised in the main relay's NIP-11 document so Buzz
+clients connect directly to the dedicated endpoint. The chart does not create
+an Ingress or HTTPRoute for the pairing Service; route the public hostname to
+`<release>-buzz-pairing:5000` with your platform's ingress configuration.
+
 ## HA (production)
 
 `replicaCount > 1` hard-requires Redis:
diff --git a/deploy/charts/buzz/templates/_helpers.tpl b/deploy/charts/buzz/templates/_helpers.tpl
index ccbb8090f9..6e3f52a580 100644
--- a/deploy/charts/buzz/templates/_helpers.tpl
+++ b/deploy/charts/buzz/templates/_helpers.tpl
@@ -121,3 +121,8 @@ secrets.existingSecret, use that. Otherwise use the chart-managed one.
 {{- include "buzz.minioEndpoint" . -}}
 {{- end -}}
 {{- end -}}
+
+{{- define "buzz.pairingRelaySelectorLabels" -}}
+{{ include "buzz.selectorLabels" . }}
+app.kubernetes.io/component: pairing-relay
+{{- end -}}
diff --git a/deploy/charts/buzz/templates/_validate.tpl b/deploy/charts/buzz/templates/_validate.tpl
index 6c99fdda92..7ca5fe29af 100644
--- a/deploy/charts/buzz/templates/_validate.tpl
+++ b/deploy/charts/buzz/templates/_validate.tpl
@@ -46,6 +46,11 @@ surface at template time regardless of which manifest helm renders first.
   {{- end -}}
 {{- end -}}
 
+{{/* Pairing relay deployment must have an advertised public URL. */}}
+{{- if and .Values.pairingRelay.enabled (not .Values.pairingRelay.url) -}}
+  {{- fail "pairingRelay.url is required when pairingRelay.enabled=true" -}}
+{{- 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." -}}
diff --git a/deploy/charts/buzz/templates/deployment.yaml b/deploy/charts/buzz/templates/deployment.yaml
index 806e241444..7f306aead4 100644
--- a/deploy/charts/buzz/templates/deployment.yaml
+++ b/deploy/charts/buzz/templates/deployment.yaml
@@ -69,6 +69,9 @@ spec:
             - { 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 }} }
+            {{- if .Values.pairingRelay.url }}
+            - { name: BUZZ_PAIRING_RELAY_URL, value: {{ .Values.pairingRelay.url | quote }} }
+            {{- end }}
             - { name: BUZZ_MEDIA_BASE_URL, value: {{ include "buzz.mediaBaseUrl" . | quote }} }
 
             # ── Behavior ─────────────────────────────────────────────
diff --git a/deploy/charts/buzz/templates/pairing-relay.yaml b/deploy/charts/buzz/templates/pairing-relay.yaml
new file mode 100644
index 0000000000..86e362fe03
--- /dev/null
+++ b/deploy/charts/buzz/templates/pairing-relay.yaml
@@ -0,0 +1,72 @@
+{{- include "buzz.validate" . -}}
+{{- if .Values.pairingRelay.enabled -}}
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: {{ include "buzz.fullname" . }}-pairing
+  labels:
+    {{- include "buzz.labels" . | nindent 4 }}
+    app.kubernetes.io/component: pairing-relay
+spec:
+  replicas: {{ .Values.pairingRelay.replicaCount }}
+  selector:
+    matchLabels:
+      {{- include "buzz.pairingRelaySelectorLabels" . | nindent 6 }}
+  template:
+    metadata:
+      labels:
+        {{- include "buzz.pairingRelaySelectorLabels" . | nindent 8 }}
+        {{- with .Values.pairingRelay.podLabels }}
+        {{- toYaml . | nindent 8 }}
+        {{- end }}
+      {{- with .Values.pairingRelay.podAnnotations }}
+      annotations:
+        {{- toYaml . | nindent 8 }}
+      {{- end }}
+    spec:
+      securityContext:
+        {{- toYaml .Values.relay.securityContext | nindent 8 }}
+      containers:
+        - name: pairing-relay
+          image: {{ include "buzz.image" . }}
+          imagePullPolicy: {{ .Values.image.pullPolicy }}
+          command: ["/usr/local/bin/buzz-pair-relay"]
+          securityContext:
+            {{- toYaml .Values.relay.containerSecurityContext | nindent 12 }}
+          env:
+            - name: BUZZ_PAIR_RELAY_BIND_ADDR
+              value: "0.0.0.0:{{ .Values.pairingRelay.service.port }}"
+          ports:
+            - name: websocket
+              containerPort: {{ .Values.pairingRelay.service.port }}
+              protocol: TCP
+          readinessProbe:
+            tcpSocket:
+              port: websocket
+          livenessProbe:
+            tcpSocket:
+              port: websocket
+          resources:
+            {{- toYaml .Values.pairingRelay.resources | nindent 12 }}
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: {{ include "buzz.fullname" . }}-pairing
+  labels:
+    {{- include "buzz.labels" . | nindent 4 }}
+    app.kubernetes.io/component: pairing-relay
+  {{- with .Values.pairingRelay.service.annotations }}
+  annotations:
+    {{- toYaml . | nindent 4 }}
+  {{- end }}
+spec:
+  type: {{ .Values.pairingRelay.service.type }}
+  selector:
+    {{- include "buzz.pairingRelaySelectorLabels" . | nindent 4 }}
+  ports:
+    - name: websocket
+      port: {{ .Values.pairingRelay.service.port }}
+      targetPort: websocket
+      protocol: TCP
+{{- end }}
diff --git a/deploy/charts/buzz/tests/pairing_relay_test.yaml b/deploy/charts/buzz/tests/pairing_relay_test.yaml
new file mode 100644
index 0000000000..2ed2ef3b40
--- /dev/null
+++ b/deploy/charts/buzz/tests/pairing_relay_test.yaml
@@ -0,0 +1,54 @@
+suite: optional pairing relay
+templates:
+  - templates/deployment.yaml
+  - templates/secret-chart.yaml
+  - templates/pairing-relay.yaml
+  - templates/serviceaccount.yaml
+tests:
+  - it: does not render the pairing relay by default
+    set:
+      relayUrl: wss://buzz.example.com
+      ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
+      externalPostgresql.url: postgres://u:p@h:5432/d
+      externalRedis.url: redis://h:6379
+      s3.endpoint: http://minio:9000
+      s3.accessKey: a
+      s3.secretKey: s
+    asserts:
+      - hasDocuments:
+          count: 0
+        template: templates/pairing-relay.yaml
+      - notContains:
+          path: spec.template.spec.containers[0].env
+          content:
+            name: BUZZ_PAIRING_RELAY_URL
+        template: templates/deployment.yaml
+
+  - it: renders and advertises the configured pairing relay
+    set:
+      relayUrl: wss://buzz.example.com
+      ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
+      externalPostgresql.url: postgres://u:p@h:5432/d
+      externalRedis.url: redis://h:6379
+      s3.endpoint: http://minio:9000
+      s3.accessKey: a
+      s3.secretKey: s
+      pairingRelay.enabled: true
+      pairingRelay.url: wss://pairing.buzz.xyz
+    asserts:
+      - equal:
+          path: kind
+          value: Deployment
+        documentIndex: 0
+        template: templates/pairing-relay.yaml
+      - equal:
+          path: kind
+          value: Service
+        documentIndex: 1
+        template: templates/pairing-relay.yaml
+      - contains:
+          path: spec.template.spec.containers[0].env
+          content:
+            name: BUZZ_PAIRING_RELAY_URL
+            value: wss://pairing.buzz.xyz
+        template: templates/deployment.yaml
diff --git a/deploy/charts/buzz/values.schema.json b/deploy/charts/buzz/values.schema.json
index c7a878ee00..9bd7ff4aa5 100644
--- a/deploy/charts/buzz/values.schema.json
+++ b/deploy/charts/buzz/values.schema.json
@@ -230,6 +230,31 @@
         "labels": { "type": "object" }
       }
     },
+    "pairingRelay": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "enabled": { "type": "boolean" },
+        "url": {
+          "type": "string",
+          "pattern": "^(wss?://.+)?$",
+          "description": "Public WebSocket URL advertised to clients for NIP-AB device pairing."
+        },
+        "replicaCount": { "type": "integer", "minimum": 1 },
+        "service": {
+          "type": "object",
+          "additionalProperties": false,
+          "properties": {
+            "type": { "type": "string", "enum": ["ClusterIP", "NodePort", "LoadBalancer"] },
+            "port": { "type": "integer", "minimum": 1, "maximum": 65535 },
+            "annotations": { "type": "object" }
+          }
+        },
+        "podAnnotations": { "type": "object" },
+        "podLabels": { "type": "object" },
+        "resources": { "type": "object" }
+      }
+    },
     "extraManifests": {
       "type": "array"
     }
diff --git a/deploy/charts/buzz/values.yaml b/deploy/charts/buzz/values.yaml
index c118cdae2f..174d48cf04 100644
--- a/deploy/charts/buzz/values.yaml
+++ b/deploy/charts/buzz/values.yaml
@@ -150,6 +150,28 @@ relay:
   extraEnv: []
   extraEnvFrom: []
 
+# ── Device pairing relay ─────────────────────────────────────────────────────
+# Optional, stateless NIP-AB relay. When enabled, the main relay advertises
+# pairingRelay.url in NIP-11 and Buzz clients use it instead of the legacy
+# same-host /pair convention.
+pairingRelay:
+  enabled: false
+  url: ""
+  replicaCount: 1
+  service:
+    type: ClusterIP
+    port: 5000
+    annotations: {}
+  podAnnotations: {}
+  podLabels: {}
+  resources:
+    requests:
+      cpu: "50m"
+      memory: "32Mi"
+    limits:
+      cpu: "250m"
+      memory: "128Mi"
+
 # ── Service ──────────────────────────────────────────────────────────────────
 service:
   type: ClusterIP
diff --git a/desktop/src-tauri/src/commands/pairing.rs b/desktop/src-tauri/src/commands/pairing.rs
index f86a1a94fe..eef99cb5dd 100644
--- a/desktop/src-tauri/src/commands/pairing.rs
+++ b/desktop/src-tauri/src/commands/pairing.rs
@@ -92,13 +92,16 @@ pub async fn start_pairing(
     // own NIP-11 declaration of NIP-43 support rather than `auth_required`,
     // which is also true for plain NIP-42 / NIP-OA relays where the main
     // relay is reachable.
-    let qr_relay_url = if probe_relay_supports_nip43(&ws_url).await {
-        let mut url = url::Url::parse(&ws_url).map_err(|e| format!("invalid relay URL: {e}"))?;
-        let path = url.path().trim_end_matches('/').to_string();
-        url.set_path(&format!("{path}/pair"));
-        url.to_string()
-    } else {
-        ws_url.clone()
+    let qr_relay_url = match probe_pairing_relay(&ws_url).await {
+        PairingRelay::Configured(url) => url,
+        PairingRelay::LegacyPath => {
+            let mut url =
+                url::Url::parse(&ws_url).map_err(|e| format!("invalid relay URL: {e}"))?;
+            let path = url.path().trim_end_matches('/').to_string();
+            url.set_path(&format!("{path}/pair"));
+            url.to_string()
+        }
+        PairingRelay::MainRelay => ws_url.clone(),
     };
 
     let (session, qr_payload) = PairingSession::new_source(qr_relay_url);
@@ -413,27 +416,24 @@ fn parse_relay_event(text: &str, sub_id: &str) -> Option<nostr::Event> {
     serde_json::from_value(arr[2].clone()).ok()
 }
 
-/// Check the relay's NIP-11 document to determine whether it advertises
-/// NIP-43 (relay membership). Returns `true` only if NIP-43 appears in the
-/// relay's `supported_nips`. Unreachable relays, malformed responses, and
-/// non-`ws(s)://` URLs all return `false`: we'd rather fail loudly against
-/// the main relay than misroute pairing to an undeployed `/pair` sidecar.
-///
-/// Converts the WebSocket URL to HTTP(S) and fetches `GET /` with
-/// `Accept: application/nostr+json` per NIP-11.
-///
-/// We test for NIP-43 specifically rather than the broader
-/// `limitation.auth_required` flag because the latter is also set on plain
-/// NIP-42 / NIP-OA relays, which accept unpaired peers on the main relay
-/// and have no `/pair` sidecar.
-async fn probe_relay_supports_nip43(relay_url: &str) -> bool {
-    // Convert ws(s):// to http(s):// for the NIP-11 fetch.
+/// Pairing route discovered from the main relay's NIP-11 document.
+#[derive(Debug, PartialEq, Eq)]
+enum PairingRelay {
+    Configured(String),
+    LegacyPath,
+    MainRelay,
+}
+
+/// Prefer the relay-advertised dedicated pairing URL. The legacy `/pair`
+/// convention remains as a compatibility fallback for NIP-43 relays that do
+/// not advertise the extension yet.
+async fn probe_pairing_relay(relay_url: &str) -> PairingRelay {
     let http_url = if let Some(rest) = relay_url.strip_prefix("wss://") {
         format!("https://{rest}")
     } else if let Some(rest) = relay_url.strip_prefix("ws://") {
         format!("http://{rest}")
     } else {
-        return false;
+        return PairingRelay::MainRelay;
     };
 
     let client = reqwest::Client::builder()
@@ -447,19 +447,112 @@ async fn probe_relay_supports_nip43(relay_url: &str) -> bool {
         .send()
         .await
     {
-        Ok(r) => r,
-        Err(_) => return false, // can't reach relay — assume open
+        Ok(response) => response,
+        Err(_) => return PairingRelay::MainRelay,
     };
 
     let json: serde_json::Value = match resp.json().await {
-        Ok(v) => v,
-        Err(_) => return false,
+        Ok(value) => value,
+        Err(_) => return PairingRelay::MainRelay,
     };
 
-    json.get("supported_nips")
-        .and_then(|v| v.as_array())
-        .map(|arr| arr.iter().any(|n| n.as_u64() == Some(43)))
-        .unwrap_or(false)
+    pairing_relay_from_nip11(&json)
+}
+
+fn pairing_relay_from_nip11(json: &serde_json::Value) -> PairingRelay {
+    if let Some(value) = json
+        .get("pairing_relay_url")
+        .and_then(|value| value.as_str())
+    {
+        if let Ok(url) = url::Url::parse(value) {
+            if matches!(url.scheme(), "ws" | "wss") && url.host_str().is_some() {
+                return PairingRelay::Configured(value.to_string());
+            }
+        }
+    }
+
+    if json
+        .get("supported_nips")
+        .and_then(|value| value.as_array())
+        .is_some_and(|nips| nips.iter().any(|nip| nip.as_u64() == Some(43)))
+    {
+        PairingRelay::LegacyPath
+    } else {
+        PairingRelay::MainRelay
+    }
+}
+
+#[cfg(test)]
+mod pairing_relay_tests {
+    use super::{pairing_relay_from_nip11, probe_pairing_relay, PairingRelay};
+    use tokio::io::{AsyncReadExt, AsyncWriteExt};
+
+    #[tokio::test]
+    async fn live_nip11_probe_discovers_configured_pairing_relay() {
+        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
+            .await
+            .expect("bind test NIP-11 server");
+        let addr = listener.local_addr().expect("test server address");
+        let server = tokio::spawn(async move {
+            let (mut stream, _) = listener.accept().await.expect("accept NIP-11 request");
+            let mut request = vec![0; 2048];
+            let bytes_read = stream.read(&mut request).await.expect("read request");
+            let request = String::from_utf8_lossy(&request[..bytes_read]);
+            assert!(request.starts_with("GET / HTTP/1.1"));
+            assert!(request
+                .to_ascii_lowercase()
+                .contains("accept: application/nostr+json"));
+
+            let body = r#"{"pairing_relay_url":"ws://127.0.0.1:5000"}"#;
+            let response = format!(
+                "HTTP/1.1 200 OK\r\nContent-Type: application/nostr+json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
+                body.len()
+            );
+            stream
+                .write_all(response.as_bytes())
+                .await
+                .expect("write response");
+        });
+
+        assert_eq!(
+            probe_pairing_relay(&format!("ws://{addr}")).await,
+            PairingRelay::Configured("ws://127.0.0.1:5000".to_string())
+        );
+        server.await.expect("NIP-11 server task");
+    }
+
+    #[test]
+    fn configured_pairing_relay_takes_precedence_over_legacy_path() {
+        let document = serde_json::json!({
+            "pairing_relay_url": "wss://pairing.buzz.xyz",
+            "supported_nips": [43]
+        });
+
+        assert_eq!(
+            pairing_relay_from_nip11(&document),
+            PairingRelay::Configured("wss://pairing.buzz.xyz".to_string())
+        );
+    }
+
+    #[test]
+    fn invalid_pairing_relay_url_falls_back_to_legacy_path() {
+        let document = serde_json::json!({
+            "pairing_relay_url": "https://pairing.buzz.xyz",
+            "supported_nips": [43]
+        });
+
+        assert_eq!(
+            pairing_relay_from_nip11(&document),
+            PairingRelay::LegacyPath
+        );
+    }
+
+    #[test]
+    fn document_without_pairing_configuration_uses_main_relay() {
+        let document = serde_json::json!({ "supported_nips": [1, 11] });
+
+        assert_eq!(pairing_relay_from_nip11(&document), PairingRelay::MainRelay);
+    }
 }
 
 fn parse_auth_challenge(text: &str) -> Option<String> {
