Relay gestionado: Railway y Kubernetes con Helm

Por: Artiko
buzznostrkuberneteshelmrailwaydespliegue

Relay gestionado: Railway y Kubernetes con Helm

En el capítulo 5 montamos el relay sobre un VPS con el bundle de Docker Compose de deploy/compose/: un nodo único, un proceso de relay, Postgres/Redis/MinIO como contenedores vecinos y Caddy delante.

Este capítulo cubre los dos caminos que el repositorio ofrece cuando no quieres administrar una máquina: el botón de Railway y el chart de Helm deploy/charts/buzz.

flowchart TD
    B{"¿Quiero administrar un servidor?"}
    B -- "No, y quiero lo más rápido" --> C["Railway: botón de un clic"]
    B -- "No, pero ya tengo clúster" --> D["Kubernetes con el chart buzz"]
    B -- "Sí, tengo un VPS" --> E["Docker Compose en deploy/compose"]
    D --> F{"¿Cuántas réplicas?"}
    F -- "Una" --> G["Perfil quickstart, evaluación"]
    F -- "Dos o más" --> H["Perfil production con Redis obligatorio"]

Parte A. Railway: el botón de un clic

Qué dice exactamente el repositorio

El README.md tiene una sección titulada “I want my own hosted relay” con este contenido, y nada más:

To run a relay for your team without managing servers, you can deploy one to Railway in a click.

Ahí aparece el badge Deploy on Railway apuntando a https://railway.com/deploy/buzz-relay-block, y un enlace a un artículo del blog de ingeniería de Block (https://engineering.block.xyz/blog/run-your-own-buzz-relay) “para más detalles”.

La plantilla existe y está enlazada desde el README oficial, pero su definición no vive en el repositorio: no hay railway.json, ni railway.toml, ni nixpacks.toml en el árbol de fuentes. Lo que la plantilla crea, cómo la parametriza y qué planes usa se decide del lado de Railway. Por lo tanto, cualquier paso operativo detallado de Railway que encuentres por ahí no está respaldado por este repositorio. No es un defecto: es la naturaleza de un despliegue “de plantilla”. El botón te entrega un relay corriendo; el precio es que la configuración canónica queda fuera de tu control de versiones.

Lo único técnico que el repo sí documenta sobre Railway

El dato de Railway más concreto no está en el README, sino en la documentación de almacenamiento de objetos, y aparece dos veces.

1. En deploy/compose/README.md. El bundle de Compose fija en compose.yml BUZZ_S3_ENDPOINT=http://minio:9000 y BUZZ_S3_ADDRESSING_STYLE=path, y no los expone en .env. La razón es que el DNS de Docker resuelve minio, no <bucket>.minio. La consecuencia práctica está escrita textualmente en ese README: el bundle no sirve para proveedores S3 que exigen direccionamiento virtual, “como los nuevos Railway Storage Buckets”. Para esos hay que usar el chart de Helm o una configuración de Compose propia.

2. En deploy/charts/buzz/README.md. Ahí está la tabla de estilos de direccionamiento:

s3.addressingStyleForma de la peticiónPara qué
path (por defecto)https://endpoint/bucket/keyMinIO empaquetado y endpoints cuyo DNS no resuelve subdominios de bucket
virtualhttps://bucket.endpoint/keyProveedores estilo AWS y los nuevos Railway Storage Buckets

Y el mapeo sugerido para un Railway Storage Bucket, tal cual en el chart:

s3:
  endpoint: "${{Object Storage.ENDPOINT}}"
  bucket: "${{Object Storage.BUCKET}}"
  region: "${{Object Storage.REGION}}"
  addressingStyle: virtual

