Despliegue en un VPS con Docker Compose

Por: Artiko
buzznostrdockerdocker-composecaddyvpsdesplieguetlsminiopostgresredis

Despliegue en un VPS con Docker Compose

En el capítulo 3 levantaste el relay desde el código fuente, con el binario corriendo en tu máquina y la infraestructura en Docker. En el capítulo 4 recorriste las variables de entorno una por una. Ahora toca el paso que convierte ese experimento en un servicio: poner el relay en un servidor accesible desde internet, con TLS, con datos que sobreviven a un reinicio y con un procedimiento repetible para actualizarlo.

Buzz trae ese procedimiento empaquetado en el directorio deploy/compose/. Este capítulo lo recorre completo, archivo por archivo y comando por comando.

Lo primero: hay dos composes y no son intercambiables

Esta es la confusión más cara que puedes cometer con Buzz, así que va antes que nada.

El repositorio tiene un docker-compose.yml en la raíz. Ese archivo no sirve para producción. El README.md:178 lo dice con todas sus letras: es infraestructura de desarrollo diario, y remite explícitamente a deploy/compose/ para el caso de un relay de nodo único o VPS.

La diferencia no es de matiz. Son dos stacks con propósitos distintos:

docker-compose.yml (raíz)deploy/compose/ (bundle)
Nombre de proyecto Composebuzzbuzz-prod
¿El relay está en el stack?No. Corre en el host con just relay o just dev, como contenedor con la imagen publicada
CredencialesFijas y públicas: buzz / buzz_devTodas obligatorias vía .env, sin defaults inseguros
Puertos de Postgres, Redis, MinIOPublicados al hostNo publicados
Servicios extraAdminer, Keycloak, PrometheusNinguno por defecto
TLSNo aplicaOverride opcional con Caddy
Imágenesminio/minio:latestReleases pinneados

El relay ausente del compose de desarrollo tiene una consecuencia visible: el Prometheus de desarrollo scrapea host.docker.internal:9102, porque el proceso que expone las métricas vive fuera de Docker. En el bundle de producción el relay está dentro de la red buzz-net y se resuelve por nombre de servicio.

Si te llevas una sola idea: para un VPS, cd deploy/compose y olvídate del compose de la raíz.

Anatomía del bundle

deploy/compose/ son siete archivos. Ninguno sobra:

ArchivoRol
compose.ymlEl stack base: relay, postgres, redis, minio, minio-init
compose.caddy.ymlOverride de TLS: quita el puerto directo del relay y añade Caddy
compose.dev.ymlOverride de administración local: publica puertos internos y añade Adminer + Prometheus
run.shWrapper de operación con validación de secretos
CaddyfileCinco líneas de proxy inverso
.env.examplePlantilla de configuración, 52 líneas
README.mdNotas de producción y checklist de validación

Los dos archivos con sufijo son overrides, no alternativas: run.sh siempre pasa -f compose.yml y añade los otros encima según dos interruptores de entorno.

flowchart LR
    RUN["run.sh"] --> BASE["-f compose.yml siempre"]
    RUN --> TLSQ{"BUZZ_COMPOSE_TLS=true"}
    RUN --> DEVQ{"BUZZ_COMPOSE_DEV=true"}
    TLSQ -->|si| CADDY["-f compose.caddy.yml"]
    DEVQ -->|si| DEVF["-f compose.dev.yml"]
    BASE --> CMD["docker compose --env-file .env ..."]
    CADDY --> CMD
    DEVF --> CMD

El stack base, servicio por servicio

relay

relay:
  image: ${BUZZ_IMAGE:-ghcr.io/block/buzz:main}
  env_file:
    - .env
  environment:
    BUZZ_BIND_ADDR: 0.0.0.0:3000
    BUZZ_HEALTH_PORT: "8080"
    BUZZ_METRICS_PORT: "9102"
    DATABASE_URL: postgres://${POSTGRES_USER:-buzz}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-buzz}
    REDIS_URL: redis://:${REDIS_PASSWORD:?set REDIS_PASSWORD}@redis:6379
    BUZZ_S3_ENDPOINT: http://minio:9000
    BUZZ_S3_ADDRESSING_STYLE: path
    ...
  ports:
    - "${BUZZ_HTTP_PORT:-3000}:3000"
  volumes:
    - buzz-git-data:/data/git

Tres detalles importantes de este bloque:

