Despliegue en un VPS con Docker Compose
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 Compose | buzz | buzz-prod |
| ¿El relay está en el stack? | No. Corre en el host con just relay o just dev | Sí, como contenedor con la imagen publicada |
| Credenciales | Fijas y públicas: buzz / buzz_dev | Todas obligatorias vía .env, sin defaults inseguros |
| Puertos de Postgres, Redis, MinIO | Publicados al host | No publicados |
| Servicios extra | Adminer, Keycloak, Prometheus | Ninguno por defecto |
| TLS | No aplica | Override opcional con Caddy |
| Imágenes | minio/minio:latest | Releases 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:
| Archivo | Rol |
|---|---|
compose.yml | El stack base: relay, postgres, redis, minio, minio-init |
compose.caddy.yml | Override de TLS: quita el puerto directo del relay y añade Caddy |
compose.dev.yml | Override de administración local: publica puertos internos y añade Adminer + Prometheus |
run.sh | Wrapper de operación con validación de secretos |
Caddyfile | Cinco líneas de proxy inverso |
.env.example | Plantilla de configuración, 52 líneas |
README.md | Notas 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
!resetde Compose para eliminar el puerto directo del relay, y ese tag no existe en versiones anteriores. Verifica condocker 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:
- La primera da la identidad del relay: pon el secret key en
BUZZ_RELAY_PRIVATE_KEY. - La segunda da tu identidad de operador: pon el public key en
RELAY_OWNER_PUBKEYy 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_KEYes 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_PUBKEYno lleva prefijoBUZZ_. Es deliberado y está documentado tanto en el código como en el README del bundle. Escribirla comoBUZZ_RELAY_OWNER_PUBKEYsignifica 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:
- Que exista
deploy/compose/.env. - Que ninguna línea del
.envcase con el patrónVAR=...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.
| Comando | Qué ejecuta | ¿Valida secretos? |
|---|---|---|
start / up | compose up -d --wait | Sí |
stop / down | compose down — conserva volúmenes | No |
restart | compose up -d --wait --force-recreate relay | Sí |
pull | compose pull | Sí |
upgrade | compose pull + up -d --wait + recordatorio de backup | Sí |
logs [svc] | compose logs -f (por defecto relay) | No |
status / ps | compose ps | No |
config | compose config | Sí |
backup-hint | Imprime la checklist de respaldo | No |
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-members | docker compose exec relay /usr/local/bin/buzz-admin list-members | No |
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:
| Volumen | Etiqueta com.buzz.volume | Qué contiene | ¿Se pierde algo si lo borras? |
|---|---|---|---|
buzz-postgres-data | postgres | Eventos, canales, membresía, audit log, índice FTS | Todo el historial de la comunidad |
buzz-redis-data | redis | AOF de Redis: presencia, rate limits | Estado transitorio; se reconstruye |
buzz-minio-data | minio | Media Blossom y objetos git | Adjuntos y repositorios |
buzz-git-data | git | /data/git: workspaces efímeros y caché de packs | Caché; se rehidrata desde el object store |
buzz-caddy-data | caddy-data | Certificados TLS y cuenta ACME | Se reemiten, sujeto a rate limits de Let’s Encrypt |
buzz-caddy-config | caddy-config | Configuración autogestionada de Caddy | Se 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:
deploy/compose/.env, especialmenteBUZZ_RELAY_PRIVATE_KEY, los secretos de DB/Redis/S3 yBUZZ_GIT_HOOK_HMAC_SECRET.- La clave privada del owner, si generaste una para
RELAY_OWNER_PUBKEY. - Los datos de Postgres, preferiblemente vía
pg_dumpo un snapshot de volumen en reposo. - El contenido del bucket MinIO/S3: media y objetos git.
- El volumen
buzz-git-data(BUZZ_GIT_REPO_PATH=/data/git). - Los volúmenes
datayconfigde 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:
| Variable | Default | Efecto en el dimensionamiento |
|---|---|---|
BUZZ_MAX_CONNECTIONS | 10000 | Techo de conexiones WebSocket simultáneas |
BUZZ_MAX_CONCURRENT_HANDLERS | 1024 | Handlers en vuelo: presiona CPU |
BUZZ_DB_POOL_SIZE | 50 | Conexiones al writer. Si lo subes, revisa max_connections de Postgres |
BUZZ_REDIS_POOL_SIZE | 16 | Conexiones a Redis |
BUZZ_MAX_IMAGE_BYTES | 50 MiB | Cota superior por imagen subida |
BUZZ_MAX_VIDEO_BYTES | 500 MiB | Cota superior por video: dimensiona el disco del bucket |
BUZZ_GIT_MAX_PACK_BYTES | 500 MiB | Pack máximo aceptado |
BUZZ_GIT_PACK_CACHE_MAX_BYTES | 5 GiB | Caché de packs en buzz-git-data: espacio en disco real |
BUZZ_GIT_MAX_CONCURRENT_OPS | 20 | Operaciones 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íntoma | Causa habitual |
|---|---|
./run.sh start aborta diciendo que hay CHANGE_ME | Quedan placeholders en .env. require_env lo comprueba con un grep |
unknown tag !reset | Docker Compose anterior a v2.24.4 |
El stack no renderiza: set POSTGRES_PASSWORD | Falta 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 conformidad | El object store no cumple la escritura condicional. No la desactives sin entender qué implica para git |
| Tablas inexistentes al arrancar | BUZZ_AUTO_MIGRATE no está en true y no corriste buzz-admin migrate |
| El cliente conecta pero NIP-42 falla | RELAY_URL no coincide con la URL real por la que llega el cliente |
| Caddy no consigue certificado | DNS sin propagar, o puerto 80 bloqueado en el firewall |
curl 127.0.0.1:3000 no responde con TLS activo | Correcto: !reset quitó el puerto publicado. Verifica por el dominio |
| El bucket S3 no se puede apuntar a un proveedor externo | El bundle fija BUZZ_S3_ENDPOINT. Usa Helm o un Compose propio |
Resumen
- El
docker-compose.ymlde la raíz es solo desarrollo y ni siquiera incluye el relay; el despliegue de VPS vive endeploy/compose/, proyecto Composebuzz-prod. - El bundle son siete archivos:
compose.ymlcomo base,compose.caddy.ymlycompose.dev.ymlcomo overrides querun.shañade segúnBUZZ_COMPOSE_TLSyBUZZ_COMPOSE_DEV. - El stack base levanta
relay,postgres:17-alpine,redis:7-alpine,miniopinneado y el one-shotminio-init, condepends_oncondicionales que garantizan orden real de arranque. - El healthcheck del relay abre
/dev/tcp/127.0.0.1/8080, pide/_readinessy busca200 OK; ese endpoint verifica Postgres y Redis con timeout de 2 segundos. - Los secretos se generan a mano con
openssl rand -hex 32ybuzz-admin generate-key: el script de bootstrap que menciona el README todavía no existe en el repositorio. run.shse niega a arrancar si falta el.envo si queda un soloCHANGE_ME;RELAY_OWNER_PUBKEYva sin prefijoBUZZ_y debe ser hex de 64 caracteres.- El TLS es un interruptor:
BUZZ_COMPOSE_TLS=truequita el puerto directo del relay con!reset, levanta Caddy en 80 y 443 y exponewss://con certificado automático. Exige Compose v2.24.4 o superior. - El
Caddyfileson cinco líneas dereverse_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 stoplos conserva ybackup-hintlista lo que hay que respaldar, con Postgres y object store desde la misma ventana. - Para actualizar: pinnea
BUZZ_IMAGEasha-<7>o a un tag semver, respalda,pull,upgrade, y confirma con/_statusqué versión quedó corriendo. - El bundle fija
BUZZ_S3_ENDPOINT=http://minio:9000yBUZZ_S3_ADDRESSING_STYLE=path: no es configurable para S3 externo desde.env.