Con BUZZ_S3_ACCESS_KEY=${{Object Storage.ACCESS_KEY_ID}} y BUZZ_S3_SECRET_KEY=${{Object Storage.SECRET_ACCESS_KEY}} guardados en el Secret al que apunta secrets.existingSecret. Tres advertencias que el propio chart deja escritas: no metas el bucket dentro de s3.endpoint, porque ENDPOINT y BUCKET van por separado y el ajuste cambia el ruteo de la petición y la firma SigV4; la pestaña Credentials de Railway es la autoridad para buckets más antiguos, que pueden seguir exigiendo path; y solo se aceptan path y virtual, de modo que un valor inválido falla el render del chart y el arranque del relay, sin degradación silenciosa.

Y todo lo del capítulo 4 sigue aplicando: BUZZ_RELAY_PRIVATE_KEY es la identidad del relay y debe ser estable entre reinicios y respaldada; RELAY_OWNER_PUBKEY, sin prefijo BUZZ_, debe ser hex de 64 caracteres cuando el modo cerrado está activo. Ver configuración.

Parte B. Kubernetes: el chart deploy/charts/buzz

Identidad y perfiles

deploy/charts/buzz/Chart.yaml declara apiVersion: v2, name: buzz, type: application, version: 0.1.7 y appVersion: "0.1.0", con licencia Apache-2.0. Se publica como artefacto OCI en oci://ghcr.io/block/buzz/charts/buzz y se versiona independientemente de la imagen del relay y de la app de escritorio, con sus propios tags chart-v*. El encabezado de values.yaml y la tabla del README del chart definen dos perfiles:

PerfilCuándoQué obtienes
Production (por defecto)Auto-hospedado multi-tenant, entorno regulado o gestión por GitOpsPostgres/Redis/S3 externos gestionados, secrets.existingSecret en todos lados, cero autogeneración del chart, capaz de HA con replicaCount >= 2
Quickstart (evaluación)Evaluación, nodo único, demo puntualPostgres + Redis + MinIO dentro del cluster, secretos autogenerados por el chart, réplica única

Un detalle que se malinterpreta constantemente: quickstart: false es solo un marcador de intención que se muestra en NOTES.txt; por sí mismo no enciende ningún servicio.

Dependencias

Chart.yaml declara dos subcharts, ambos condicionales:

SubchartDeclaradaEn Chart.lockRepositorioCondiciónAlias
postgres0.19.x0.19.5oci://registry-1.docker.io/cloudpiratespostgresql.enabledpostgresql
redis0.30.x0.30.3oci://registry-1.docker.io/cloudpiratesredis.enabled

MinIO no es un subchart. Es un Deployment propio del chart (templates/quickstart-minio.yaml más templates/quickstart-minio-init.yaml), con imágenes fijadas en values.yaml: minio/minio:RELEASE.2025-09-07T16-13-09Z y minio/mc:RELEASE.2025-08-13T08-35-41Z, y persistencia de 10Gi.

Instalación quickstart

helm install buzz oci://ghcr.io/block/buzz/charts/buzz --version 0.1.7 \
  --create-namespace --namespace buzz \
  --set quickstart=true \
  --set postgresql.enabled=true \
  --set redis.enabled=true \
  --set minio.enabled=true \
  --set relayUrl=wss://buzz.example.com \
  --set ownerPubkey=<64-char-hex-pubkey>

Esto levanta todo dentro del cluster — Postgres, Redis y MinIO con su bucket creado por un Job post-install — y compone BUZZ_S3_ENDPOINT junto con credenciales autogeneradas, sin servicios externos. El propio README lo marca como solo evaluación: cada servicio empaquetado es una réplica única sin HA. ci/quickstart-values.yaml es el conjunto exacto que CI instala con ct install contra un cluster kind.

Entradas obligatorias

ClaveQué esCuándo es obligatoria
relayUrlURL pública wss:// a la que se conectan los clientesSiempre
ownerPubkeyPubkey Nostr del operador, 64 caracteres hex en minúsculaCuando relay.requireRelayMembership=true, que es el valor por defecto
secrets.existingSecretNombre de un Secret pre-creadoProducción y GitOps
externalPostgresql.url / externalRedis.url / s3.endpointURLs de servicios externosProducción, cuando el servicio empaquetado correspondiente está desactivado