Uno. El bloque environment gana sobre env_file: todo lo que aparece ahí no lo puedes cambiar desde tu .env, incluidos BUZZ_S3_ENDPOINT: http://minio:9000 y BUZZ_S3_ADDRESSING_STYLE: path. El README.md del bundle lo declara explícitamente: el DNS de Docker resuelve minio, no <bucket>.minio, así que el direccionamiento tiene que ser path, y el bundle no es configurable para un proveedor S3 externo a través de .env. Si necesitas S3 gestionado, la salida es el chart de Helm (que verás en el capítulo 6) o una configuración Compose propia.

Dos. La sintaxis ${VAR:?mensaje} de Compose aborta si la variable no está definida. POSTGRES_PASSWORD, REDIS_PASSWORD, BUZZ_S3_ACCESS_KEY y BUZZ_S3_SECRET_KEY son obligatorias por construcción: sin ellas docker compose ni siquiera renderiza el stack.

Tres. Solo se publica un puerto al host: ${BUZZ_HTTP_PORT:-3000}:3000. El de salud (8080) y el de métricas (9102) están configurados vía entorno pero no se publican: son alcanzables desde dentro de buzz-net, no desde fuera del VPS. Volveremos a esto en el capítulo 12.

El healthcheck del relay merece párrafo propio, porque es peculiar:

healthcheck:
  test: ["CMD-SHELL", "bash -ec 'exec 3<>/dev/tcp/127.0.0.1/8080; printf \"GET /_readiness HTTP/1.1\\r\\nHost: 127.0.0.1\\r\\nConnection: close\\r\\n\\r\\n\" >&3; grep -q \"200 OK\" <&3'"]
  interval: 10s
  timeout: 3s
  retries: 12
  start_period: 30s

Abre un socket TCP con el redireccionador /dev/tcp de bash, escribe a mano una petición HTTP contra /_readiness en el puerto de salud y busca 200 OK en la respuesta. No usa curl. Con start_period: 30s y 12 reintentos cada 10 segundos, el relay tiene margen para arrancar antes de que Compose lo marque como no saludable.

Qué comprueba realmente /_readiness: el handler del relay verifica la bandera de apagado, hace ping a Postgres y pide una conexión a Redis, todo con un timeout de 2 segundos. Si ambos responden devuelve {"status": "ready"}; si no, un 503 con el detalle de cuál falló. Es decir, “healthy” en docker compose ps significa que el relay tiene base de datos y Redis vivos, no solo que el proceso existe.

postgres, redis, minio, minio-init

postgres:
  image: postgres:17-alpine
  environment:
    PGDATA: /var/lib/postgresql/data/pgdata
  volumes:
    - buzz-postgres-data:/var/lib/postgresql/data
  healthcheck:
    test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]

redis:
  image: redis:7-alpine
  command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:?set REDIS_PASSWORD}"]
  volumes:
    - buzz-redis-data:/data

minio:
  image: minio/minio:RELEASE.2025-09-07T16-13-09Z
  command: server /data --console-address ":9001"
  environment:
    MINIO_ROOT_USER: ${BUZZ_S3_ACCESS_KEY:?set BUZZ_S3_ACCESS_KEY}
    MINIO_ROOT_PASSWORD: ${BUZZ_S3_SECRET_KEY:?set BUZZ_S3_SECRET_KEY}

Redis arranca con --appendonly yes: persiste en disco. Buzz lo usa para pub/sub, presencia, indicadores de escritura y rate limiting compartido; nada de eso es un dato de archivo, pero el AOF evita perder estado transitorio en un reinicio limpio.

MinIO va con imagen pinneada, no latest. Y fíjate en la simetría: las credenciales root de MinIO son las credenciales S3 del relay. No hay dos juegos de secretos que sincronizar.

minio-init es un contenedor one-shot (restart: "no") con minio/mc:RELEASE.2025-08-13T08-35-41Z que ejecuta tres comandos y muere:

mc alias set local http://minio:9000 "$BUZZ_S3_ACCESS_KEY" "$BUZZ_S3_SECRET_KEY"
mc mb --ignore-existing "local/$BUZZ_S3_BUCKET"
mc anonymous set none "local/$BUZZ_S3_BUCKET"

Crea el bucket si no existe y le quita el acceso anónimo. El bucket de Buzz nunca es público: la lectura de media pasa siempre por el relay con autenticación Blossom, como verás en el capítulo 9.

El orden de arranque

El relay declara dependencias con condiciones, no con simple orden:

