Operación

Desplegar styx en Coolify

Runbook de la pila completa de styx como aplicación Raw Compose de Coolify — compose de un solo fichero, secretos como ficheros del host con un one-shot y su smoke, Base Directory, dominios .vpn. con TLS del proxy y acceso sólo desde la tailnet (proxy y QUIC), infraestructura sin exponer, verificación, actualización y rollback.

EspecificadoNo implementado

Especificado, no probado contra un Coolify real. Todo lo que este runbook usa está en el repositorio y está comprobado en local: el compose resuelve con la Base Directory que se indica y falla con la de por defecto; el one-shot de secretos y su smoke tienen tests con un mutante por regla; los routers de Traefik salen del modelo de despliegue y check:deploy-render los compara con el compose. Que Coolify acepta el compose está comprobado leyendo su código (v4.x, validateDockerComposeForInjection), no desplegándolo. Lo que no se ha hecho es desplegarlo en la instancia de Coolify de waxin: los nombres de la UI son los de Coolify v4 y pueden cambiar (track/release-engineering#24, pendiente de verificación independiente).

Qué se despliega

PiezaQué es
Composedeploy/docker-compose.coolify.yml: docker-compose.infra.yml + docker-compose.apps.yml en un solo fichero, generado (bun run deploy:coolify-compose; un test lo compara byte a byte y comprueba que docker compose config lo resuelve igual que deploy/docker-compose.yml). Coolify rechaza deploy/docker-compose.yml: lo lee del repositorio al cargarlo y en cada despliegue, y un fichero sin services: (sólo include:) no pasa su validación. Aplicación Raw Compose Deployment (deploy/README.md, DS8-01): en el modo por defecto Coolify mete su red en todos los servicios y la infra deja de estar aislada
ImágenesDesde el código: Coolify las construye del clon del repositorio. Con release: imágenes por digest del manifiesto, con styx render --runtime coolify (ver Con una release)
SecretosFicheros del host, fuera del clon, en STYX_SECRETS_DIR. Los crea una vez bun run deploy:coolify-secrets -- init. Ningún secreto va en las variables de Coolify
InfraestructuraPostgres, Valkey, NATS y el collector sólo en styx-internal (red internal sin gateway): sin puertos publicados, sin salida, inalcanzables desde el proxy
EntradaEl Traefik de Coolify, enganchado a styx-edge con la IP fija 10.254.254.2. Routers con TLS (ACME del proxy): /auth (identity-svc) y /api/upload (ingesta del daemon) en STYX_PUBLIC_HOST, y el sitio de docs en STYX_DOCS_HOST. Cada router empieza por el middleware styx-tailnet-only
AccesoSólo la tailnet. alpha.0 no se publica hasta que AppSec esté seco (dec-0133). Los dominios llevan .vpn. (el hook de Coolify de waxin), pero que ese hook cubra los routers escritos como labels en Raw Compose no está verificado, y si es sólo DNS no para a quien llegue por la IP pública. Por eso el corte lo hace el propio proxy: styx-tailnet-only (deploy/traefik/styx-tailnet.yml, ipAllowList de 100.64.0.0/10 y fd7a:115c:a1e0::/48 por la dirección del socket) responde 403 a cualquier otro origen
QUICUDP 4433-4435 del daemon publicados en el host en 0.0.0.0 (styx-quic, DS9-01). deploy/host-firewall.sh con STYX_QUIC_ALLOW_FROM=100.64.0.0/10 descarta en DOCKER-USER todo UDP y TCP hacia esa red que no venga de la tailnet; el smoke (--quic-firewall) lo exige. El origen TCP del daemon (4435/tcp, HTTPS HTTP/1.1 con Alt-Svc hacia h3, dec-0134) se publica igual, directo en el host y sin router de Traefik: el daemon termina su TLS con el certificado de la QUIC y ve la dirección real del cliente
SaludCada servicio tiene healthcheck en el compose; los *-migrate corren antes de su servicio (service_completed_successfully)

Antes de empezar

  • Coolify v4 con su proxy Traefik por defecto (entrypoint https, resolvedor ACME letsencrypt).
  • Un dominio cuyo DNS controles. En este runbook: styx.vpn.<tu-dominio> para la API y docs.vpn.<tu-dominio> para la documentación, los dos apuntando al VPS.
  • En el host de Coolify, por SSH y con sudo: un checkout de MKS2508/styx (cualquiera; sólo se usa para el one-shot y el smoke) con bun install hecho, y docker compose v2.
  • El certificado TLS de la QUIC del daemon (cadena y clave del dominio de STYX_MEDIA_WT_HOST) en el host, legible por el uid 1001. Es también el del origen TCP (4435/tcp, dec-0134), al que un navegador llega sin flags: de una CA pública (Let's Encrypt), con la cadena completa (fullchain.pem). Los certificados ACME del Traefik de Coolify no sirven aquí (no son ficheros del daemon). Tras renovarlo, reinicia el daemon: la QUIC no recarga el certificado en caliente.
  • La configuración del OP OIDC (OIDC_ISSUER, OIDC_CLIENT_ID) y el SDK_READ_TOKEN de sólo lectura para construir las imágenes Bun (deploy/README.md, «Secreto de build»).

1. Secretos en el host (una vez)

Coolify clona el repositorio de nuevo en cada despliegue, así que deploy/secrets/ y deploy/.env de bun run deploy:secrets no sobreviven. El one-shot los deja en un directorio fijo del host:

cd /ruta/al/checkout/styx
sudo STYX_OPERATOR_UID="$(id -u)" bun run deploy:coolify-secrets -- init \
  --dir /data/styx/secrets \
  --env-out /data/styx/coolify.env \
  --traefik-dir /data/coolify/proxy/dynamic
  • --dir: absoluto y fuera de todo checkout (el script lo exige). Cada fichero queda del uid de su contenedor, 0400 o 0440; el directorio, 0700. Repetirlo no regenera nada: sólo crea lo que falte.
  • --env-out: lo público que el compose interpola (keyring y NKeys del bus, clave pública del SCT) y STYX_SECRETS_DIR. Es lo que se pega en Coolify; no lleva ningún secreto.
  • --traefik-dir: la configuración dinámica del proxy de Coolify. Deja styx-proxy-proof.yml (los middlewares styx-proxy-proof y styx-ingest-proxy-proof, con las pruebas de proxy de los secretos; 0600), styx-ingest.yml (los límites de la ingesta) y styx-tailnet.yml (el corte a la tailnet). Sin ellos los routers apuntan a middlewares que no existen y Traefik no los carga: falla cerrado. Coolify lista y enseña los ficheros de ese directorio en Proxy → Dynamic Configurations: quien administre Coolify ve las pruebas de proxy en claro. Es defensa en profundidad (esa persona ya es dueña del host), pero cuenta como un sitio más donde viven.
  • SIGNOZ_INGESTION_KEY en el entorno del init, si exportas a SigNoz; sin ella el collector usa una clave que SigNoz rechaza.

Copia de seguridad: el directorio de secretos y la clave privada de la QUIC van al backup del host (no están en Coolify ni en git). Perderlos es rotar las identidades del bus.

2. Recurso Raw Compose

  1. En el proyecto styx de Coolify: + New → Private Repository (with GitHub App) → MKS2508/styx, rama master, Build Pack: Docker Compose.
  2. Base Directory: /deploy y Docker Compose Location: /docker-compose.coolify.yml. Coolify corre compose con --project-directory <Base Directory> y compose resuelve contra ese directorio los build.context: .. y las rutas ./. Con /deploy coinciden con el directorio del fichero (como a mano); con la Base Directory / por defecto Coolify no encuentra el fichero, y los contextos de build caerían fuera del repositorio. No uses /docker-compose.yml: es el de include: y Coolify lo rechaza («Docker Compose file must contain a "services" section»). Lo mismo que se hizo con el sitio de docs (deploy/coolify/docs.compose.yml, Base Directory /deploy/coolify).
  3. En Advanced: activa Raw Compose Deployment y desactiva Escape special characters in labels (los routers usan ${STYX_PUBLIC_HOST} y ${STYX_DOCS_HOST} en sus labels).
  4. No asignes dominios en la UI: en Raw Compose los dominios son las labels de Traefik del compose, que salen de route en deploy/model/styx.deploy.yml.
  5. Un solo sitio de docs por dominio. Esta pila trae su propio docs en STYX_DOCS_HOST. Si la aplicación de docs suelta (deploy/coolify/docs.compose.yml, operacion/desplegar-docs) sigue desplegada con el mismo dominio, Traefik tiene dos routers para el mismo Host y cuál gana no está definido: para esa aplicación antes, o da a esta pila otro STYX_DOCS_HOST.

3. Variables de entorno del recurso

En Environment Variables → Developer view, pega /data/styx/coolify.env y añade lo del operador:

VariableEjemploPara qué
STYX_PUBLIC_HOSTstyx.vpn.<tu-dominio>dominio de /auth y /api/upload (router de Traefik)
STYX_DOCS_HOSTdocs.vpn.<tu-dominio>dominio del sitio de docs
IDENTITY_PUBLIC_URL, IDENTITY_ALLOWED_RETURN_ORIGINShttps://styx.vpn.<tu-dominio>identity-svc
OIDC_ISSUER, OIDC_CLIENT_IDtu OPlogin
STYX_INGEST_PUBLIC_URLhttps://styx.vpn.<tu-dominio>/api/uploadla ingesta
STYX_MEDIA_WT_HOST, STYX_MEDIA_ALLOWED_ORIGINSdominio de la QUIC, orígenes de la webel daemon
STYX_MEDIA_ROOTS, STYX_MEDIA_LIBRARYlibrary=/media/library:ro, /srv/mediabiblioteca (bind de sólo lectura)
STYX_MEDIA_TLS_CERT_FILE, STYX_MEDIA_TLS_KEY_FILErutas absolutas del hostTLS de la QUIC (secretos de fichero)
SDK_READ_TOKENPAT de sólo lecturasólo el build (BuildKit), nunca en runtime

Ninguna variable lleva un secreto de runtime: un secreto en el entorno de un contenedor, en cualquier valor del servicio o en un --env-file del smoke hace que el smoke falle y, en producción, que el servicio no arranque.

SDK_READ_TOKEN sí vive en Coolify. Marcado como variable de build, Coolify lo guarda en claro en su base de datos, lo enseña en la UI a todo el equipo y lo escribe en el .env de la aplicación en el host (el de runtime lleva todas las variables). Además, si el recurso no tiene activado Use Docker Build Secrets, Coolify añade cada variable de build como --build-arg al docker compose build (ApplicationDeploymentJob, generate_build_env_variables): actívalo para que sólo llegue como el secreto de BuildKit sdk_token que ya declaran los Dockerfiles. Usa un token de sólo lectura y sólo para los repositorios de los SDK, y rótalo si alguien deja el equipo de Coolify.

4. Cortafuegos y smoke antes del primer despliegue

El cortafuegos va por subred (fija en el compose), así que se aplica antes de que exista la red:

sudo STYX_QUIC_ALLOW_FROM=100.64.0.0/10 sh deploy/host-firewall.sh apply

Cierra styx-quic y egress-oidc como siempre y, con STYX_QUIC_ALLOW_FROM, descarta toda QUIC entrante que no venga de la tailnet. Hay que aplicarlo tras cada arranque del host (o persistirlo con iptables-save). Hacer pública la QUIC es parte de la decisión de publicar alpha.0 (waxin), no un ajuste del operador.

Guarda las variables del operador de la tabla (sin SDK_READ_TOKEN) en /data/styx/operator.env y resuelve el compose exactamente como lo hará Coolify:

sudo bun run deploy:coolify-secrets -- check \
  --dir /data/styx/secrets \
  --env-file /data/styx/coolify.env --env-file /data/styx/operator.env \
  --base-directory /deploy \
  --traefik-dir /data/coolify/proxy/dynamic \
  --quic-firewall

Verde (check: OK, exit 0) es: el compose de Coolify resuelve con esa Base Directory; todo build.context cae dentro del repositorio; cada secreto es un fichero absoluto bajo STYX_SECRETS_DIR, regular, no vacío y 0400/0440 (0600 no vale); ningún valor de un servicio (entorno, labels, command…) ni de los --env-file contiene el de un secreto; la infra no publica puertos; un puerto publicado fuera de la tailnet sólo pasa con --quic-firewall y deploy/host-firewall.sh check en verde con STYX_QUIC_ALLOW_FROM=100.64.0.0/10; cada router se llama styx-<servicio>, tiene la regla exacta del render (Host(…) y, opcional, PathPrefix(…): un STYX_PUBLIC_HOST que cuele un segundo Host no pasa), un dominio con la etiqueta vpn y styx-tailnet-only@file como primer middleware; y los middlewares del proxy llevan las pruebas de los secretos y el corte a la tailnet. Con --base-directory / falla: es la comprobación de que el paso 2 está bien puesto. Con sudo, porque cada secreto es del uid de su contenedor y iptables necesita root.

5. Proxy y red de borde

Tras el primer despliegue (la red styx-edge la crea compose):

docker network connect --ip 10.254.254.2 styx-edge coolify-proxy
  • La IP 10.254.254.2 es la única cuyo X-Forwarded-For aceptan identity-svc y la ingesta (deploy/README.md, «Proxy de entrada»). Coolify recrea coolify-proxy cuando cambias su configuración o lo reinicias: repite el docker network connect después (sin él los routers no llegan a styx y responden 502/504).
  • styx-tailnet-only decide por la dirección del socket que ve Traefik. Si el tráfico de la tailnet llega al proxy a través de otro proxy local (tailscale serve), esa dirección es 127.0.0.1 y todo responde 403: el corte falla cerrado. El camino previsto es el directo, al 443 publicado del host por su IP de Tailscale.

6. Desplegar y verificar

  1. Deploy en Coolify. Los *-migrate terminan antes de sus servicios; un despliegue sano deja todos los contenedores healthy.

  2. En el host, sobre el proyecto compose de la aplicación (el uuid de Coolify):

    bun run check:infra-live -- --project <uuid-de-la-aplicación>

    Exige usuarios no root, rootfs de sólo lectura, sin capabilities, seccomp, sin puertos fuera de loopback salvo la QUIC, redes exactamente las del compose y rechazo del anónimo en NATS, Valkey, OTLP y Postgres. Rojo si Coolify añadió su red (el recurso no está en Raw Compose).

  3. Desde un equipo en la tailnet:

    curl -sS -o /dev/null -w '%{http_code}\n' https://styx.vpn.<tu-dominio>/auth/session   # 401: routed a identity-svc
    curl -sS -o /dev/null -w '%{http_code}\n' https://docs.vpn.<tu-dominio>/health        # 200

    Un 503 IDENTITY_CLIENT_ADDRESS_UNKNOWN en /auth es la prueba de proxy que falta o no coincide (paso 1, --traefik-dir).

  4. Desde un equipo fuera de la tailnet, contra la IP pública aunque el DNS no la dé:

    curl -sS -o /dev/null -w '%{http_code}\n' --resolve styx.vpn.<tu-dominio>:443:<IP-pública> https://styx.vpn.<tu-dominio>/auth/session   # 403
    curl -sS -o /dev/null -w '%{http_code}\n' --resolve docs.vpn.<tu-dominio>:443:<IP-pública> https://docs.vpn.<tu-dominio>/health        # 403

    Un 401 o un 200 aquí es styx publicado: para y revisa styx-tailnet.yml en el directorio dinámico del proxy y las labels del router.

Con una release (imágenes por digest)

Cuando haya releases (dec-0123: alfas 0.0.0-alpha.N), el compose no se construye: lo renderiza el CLI desde el modelo con las imágenes por digest del manifiesto.

styx render --runtime coolify --manifest /opt/styx/current/styx-release.json --out ./styx-coolify

styx.toml lleva entonces STYX_PUBLIC_HOST y STYX_DOCS_HOST (además de lo de INSTALL.md §8), y validate() se niega a dar el compose por bueno (exit 1, una línea por regla) si queda un build:, un secreto por ruta relativa o sin resolver, un router sin dominio, un dominio sin la etiqueta vpn, una regla fuera de la gramática del render, un router sin styx-tailnet-only@file primero, un router que el modelo no declara (o uno TCP/UDP) o un router sin el resolvedor ACME del proxy. validate() no ve el cortafuegos del host: la QUIC se limita igual que arriba (paso 4). El compose renderizado se pega en el mismo recurso Raw Compose. Hoy styx render necesita un styx install previo en esa máquina para styx.toml, los secretos y las claves públicas (INSTALL.md §8).

Actualizar y volver atrás

  • Desde el código: redeploy del recurso (o el webhook, como en el sitio de docs). Los secretos y las variables no cambian; las migraciones las aplican los *-migrate.
  • Volver atrás: redeploy del commit anterior en Coolify. Si la versión nueva aplicó una migración contract, eso no basta: restaura antes el pg_dump del rol migrador hecho antes de actualizar (styx backup no opera Coolify todavía, track/release-engineering#24).
  • Rotar un secreto: borra su fichero, repite el init (sólo crea lo que falta), repite el --traefik-dir si era una prueba de proxy, y reinicia el servicio que lo lee.

Problemas frecuentes

SíntomaCausa
Docker Compose file must contain a "services" sectionDocker Compose Location /docker-compose.yml (el de include:) en vez de /docker-compose.coolify.yml (paso 2)
Docker Compose file not found at: /docker-compose.coolify.ymlBase Directory / en vez de /deploy (paso 2)
deploy/docker-compose.coolify.yml desactualizado (tests)Cambió un compose incluido: bun run deploy:coolify-compose y commit
403 desde un equipo de la tailnetEl tráfico llega al proxy por otro proxy local (127.0.0.1), o el cliente sale por otra red (paso 5)
SPIRE_KEYRING sin definirFalta pegar coolify.env en las variables del recurso
secret … file … no such fileSTYX_SECRETS_DIR no es el --dir del init, o el init se hizo en otro host
404 en el dominioEscape de labels activo (el router queda con ${STYX_PUBLIC_HOST} literal) o falta traefik.enable
502/504 en el dominiocoolify-proxy no está en styx-edge con 10.254.254.2 (paso 5)
check:infra-live rojo por una red <uuid>El recurso no está en Raw Compose Deployment

Lo que falta (track/release-engineering#24)

  • Probarlo en una instancia de Coolify real (instalación limpia, reboot del host, update N-1 → N con datos sembrados y rollback) y la suite de D12; hasta entonces el adapter declara Coolify tier 2.
  • styx status/update/rollback/backup contra Coolify (RE15) y un styx que genere styx.toml, secretos y claves sin styx install en esa máquina.
  • Las rutas de catalog, playback, sources y realtime: están en styx-edge pero el modelo no les da router todavía (la web, que las consume, no está en el modelo de despliegue).
  • Sin probar contra Coolify: que su parser de compose (el que corre al cargar el fichero) no cambie nada más del de un solo fichero, que la red <uuid> que conecta al proxy no rompa el aislamiento de Raw Compose, y que docker compose acepte los secrets.file del host cuando Coolify lo ejecuta dentro de su contenedor auxiliar (las rutas son del host; si compose las comprueba en el cliente, no las vería).