Las nueve validaciones que fallan en tiempo de render

templates/_validate.tpl define buzz.validate, y todos los manifiestos lo incluyen en su primera línea, de modo que una mala configuración salta sin importar qué plantilla renderice Helm primero. Las guardas son:

  1. relayUrl vacío falla con relayUrl is required: set --set relayUrl=wss://your.domain.
  2. Réplica mínima mayor que 1 sin fuente de Redis: redis.enabled, externalRedis.url o secrets.existingSecret.
  3. autoscaling.minReplicas < 1, maxReplicas < minReplicas, o websocketMetricEnabled sin websocketMetricName.
  4. relay.requireRelayMembership: true sin ownerPubkey.
  5. ownerPubkey que no cumpla ^[0-9a-f]{64}$; el mensaje informa cuántos caracteres recibió.
  6. pairingRelay.enabled sin pairingRelay.url.
  7. ingress.enabled y httproute.enabled a la vez: hay que elegir uno.
  8. Sin fuente de Postgres: postgresql.enabled, externalPostgresql.url o existingSecret.
  9. Sin fuente de S3: minio.enabled, s3.endpoint o existingSecret.

Hay además una nota histórica valiosa en ese archivo: se eliminó la validación que exigía persistence.git.accessMode=ReadWriteMany en multi-réplica. Su justificación original — el estado git en disco tiene que compartirse entre réplicas — dejó de ser cierta cuando ese estado pasó a vivir en object storage.

Secretos

El camino de producción es crear un Secret fuera de banda y apuntarle con secrets.existingSecret. examples/secret-sample.yaml documenta el esquema completo, y recomienda gestionarlo con SealedSecrets, SOPS, External Secrets o Vault para que la forma sin cifrar nunca entre a git:

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:[email protected]:5432/buzz?sslmode=require"
  REDIS_URL: "redis://:[email protected]:6379"
  BUZZ_S3_ACCESS_KEY: "REPLACE"
  BUZZ_S3_SECRET_KEY: "REPLACE"

También acepta READ_DATABASE_URL para una réplica de lectura opcional; omitirla mantiene todas las lecturas en el writer. En el Deployment, BUZZ_RELAY_PRIVATE_KEY, BUZZ_GIT_HOOK_HMAC_SECRET, READ_DATABASE_URL y las dos claves S3 se montan con optional: true; DATABASE_URL no es opcional y sin ella el pod no arranca; y REDIS_URL es opcional solo cuando la réplica mínima es 1 y no hay Redis empaquetado ni externo.

Por qué existingSecret es obligatorio bajo GitOps. ArgoCD y Flux renderizan con helm template. En ese modo la función lookup de Helm devuelve vacío, así que cualquier randAlphaNum del chart regeneraría el secreto en cada sync. El Secret gestionado por el chart (templates/secret-chart.yaml, que lleva helm.sh/resource-policy: keep) es seguro solo para helm install y helm upgrade, y NOTES.txt imprime esa advertencia al detectarlo. El Deployment lleva además una anotación checksum/secret calculada con sha256sum sobre el Secret renderizado, para rotar los pods cuando el secreto cambia.

Exposición: Ingress o Gateway API

Son mutuamente excluyentes por validación. Con ingress.enabled: true y ingress.hosts vacío, el chart deriva el host desde relayUrl y crea una regla / con pathType: Prefix apuntando a service.port. Con httproute.enabled: true y httproute.rules vacío, genera una HTTPRoute de gateway.networking.k8s.io/v1 con un match PathPrefix: / hacia el mismo Service.