flowchart TD
    PG["postgres · service_healthy"] --> RELAY["relay"]
    RD["redis · service_healthy"] --> RELAY
    MIN["minio · service_healthy"] --> RELAY
    MIN --> INIT["minio-init · service_completed_successfully"]
    INIT --> RELAY
    RELAY -->|solo con compose.caddy.yml| CADDY["caddy · depende de relay service_healthy"]

minio-init tiene que terminar con éxito antes de que el relay arranque: si el bucket no existe, el relay fallaría al primer contacto con S3. Y Caddy espera a que el relay esté service_healthy, no solo iniciado. El resultado es que up -d --wait o devuelve el stack completo funcionando, o falla ruidosamente.

Preparar el VPS

Requisitos

  • Docker Compose v2.24.4 o superior. No es negociable: el override de TLS usa el tag !reset de Compose para eliminar el puerto directo del relay, y ese tag no existe en versiones anteriores. Verifica con docker compose version.
  • Un dominio bajo tu control.
  • Puertos 80 y 443 abiertos hacia el VPS si vas a usar Caddy. Let’s Encrypt necesita alcanzar el 80 para el desafío HTTP.

DNS

Antes de tocar nada más, apunta un registro A del dominio al IPv4 del servidor (y AAAA si tienes IPv6) y confirma la propagación con dig +short buzz.example.com. Caddy pide el certificado en cuanto arranca; si el DNS aún no resuelve, la emisión falla y toca esperar el reintento.

Traer el bundle

git clone https://github.com/block/buzz.git
cd buzz/deploy/compose
cp .env.example .env

No necesitas compilar nada en el VPS: el stack usa la imagen publicada ghcr.io/block/buzz. El código fuente solo te da los archivos de Compose.

Generar secretos

El .env.example viene con placeholders CHANGE_ME deliberadamente inservibles, y run.sh se niega a arrancar mientras quede uno. El README menciona un script de bootstrap que generará estos valores automáticamente, pero está descrito en futuro (“should eventually replace manual .env editing”, “once it lands”): hoy no existe en el repositorio. Los generas a mano.

Las contraseñas de infraestructura son bytes aleatorios; genera una distinta para POSTGRES_PASSWORD, REDIS_PASSWORD, BUZZ_S3_ACCESS_KEY, BUZZ_S3_SECRET_KEY y BUZZ_GIT_HOOK_HMAC_SECRET:

openssl rand -hex 32

BUZZ_GIT_HOOK_HMAC_SECRET tiene una restricción dura en el código: si está definida y mide menos de 32 caracteres, el relay falla al arrancar. openssl rand -hex 32 produce 64 caracteres, sobrado.

La clave del relay y la del owner son claves Nostr, y para esas hay una herramienta en la propia imagen:

docker run --rm --entrypoint /usr/local/bin/buzz-admin \
  ghcr.io/block/buzz:main generate-key

Imprime Public key: y Secret key: en hexadecimal. Necesitas dos ejecuciones distintas:

  1. La primera da la identidad del relay: pon el secret key en BUZZ_RELAY_PRIVATE_KEY.
  2. La segunda da tu identidad de operador: pon el public key en RELAY_OWNER_PUBKEY y guarda el secret key donde guardas tus claves, porque es con lo que administrarás la comunidad.

Dos avisos sobre estas claves:

  • BUZZ_RELAY_PRIVATE_KEY es la identidad del relay. Rotarla no es “cambiar una contraseña”: es crear un relay nuevo desde el punto de vista de los clientes. Guárdala con el mismo cuidado que la base de datos.
  • RELAY_OWNER_PUBKEY no lleva prefijo BUZZ_. Es deliberado y está documentado tanto en el código como en el README del bundle. Escribirla como BUZZ_RELAY_OWNER_PUBKEY significa que simplemente no se lee. Debe ser hex de 64 caracteres.

Rellenar el .env

El .env.example de producción está organizado en bloques. Este es el mapa de lo que tienes que tocar:

BUZZ_IMAGE=ghcr.io/block/buzz:main

# Identidad pública del despliegue
BUZZ_DOMAIN=buzz.example.com
RELAY_URL=wss://buzz.example.com
BUZZ_MEDIA_BASE_URL=https://buzz.example.com/media
BUZZ_MEDIA_SERVER_DOMAIN=buzz.example.com
BUZZ_CORS_ORIGINS=https://buzz.example.com

# Postura de seguridad. Estos valores NO son los defaults del binario.
BUZZ_REQUIRE_AUTH_TOKEN=true
BUZZ_REQUIRE_RELAY_MEMBERSHIP=true
BUZZ_ALLOW_NIP_OA_AUTH=true
BUZZ_AUTO_MIGRATE=true
BUZZ_GIT_CONFORMANCE_PROBE=true
RUST_LOG=buzz_relay=info,buzz_db=info,buzz_auth=info,buzz_pubsub=info,tower_http=info

RELAY_OWNER_PUBKEY=<hex de 64 caracteres>

BUZZ_RELAY_PRIVATE_KEY=<hex de 64 caracteres>
BUZZ_GIT_HOOK_HMAC_SECRET=<hex aleatorio>
POSTGRES_DB=buzz
POSTGRES_USER=buzz
POSTGRES_PASSWORD=<aleatorio>
REDIS_PASSWORD=<aleatorio>
BUZZ_S3_ACCESS_KEY=<aleatorio>
BUZZ_S3_SECRET_KEY=<aleatorio>
BUZZ_S3_BUCKET=buzz-media
BUZZ_S3_ADDRESSING_STYLE=path

BUZZ_HTTP_PORT=3000
CADDY_HTTP_PORT=80
CADDY_HTTPS_PORT=443

Cuatro observaciones que se pagan caras si se ignoran:

RELAY_URL es la URL que aparece en los desafíos NIP-42. No es decorativa: si el cliente se conecta a wss://buzz.example.com y el relay firma desafíos con otra URL, la autenticación falla. Tiene que coincidir exactamente con lo que los clientes usan, esquema incluido. El capítulo 7 entra en el mecanismo.

Las tres banderas de seguridad valen false en el binario. BUZZ_REQUIRE_AUTH_TOKEN, BUZZ_REQUIRE_RELAY_MEMBERSHIP y BUZZ_ALLOW_NIP_OA_AUTH son true en este .env.example, no por defecto. Un relay arrancado sin esas variables es un relay abierto. Si escribes tu propio .env desde cero, ponlas.

BUZZ_AUTO_MIGRATE es opt-in. El compose.yml la pasa como ${BUZZ_AUTO_MIGRATE:-false}, así que si no la defines el relay arranca contra una base de datos sin esquema. Las opciones son ponerla en true (lo que hace el .env.example) o ejecutar buzz-admin migrate antes del primer arranque. La automigración requiere una imagen con las migraciones SQLx embebidas, que es el caso de la imagen oficial.

BUZZ_GIT_CONFORMANCE_PROBE=true es una puerta de admisión, no un aviso. Al arrancar, el relay corre una sonda de escritura condicional linealizable contra el object store. Si el backend no la pasa, el arranque falla: el relay no abre el listener. Es intencional, porque el protocolo de punteros de git depende de esa propiedad. Con el MinIO del bundle pasa; con un S3 exótico puede no pasar, y ahí quieres enterarte al arrancar y no cuando un push corrompa un repositorio.

Primer arranque

Antes de levantar nada, valida que el stack renderiza con ./run.sh config, que ejecuta require_env y luego docker compose config. require_env hace dos comprobaciones y aborta en cualquiera de ellas:

  1. Que exista deploy/compose/.env.
  2. Que ninguna línea del .env case con el patrón VAR=...CHANGE_ME.

Si pasa, imprime la configuración combinada de todos los -f activos: la forma más rápida de confirmar que las variables se sustituyeron como esperabas, antes de crear volúmenes.

Ahora arranca con ./run.sh start. Por debajo es docker compose --env-file .env -f compose.yml up -d --wait: el flag --wait bloquea hasta que todos los servicios con healthcheck estén saludables, o falla. Cuando devuelve el control, el stack está operativo de verdad.

Verifica, tal como propone el README del bundle:

curl -fsS "http://127.0.0.1:$(grep -E '^BUZZ_HTTP_PORT=' .env | cut -d= -f2-)/_liveness"
./run.sh status        # docker compose ps con los archivos correctos
./run.sh logs          # sigue el servicio relay por defecto

/_liveness está registrado tanto en el router principal como en el de salud, así que responde en el puerto 3000. En este punto tienes un relay funcional en http://<ip-del-vps>:3000, sin cifrar. Sirve para probar. No sirve para usarlo.

TLS automático con Caddy

BUZZ_COMPOSE_TLS=true ./run.sh start

Eso es todo. Lo que ocurre por dentro son dos cambios sobre el stack base.

El primero, en compose.caddy.yml:

services:
  relay:
    ports: !reset []