El ejemplo examples/ingress-cert-manager.yaml es un archivo con dos documentos YAML y una advertencia explícita: el parser de valores de Helm lee solo el primero; el segundo — el ClusterIssuer de cert-manager con un solver HTTP-01 de Let’s Encrypt — se aplica una vez por cluster con kubectl apply -f, porque es cluster-scoped y sobrevive a cualquier release. El fragmento de valores:

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"
  hosts:
    - host: buzz.example.com
      paths: [{ path: /, pathType: Prefix }]
  tls:
    - { hosts: [buzz.example.com], secretName: buzz-tls }

Esos dos timeouts de una hora no son decorativos: las conexiones WebSocket del relay son de larga vida. NOTES.txt lo repite cuando no hay ni Ingress ni HTTPRoute habilitados — las conexiones WebSocket de larga vida requieren timeouts generosos de lectura y envío, de al menos una hora.

Si habilitas el pairing relay opcional (pairingRelay.enabled con su url), el chart no crea Ingress ni HTTPRoute para él: hay que rutear el hostname público hacia <release>-buzz-pairing:5000 con la configuración de ingress de tu plataforma.

Réplicas, HA y autoscaling

replicaCount vale 1 por defecto. Subirlo por encima de 1 exige Redis para el fan-out de buzz-pubsub, y el chart falla el template si se rompe ese invariante: sin degradación silenciosa. Lo que no exige es almacenamiento compartido; el comentario de values.yaml es la explicación canónica: el estado de refs y objetos git está respaldado por object storage — cada petición hidrata un repo efímero desde S3, la serialización de escritores es el CAS del puntero en el object store, y la unicidad de nombres de repo vive en Postgres. Cada réplica puede usar su propio volumen ReadWriteOnce, o ninguno.

El HPA es opcional y escala por la mayor de dos recomendaciones, CPU o WebSockets activos por pod:

autoscaling:
  enabled: true
  minReplicas: 5
  maxReplicas: 15
  targetCPUUtilizationPercentage: 65
  websocketMetricEnabled: true
  websocketMetricName: buzz_ws_connections_active
  targetWebsocketConnections: 5000

El escalado por CPU requiere Metrics Server. El de WebSockets requiere además un adaptador de métricas personalizado — por ejemplo Prometheus Adapter — que exponga el gauge buzz_ws_connections_active como métrica de pod. El chart crea el HPA pero deliberadamente no instala ni configura ese adaptador; websocketMetricEnabled: false deja un HPA solo de CPU. El behavior por defecto es asimétrico a propósito: scaleUp con stabilizationWindowSeconds: 0, políticas de 100 % cada 60 s y 4 pods cada 60 s, selectPolicy: Max; scaleDown con stabilizationWindowSeconds: 600 y 1 pod cada 120 s, selectPolicy: Min. Bajar despacio da tiempo a drenar las conexiones WebSocket de larga vida.

Complementos del modelo HA: el Deployment usa strategy: RollingUpdate con maxSurge: 1 y maxUnavailable: 0; el PodDisruptionBudget se renderiza solo cuando la réplica mínima es mayor que 1, por defecto con minAvailable: 1; y relay.drainJitterMs, junto a terminationGracePeriodSeconds: 60, reparte los cierres de socket para no provocar una estampida de reconexiones contra el pool de la base. Valores por encima de 20000 se topan a 20000.

Almacenamiento git

persistence.git tiene por defecto enabled: true, mountPath: /var/lib/buzz/git, accessMode: ReadWriteOnce y size: 10Gi, más storageClass y existingClaim vacíos.

  • enabled: true monta un PVC en mountPath: durable entre reinicios del pod, pero un único PVC ReadWriteOnce se ata a un nodo, así que no soporta planificar varios pods entre nodos.
  • enabled: false monta un emptyDir por pod, acotado por size. Es la elección correcta para HA multi-réplica: cada pod tiene su espacio local, no se comparte nada y no hay volumen que multi-adjuntar. Es seguro porque las fuentes de verdad son el object store y Postgres.