El tag !reset de Compose borra la lista de puertos del relay en lugar de fusionarla. El relay deja de estar publicado en el host: solo es alcanzable desde buzz-net. Esto es lo que exige Compose v2.24.4 como mínimo.

El segundo, el propio Caddy:

caddy:
  image: caddy:2-alpine
  depends_on:
    relay: { condition: service_healthy }
  environment:
    BUZZ_DOMAIN: ${BUZZ_DOMAIN:?set BUZZ_DOMAIN}
  ports:
    - "${CADDY_HTTP_PORT:-80}:80"
    - "${CADDY_HTTPS_PORT:-443}:443"
  volumes:
    - ./Caddyfile:/etc/caddy/Caddyfile:ro
    - buzz-caddy-data:/data
    - buzz-caddy-config:/config

BUZZ_DOMAIN es obligatoria aquí: sin ella el stack no renderiza. Y el Caddyfile completo, las cinco líneas:

{$BUZZ_DOMAIN} {
  encode zstd gzip

  reverse_proxy relay:3000
}

Nada más. No hay rate limiting, ni cabeceras de seguridad, ni bloques específicos para WebSocket. La única línea de enrutamiento es reverse_proxy relay:3000, que reenvía la conexión al relay dentro de la red de Docker. Caddy obtiene y renueva el certificado de Let’s Encrypt automáticamente para el nombre del bloque de sitio.

Si necesitas cabeceras de seguridad o límites de tasa en el edge, es trabajo tuyo sobre este archivo; lo verás en el capítulo 14.

flowchart LR
    CLIENT["Cliente Buzz"] -->|"wss://buzz.example.com"| CADDY["caddy · puertos 80 y 443"]
    CADDY -->|"ws://relay:3000 dentro de buzz-net"| RELAY["relay"]
    RELAY --> PG["postgres:5432"]
    RELAY --> RD["redis:6379"]
    RELAY --> MIN["minio:9000"]
    RELAY --> GIT["volumen buzz-git-data en /data/git"]
    LE["Let's Encrypt"] -.->|"emisión y renovación"| CADDY

Verificar el despliegue con TLS

Ojo con un detalle: con el override activo el relay ya no publica el puerto 3000, así que el curl a 127.0.0.1:3000 del arranque sin TLS deja de funcionar. Verifica desde fuera, contra el dominio:

# Liveness a través de Caddy
curl -fsS https://buzz.example.com/_liveness

# NIP-11: el endpoint raíz negocia contenido. Con este Accept devuelve
# el documento de información del relay en vez de intentar un upgrade WS.
curl -fsS -H "Accept: application/nostr+json" https://buzz.example.com/

# Certificado
openssl s_client -connect buzz.example.com:443 -servername buzz.example.com </dev/null 2>/dev/null | openssl x509 -noout -dates

Y desde dentro del stack, /_status en el puerto de salud devuelve service, version y uptime_seconds: es la forma canónica de confirmar qué versión del relay está corriendo realmente.

docker compose -f compose.yml -f compose.caddy.yml --env-file .env \
  exec relay bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080; printf "GET /_status HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n" >&3; cat <&3'

La prueba definitiva es un cliente conectándose a wss://buzz.example.com. Si el handshake WebSocket sube y el desafío NIP-42 se resuelve, el despliegue está bien: significa que RELAY_URL coincide con la URL real.

Referencia de run.sh

run.sh no hace magia: es un envoltorio delgado que garantiza que siempre uses los mismos -f y el mismo --env-file. Esa consistencia es justo lo que se pierde cuando escribes docker compose a mano.

ComandoQué ejecuta¿Valida secretos?
start / upcompose up -d --wait
stop / downcompose down — conserva volúmenesNo
restartcompose up -d --wait --force-recreate relay
pullcompose pull
upgradecompose pull + up -d --wait + recordatorio de backup
logs [svc]compose logs -f (por defecto relay)No
status / pscompose psNo
configcompose config
backup-hintImprime la checklist de respaldoNo
add-member <npub-o-hex> [--role member|admin]docker compose exec relay /usr/local/bin/buzz-admin add-member --pubkey <valor>No
remove-member <npub-o-hex> [--role ...]Ídem con remove-member --pubkey <valor>No
list-membersdocker compose exec relay /usr/local/bin/buzz-admin list-membersNo

Tres cosas que conviene saber: restart solo recrea el relay (es lo que quieres tras cambiar el .env o la imagen, sin tocar Postgres, Redis ni MinIO); los subcomandos de miembros invocan docker compose exec sin los -f ni el --env-file que usa el resto del script, una asimetría real que funciona porque los ejecutas desde deploy/compose/ con el stack arriba; y nunca añadas miembros en paralelo. El propio help de run.sh lo advierte: hay que poner sleep 1 entre invocaciones, porque el evento de roster kind:13534 colisiona con marcas de tiempo del mismo segundo. Textualmente: “Do not use parallel adds (e.g. xargs -P)”.

# Correcto
while read -r pk; do
  ./run.sh add-member "$pk" --role member
  sleep 1
done < miembros.txt

La gestión de membresía y roles es el tema del capítulo 8.

Persistencia: dónde vive el estado

El stack define volúmenes con nombre y etiquetados, lo que hace trivial identificarlos con docker volume ls --filter label=com.buzz.volume:

VolumenEtiqueta com.buzz.volumeQué contiene¿Se pierde algo si lo borras?
buzz-postgres-datapostgresEventos, canales, membresía, audit log, índice FTSTodo el historial de la comunidad
buzz-redis-dataredisAOF de Redis: presencia, rate limitsEstado transitorio; se reconstruye
buzz-minio-dataminioMedia Blossom y objetos gitAdjuntos y repositorios
buzz-git-datagit/data/git: workspaces efímeros y caché de packsCaché; se rehidrata desde el object store
buzz-caddy-datacaddy-dataCertificados TLS y cuenta ACMESe reemiten, sujeto a rate limits de Let’s Encrypt
buzz-caddy-configcaddy-configConfiguración autogestionada de CaddySe regenera

./run.sh stop ejecuta docker compose down sin -v: los volúmenes sobreviven. Para destruir el despliegue de verdad hay que pedirlo explícitamente, y ese comando no está en run.sh a propósito.

./run.sh backup-hint imprime la checklist oficial:

  1. deploy/compose/.env, especialmente BUZZ_RELAY_PRIVATE_KEY, los secretos de DB/Redis/S3 y BUZZ_GIT_HOOK_HMAC_SECRET.
  2. La clave privada del owner, si generaste una para RELAY_OWNER_PUBKEY.
  3. Los datos de Postgres, preferiblemente vía pg_dump o un snapshot de volumen en reposo.
  4. El contenido del bucket MinIO/S3: media y objetos git.
  5. El volumen buzz-git-data (BUZZ_GIT_REPO_PATH=/data/git).
  6. Los volúmenes data y config de Caddy si usas el override TLS.

Y una regla que no es opcional: los snapshots de Postgres y los de object/git deben venir de la misma ventana de mantenimiento. Postgres guarda los punteros, el object store guarda los objetos; restaurar mitades de momentos distintos produce referencias colgantes. El capítulo 13 desarrolla el procedimiento completo.

Dimensionamiento

El repositorio no publica una tabla de “VPS recomendado”, pero sí varios números que permiten razonar el tamaño. El chart de Helm declara para el pod del relay requests de 500m de CPU y 512Mi de memoria, con limits de 2 CPU y 2Gi: ese es el punto de partida oficial del proyecto para un relay. En Compose no hay límites declarados, así que ese rango te dice qué esperar del contenedor, no qué te van a imponer.

A eso hay que sumarle, en el mismo VPS, Postgres, Redis, MinIO y Caddy. Un servidor de 2 vCPU y 4 GB es un piso razonable para una comunidad pequeña; con 4 vCPU y 8 GB tienes margen para que el barrido de almacenamiento y las operaciones de git no compitan con el tráfico de mensajes.

Los límites que de verdad marcan el consumo:

VariableDefaultEfecto en el dimensionamiento
BUZZ_MAX_CONNECTIONS10000Techo de conexiones WebSocket simultáneas
BUZZ_MAX_CONCURRENT_HANDLERS1024Handlers en vuelo: presiona CPU
BUZZ_DB_POOL_SIZE50Conexiones al writer. Si lo subes, revisa max_connections de Postgres
BUZZ_REDIS_POOL_SIZE16Conexiones a Redis
BUZZ_MAX_IMAGE_BYTES50 MiBCota superior por imagen subida
BUZZ_MAX_VIDEO_BYTES500 MiBCota superior por video: dimensiona el disco del bucket
BUZZ_GIT_MAX_PACK_BYTES500 MiBPack máximo aceptado
BUZZ_GIT_PACK_CACHE_MAX_BYTES5 GiBCaché de packs en buzz-git-data: espacio en disco real
BUZZ_GIT_MAX_CONCURRENT_OPS20Operaciones git simultáneas: pico de CPU y disco