Aparte del volumen git, el Deployment monta siempre un emptyDir git-pack-cache en git.packCachePath (/var/cache/buzz/git-packs), dimensionado por git.packCacheVolumeSize (7Gi).

Migraciones y upgrades

Las migraciones de esquema están embebidas en el binario del relay vía sqlx::migrate! y se ejecutan al arrancar, controladas por migrate.autoMigrate, que vale true por defecto. Varias réplicas compiten de forma race-safe detrás de un advisory lock de Postgres, y helm upgrade es todo el procedimiento de upgrade. Si prefieres desacoplarlas del servicio, migrate.autoMigrate=false; en ese modo el chart no corre migraciones por ti: eres tú quien debe ejecutar buzz-admin migrate — un Pod aparte o un Job one-shot — antes de cada helm install o helm upgrade. Las readiness probes solo verifican conectividad a la base, no frescura del esquema, así que un pod puede parecer sano contra un esquema sin migrar y fallar bajo carga; NOTES.txt imprime esa advertencia. El valor migrate.preUpgradeJob.enabled existe pero está reservado: hoy no hace nada.

Observabilidad y salud

Los puertos del contenedor están nombrados: app (3000), health (service.healthPort, 8080) y metrics (service.metricsPort, 9102). Las tres sondas apuntan al puerto health: livenessProbe a /_liveness con initialDelaySeconds: 5, periodSeconds: 10, timeoutSeconds: 3 y failureThreshold: 3; readinessProbe a /_readiness con 5 / 5 / 3 / 3; y startupProbe a /_liveness con failureThreshold: 60 y periodSeconds: 2, es decir unos 120 s de margen.

serviceMonitor.enabled: false por defecto; al activarlo el chart crea un ServiceMonitor de Prometheus Operator que selecciona el Service por las etiquetas del chart y scrapea el puerto llamado metrics con interval: 30s y scrapeTimeout: 10s. Todo esto se profundiza en observabilidad. NOTES.txt da el atajo manual:

kubectl -n buzz port-forward svc/buzz 8080:8080
curl http://localhost:8080/_readiness

Seguridad del pod y puntos de extensión

relay.securityContext corre como no-root con uid, gid y fsGroup 65532 y seccompProfile: RuntimeDefault; relay.containerSecurityContext desactiva allowPrivilegeEscalation, hace capabilities.drop: [ALL] y deja readOnlyRootFilesystem: false con el motivo escrito al lado: las escrituras de git necesitan una ruta escribible.

El chart expone puntos de extensión estrechos: extraInitContainers, extraVolumes, relay.extraVolumeMounts, relay.command, relay.args, relay.extraEnv y relay.extraEnvFrom. Todos se renderizan con toYaml, no con tpl, y el chart no valida relaciones entre campos: los nombres no deben colisionar con lo que ya es del chart, los mounts deben referenciar volúmenes existentes, y cada init container debe definir su securityContext y sus resources. extraManifests crea recursos independientes pero no puede modificar el Deployment del relay.

GitOps: ArgoCD y Flux

examples/argocd-app.yaml incluye una lección aprendida a golpes, escrita como comentario: en ArgoCD 3.1 y superiores con fuentes OCI nativas, repoURL debe ser la ruta completa del artefacto. Con la forma partida repoURL: .../charts más chart: buzz, el campo chart se ignora para URLs oci:// y la descarga falla con un 403; el validador del spec sigue exigiendo path, así que se usa ".".

source:
  repoURL: oci://ghcr.io/block/buzz/charts/buzz
  path: .
  targetRevision: 0.1.7
  helm:
    releaseName: buzz
    values: |
      relayUrl: wss://buzz.example.com
      replicaCount: 3
      secrets: { existingSecret: buzz-secrets }

examples/flux-helmrelease.yaml hace lo equivalente con un HelmRepository de tipo oci hacia oci://ghcr.io/block/buzz/charts y un HelmRelease de helm.toolkit.fluxcd.io/v2. Ambos ejemplos usan persistence.git.accessMode: ReadWriteOnce con replicaCount: 3, y el comentario lo justifica: RWO es válido a cualquier número de réplicas porque el git está respaldado por object storage.

Cómo se publica el chart

.github/workflows/helm-chart.yml lintea, corre tests unitarios de helm-unittest y hace render-check en cada PR y cada push a main, pero solo un tag chart-v* publica: así un main en progreso nunca puede sobrescribir una versión ya publicada. El repositorio OCI sale de la variable CHART_REPO, con valor por defecto oci://ghcr.io/block/buzz/charts. Para cortar un release se empuja una rama chart-release/<version> cuya <version> coincida con la de Chart.yaml; al mergear se auto-taggea chart-v<version> y el job de publicación falla ruidosamente si ambas discrepan.

Limitaciones honestas que el chart declara

El README del chart tiene una sección titulada “Honest limitations (v1)”:

  • MinIO empaquetado es solo evaluación. Réplica única, sin HA, credenciales autogeneradas con lookup, no GitOps-safe y no pensado para tráfico de producción.
  • El “modo mínimo” todavía no está soportado. BUZZ_PUBSUB=local y las rutas de media sobre sistema de archivos son trabajo en curso upstream; incluso el quickstart levanta Redis y S3 reales. La búsqueda full-text sí corre ya en Postgres, así que no se aprovisiona servicio de búsqueda aparte.
  • La firma Cosign del chart publicado está pendiente. La imagen del relay sí lleva atestación vía actions/attest-build-provenance; el chart todavía no.

Parte C. El testbed local: deploy/local/

El repositorio trae un testbed reproducible para el Kubernetes de Docker Desktop. deploy/local/quickstart-ha-values.yaml es el quickstart de CI con la forma HA encima:

quickstart: true
postgresql: { enabled: true }
redis: { enabled: true }
minio: { enabled: true }
relayUrl: wss://buzz.test.local
ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000001"
relay: { requireRelayMembership: false }
podDisruptionBudget: { enabled: false }
replicaCount: 3
persistence:
  git:
    enabled: false
image:
  repository: buzz-relay
  tag: mesh-local
  pullPolicy: IfNotPresent

Dos líneas cargan con todo el peso del HA, y el archivo las comenta: replicaCount: 3 obliga al chart a exigir Redis (que el quickstart provee, y el HMAC de hooks git se autogenera del lado del chart), y persistence.git.enabled: false cambia el PVC ReadWriteOnce por un emptyDir por pod. Con el valor por defecto true, un único PVC RWO no puede multi-adjuntarse a 3 pods y dejaría 2 de 3 atascados.

deploy/local/build-and-deploy.sh orquesta el ciclo completo:

flowchart TD
    A["Verificar current-context igual a docker-desktop"] --> B["docker build buzz-relay:mesh-local"]
    A -- "Otro contexto" --> Z["Aborta sin tocar el clúster"]
    B --> C["helm dependency build"]
    C --> D["helm upgrade --install --timeout 5m sin --wait"]
    D --> E["kubectl rollout status --timeout=4m"]
    E --> F["curl a /_readiness en cada pod"]
    F --> G["Exige el literal status ready"]

Los detalles que importan:

  • Guardarraíl de contexto. El script compara kubectl config current-context con docker-desktop y se niega a tocar cualquier otro cluster.
  • Namespace buzz-mesh, release buzz, imagen buzz-relay:mesh-local, 3 réplicas. Docker Desktop comparte el almacén de imágenes con Kubernetes, así que un tag construido localmente más pullPolicy: IfNotPresent no necesita push a un registro ni kind load.
  • Sin --wait en helm upgrade --install. El comentario del script explica por qué: la sonda de conformidad S3 A3 del relay es fatal al arranque, así que los relays entran en CrashLoopBackOff unas cuantas veces hasta que el Job de init concurrente crea el bucket. helm --wait compite con ese transitorio y puede abortar antes de tiempo; el script se hace cargo con rollout status más sondas por pod, que sí toleran los reinicios.
  • Verificación por pod, no agregada. Además de exigir readyReplicas == 3, hace exec en cada pod de relay y comprueba que http://127.0.0.1:8080/_readiness responda con el literal "status":"ready". La evidencia queda en /tmp/mesh-build/deploy-evidence-<timestamp>.
  • Variables útiles: SKIP_BUILD=1, IMAGE_TAG=..., NS y RELEASE.