Para el disco, la aritmética es simple: los datos de Postgres crecen con el historial de eventos, el bucket crece con la media, y buzz-git-data puede llegar hasta el tamaño de la caché de packs. Un VPS con 40 GB se queda corto rápido si la comunidad comparte video.

Una restricción a tener presente aunque no sea de tamaño: BUZZ_HUDDLE_AUDIO_AVAILABLE es true por defecto en el binario, y solo es seguro en despliegues de un solo proceso hasta que exista un SFU. En un VPS con Compose estás exactamente en ese caso, así que no hay nada que hacer. Deja de ser cierto en cuanto escales a réplicas, que es territorio del capítulo 6.

Actualizar la versión desplegada

Primero: pinnea la imagen

.env.example viene con BUZZ_IMAGE=ghcr.io/block/buzz:main, y el README del bundle es explícito sobre por qué: ese tag sigue la rama principal y sirve para pruebas tempranas. Para producción hay que pinnear a ghcr.io/block/buzz:sha-<7> o a un tag semver de release cuando exista, por ejemplo BUZZ_IMAGE=ghcr.io/block/buzz:sha-a1b2c3d.

Con :main, cada pull te trae lo que sea que esté en main en ese momento. Con un SHA corto, un pull es un no-op hasta que tú decides moverte, y sabes exactamente a qué versión vuelves si algo sale mal.

El ciclo de actualización

cd deploy/compose

# 1. Respalda antes de tocar nada
./run.sh backup-hint

# 2. Actualiza el pin de la imagen en .env
$EDITOR .env

# 3. Descarga sin aplicar
./run.sh pull

# 4. Aplica y espera a que quede saludable
./run.sh upgrade

upgrade hace compose pull, luego compose up -d --wait, y al terminar imprime el recordatorio de backup. Compose solo recrea los contenedores cuya definición o imagen cambió: si únicamente movías BUZZ_IMAGE, Postgres, Redis y MinIO ni se enteran.

Si el cambio es solo de configuración y no de imagen, ./run.sh restart recrea únicamente el relay.

Sobre migraciones: con BUZZ_AUTO_MIGRATE=true el relay aplica las migraciones SQLx pendientes al arrancar, antes de abrir el listener; si alguna falla, el arranque falla. Si prefieres controlarlo —el patrón que quieres cuando la actualización cruza una migración pesada— deja la variable en false y ejecútala explícitamente antes de subir la versión nueva:

docker compose --env-file .env -f compose.yml run --rm \
  --entrypoint /usr/local/bin/buzz-admin relay migrate

Qué observar tras el upgrade

Comprueba ./run.sh status (todos healthy), ./run.sh logs relay y curl -fsS https://buzz.example.com/_liveness. En los logs del relay, tres líneas confirman un arranque limpio: la conexión a Postgres, el resultado de las migraciones y el veredicto de la sonda de conformidad de git contra el object store. Si la sonda falla, el relay no abre el listener y el healthcheck nunca pasa a saludable: up -d --wait te lo dirá en lugar de dejarte un servicio medio roto.

El override de administración local

compose.dev.yml existe para cuando necesitas mirar dentro del stack de producción: BUZZ_COMPOSE_DEV=true ./run.sh status. Añade tres cosas: publica los puertos de Postgres (5432), Redis (6379) y MinIO (9000 y 9001); levanta Adminer en el 8082; y levanta Prometheus en el 9090 montando el prometheus.yml de la raíz del repositorio. Se combina con el override de TLS, así que puedes usar los dos interruptores a la vez.

Nada de esto está en el stack base, y es correcto que así sea. Publicar Postgres al host de un VPS público es exponer tu base de datos a internet. Si usas este override en un servidor real, hazlo con el firewall cerrado y accediendo por un túnel SSH:

ssh -N -L 8082:127.0.0.1:8082 -L 9090:127.0.0.1:9090 usuario@vps

La imagen que estás desplegando

Vale la pena saber qué hay dentro de ghcr.io/block/buzz, porque explica varias decisiones del Compose. Es un Dockerfile multi-stage: cargo-chef para cachear el grafo de dependencias de Rust, una etapa independiente con Node y pnpm que compila los bundles de web y admin-web, y un runtime debian-slim con ca-certificates, curl, git y openssl. git está ahí porque el relay invoca git para hidratar repos y para receive-pack / upload-pack.

La imagen trae tres binarios: buzz-relay (el ENTRYPOINT), buzz-admin (el que usa run.sh para los miembros y las migraciones) y buzz-pair-relay. Corre como usuario no-root buzz con uid y gid 1000, con WORKDIR /var/lib/buzz, y declara EXPOSE 3000 8080 9102 — los mismos tres puertos del compose.yml. El directorio /data/git está precreado con owner buzz:buzz para que el volumen buzz-git-data herede los permisos correctos, y BUZZ_WEB_DIR / BUZZ_ADMIN_WEB_DIR vienen fijadas a /srv/buzz/web y /srv/buzz/admin-web: la UI web se sirve desde el propio relay sin configurar nada, mientras que el bundle de admin queda inerte hasta que definas BUZZ_ADMIN_HOST.

Un detalle de higiene: el .dockerignore excluye del contexto de build docker-compose.yml, docker-compose.*.yml, prometheus.yml, docs/, desktop/, mobile/ y cualquier .env salvo .env.example. Tus secretos no acaban en una capa por accidente.

Errores frecuentes

SíntomaCausa habitual
./run.sh start aborta diciendo que hay CHANGE_MEQuedan placeholders en .env. require_env lo comprueba con un grep
unknown tag !resetDocker Compose anterior a v2.24.4
El stack no renderiza: set POSTGRES_PASSWORDFalta una variable obligatoria del ${VAR:?}
El relay nunca pasa a healthy/_readiness devuelve 503: Postgres o Redis no responden. Míralo en ./run.sh logs relay
Arranque fatal con la sonda de conformidadEl object store no cumple la escritura condicional. No la desactives sin entender qué implica para git
Tablas inexistentes al arrancarBUZZ_AUTO_MIGRATE no está en true y no corriste buzz-admin migrate
El cliente conecta pero NIP-42 fallaRELAY_URL no coincide con la URL real por la que llega el cliente
Caddy no consigue certificadoDNS sin propagar, o puerto 80 bloqueado en el firewall
curl 127.0.0.1:3000 no responde con TLS activoCorrecto: !reset quitó el puerto publicado. Verifica por el dominio
El bucket S3 no se puede apuntar a un proveedor externoEl bundle fija BUZZ_S3_ENDPOINT. Usa Helm o un Compose propio

Resumen

  • El docker-compose.yml de la raíz es solo desarrollo y ni siquiera incluye el relay; el despliegue de VPS vive en deploy/compose/, proyecto Compose buzz-prod.
  • El bundle son siete archivos: compose.yml como base, compose.caddy.yml y compose.dev.yml como overrides que run.sh añade según BUZZ_COMPOSE_TLS y BUZZ_COMPOSE_DEV.
  • El stack base levanta relay, postgres:17-alpine, redis:7-alpine, minio pinneado y el one-shot minio-init, con depends_on condicionales que garantizan orden real de arranque.
  • El healthcheck del relay abre /dev/tcp/127.0.0.1/8080, pide /_readiness y busca 200 OK; ese endpoint verifica Postgres y Redis con timeout de 2 segundos.
  • Los secretos se generan a mano con openssl rand -hex 32 y buzz-admin generate-key: el script de bootstrap que menciona el README todavía no existe en el repositorio.
  • run.sh se niega a arrancar si falta el .env o si queda un solo CHANGE_ME; RELAY_OWNER_PUBKEY va sin prefijo BUZZ_ y debe ser hex de 64 caracteres.
  • El TLS es un interruptor: BUZZ_COMPOSE_TLS=true quita el puerto directo del relay con !reset, levanta Caddy en 80 y 443 y expone wss:// con certificado automático. Exige Compose v2.24.4 o superior.
  • El Caddyfile son cinco líneas de reverse_proxy relay:3000: sin rate limiting ni cabeceras de seguridad. Ese endurecimiento es trabajo tuyo.
  • El estado vive en volúmenes etiquetados com.buzz.volume; ./run.sh stop los conserva y backup-hint lista lo que hay que respaldar, con Postgres y object store desde la misma ventana.
  • Para actualizar: pinnea BUZZ_IMAGE a sha-<7> o a un tag semver, respalda, pull, upgrade, y confirma con /_status qué versión quedó corriendo.
  • El bundle fija BUZZ_S3_ENDPOINT=http://minio:9000 y BUZZ_S3_ADDRESSING_STYLE=path: no es configurable para S3 externo desde .env.

Siguiente: Relay gestionado: Railway y Kubernetes con Helm