Ese mismo transitorio del bucket es la razón del initContainer wait-for-bucket que el chart inyecta cuando minio.enabled está activo: con la imagen mc, espera con mc alias set hasta que MinIO responda y con mc stat hasta que el bucket exista, y solo entonces deja arrancar al relay.

Parte D. El chart secundario: buzz-push-gateway

deploy/charts/buzz-push-gateway empaqueta un componente separado: el gateway APNs público de último salto, descrito en su Chart.yaml como “Public capability-gated APNs last-hop gateway for Buzz”, versión 0.1.0. Su documentación propia es docs/push-gateway-deployment.md, cuya primera línea es la regla de oro: se construye con Dockerfile.push-gateway, y no se corre dentro de la imagen del relay ni se dan credenciales APNs a los relays.

Defaults relevantes de su values.yaml:

ClaveDefault
replicaCount2
image.repository / tagghcr.io/block/buzz-push-gateway / main
existingSecret / migration.existingSecretbuzz-push-gateway / buzz-push-gateway-migrations
migration.runtimeDatabaseRolebuzz_push_gateway_runtime
publicDeliveryUrlhttps://push.buzz.xyz/v1/deliveries/apns
maxGrantLifetimeSeconds2592000, es decir 30 días
appAttestAppIdTEAMID.xyz.buzz — es un ejemplo, producción DEBE sobrescribirlo
service.port / httpRoute.enabled / networkPolicy.enabled8080 / false / true

Cuatro decisiones de diseño antes de desplegarlo:

  1. Separación de privilegios en la base de datos. El DATABASE_URL de runtime debe tener privilegios solo DML. Las credenciales con DDL las usa únicamente el Job de migración pre-install/pre-upgrade, vía migration.existingSecret. Ese Job, tras migrar, revoca CREATE de base y de esquema al rol de runtime y le concede solo CONNECT, USAGE y SELECT, INSERT, UPDATE, DELETE sobre las seis tablas del gateway.
  2. Base de datos dedicada, no la del relay. SQLx guarda su historial _sqlx_migrations en public, así que compartir base colisionaría con el historial de migraciones de otra aplicación.
  3. Métricas en un puerto privado. El listener público es 8080; /metrics y las sondas viven en 8081. httpRoute.enabled: false evita que una instalación genérica reclame una ruta sin adjuntar; podMonitor y prometheusRule están apagados, y el primero exige además networkPolicy.monitoring habilitado.
  4. NetworkPolicy con placeholders deliberados. apnsEgressCidrs viene como [0.0.0.0/0] y postgresEgressCidrs como [10.0.0.0/8], ambos con comentarios diciendo que hay que estrecharlos. Kubernetes NetworkPolicy no admite nombres DNS: hay que traducirlos a los CIDR reales de tu red.

Parte E. Cuándo conviene cada modelo

CriterioCompose en VPSRailwayHelm en Kubernetes
Requiere administrar máquinaNoEl cluster, sí
Multi-réplicaNoNo documentado en el repoSí, con Redis obligatorio
AutoscalingNoNo documentado en el repoHPA opcional, requiere Metrics Server
Configuración versionable.env propioVive en la plantilla de Railwayvalues.yaml y GitOps
S3 externoNo: fija endpoint y pathStorage Buckets con virtuals3.* completo, path o virtual
TLSCaddy con compose.caddy.ymlLo aporta la plataformaIngress más cert-manager, o Gateway API
Validación de configuraciónrun.sh aborta con CHANGE_ME pendientes9 guardas que fallan el render
SecretosArchivo .envVariables de la plataformaexistingSecret con SealedSecrets, SOPS, External Secrets o Vault

Criterios de decisión:

  • Railway si quieres un relay para tu equipo hoy y no te importa que la definición del despliegue viva fuera de tu control de versiones. Con Storage Buckets nuevos, recuerda virtual.
  • Compose en un VPS si tienes una máquina, una comunidad y quieres el mínimo de piezas móviles: es el camino del capítulo 5.
  • El chart de Helm cuando necesites más de una réplica (con la condición dura de Redis), object storage externo gestionado, o gestión declarativa por ArgoCD o Flux. Y nunca el perfil quickstart en producción: el MinIO empaquetado es réplica única sin HA, con credenciales autogeneradas por lookup, y no es GitOps-safe.

Sea cual sea el camino, la lista de respaldo es la misma y NOTES.txt la imprime en cada helm install: BUZZ_RELAY_PRIVATE_KEY (rotarla es cambiar de identidad y los pares de federación dejarán de reconocer al relay), la base PostgreSQL, el bucket S3 con los blobs de media, el PVC de git y la clave privada del owner, que la tiene el operador y no el chart. Más en backups y actualizaciones.


Resumen

  • El botón Deploy on Railway existe y está enlazado desde el README.md hacia https://railway.com/deploy/buzz-relay-block, pero la definición de la plantilla no vive en el repositorio: no hay railway.json, railway.toml ni nixpacks.toml.
  • Los Storage Buckets nuevos de Railway exigen s3.addressingStyle: virtual, y el bundle de Compose no sirve porque fija path.
  • El chart deploy/charts/buzz está en la versión 0.1.7, se publica en oci://ghcr.io/block/buzz/charts/buzz y tiene dos perfiles: production por defecto y quickstart solo para evaluación, este último activado servicio por servicio con postgresql.enabled, redis.enabled y minio.enabled — Postgres y Redis son subcharts de CloudPirates (0.19.5 y 0.30.3 en Chart.lock), MinIO es un Deployment del propio chart.
  • templates/_validate.tpl define nueve guardas que fallan en tiempo de render: relayUrl, Redis en multi-réplica, coherencia del autoscaling, ownerPubkey y su formato hex, pairingRelay.url, exclusión mutua de Ingress y HTTPRoute, y las fuentes de Postgres y de S3.
  • replicaCount > 1 exige Redis y el chart falla el template si no lo hay, pero no exige almacenamiento ReadWriteMany: el estado git vive en object storage, así que emptyDir por pod es la elección correcta en HA.
  • En producción y bajo GitOps, secrets.existingSecret es obligatorio: ArgoCD y Flux renderizan con helm template, donde lookup devuelve vacío y los secretos autogenerados rotarían en cada sync.
  • migrate.autoMigrate: true corre las migraciones sqlx al arrancar tras un advisory lock de Postgres, y helm upgrade es todo el procedimiento de upgrade. Con false, el chart no migra por ti.
  • deploy/local/build-and-deploy.sh levanta el testbed HA de 3 réplicas sobre Docker Desktop, corre helm upgrade --install sin --wait por la sonda S3 fatal al arranque, y verifica /_readiness pod por pod. El chart buzz-push-gateway (0.1.0) es un componente aparte, con base de datos dedicada, privilegios DML en runtime y métricas en el puerto privado 8081.
  • Limitaciones declaradas por el propio chart: MinIO empaquetado es solo evaluación, el modo mínimo no está soportado todavía, y la firma Cosign del chart publicado está pendiente.

Siguiente: Identidad y autenticación: claves, NIP-42 y NIP-98