Skip to content

Autoalojamiento (despliegue soberano)

Futuros está diseñado para ser redesplegado en la región. Este es el Pilar 2 (el Modelo Soberano): un gobierno latinoamericano o un multilateral puede ejecutar la plataforma completa — el atlas, el corpus horneado, la API pública y el asistente de IA — sobre infraestructura dentro de su propia jurisdicción, apuntando la inferencia a un gateway de pesos abiertos en la región para que el corpus y cada consulta de usuario permanezcan en suelo soberano.

Como la plataforma es una SPA estática + un corpus JSON horneado + un conjunto pequeño de funciones serverless, el autoalojamiento viene en dos niveles.

Dos niveles, un repo

El Dockerfile + Caddyfile del repo producen un despliegue estático: la SPA, el corpus horneado de public/data/, la API Pública v1 y los embeds. Ese contenedor (Caddy sirviendo dist/) no ejecuta las funciones serverless de api/ — así que el chat, el servidor MCP, los endpoints de contribución/revocación del Fideicomiso de Datos, el consenso y las alertas no son servidos por él. Para ejecutar esos servicios interactivos necesitas un host que ejecute las funciones Node de api/* (Vercel, o cualquier runtime de Node/funciones), más el env de abajo.

Nivel 1 — espejo soberano estático (Docker + Caddy)

El Dockerfile multi-etapa incluido construye con Bun y entrega una imagen Caddy diminuta que sirve el build estático:

dockerfile
FROM oven/bun:1.3 AS build
WORKDIR /app
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile
COPY . .
ARG VITE_GATE_PASSWORD                 # opcional; sin ella la SPA no tiene compuerta
ENV VITE_GATE_PASSWORD=${VITE_GATE_PASSWORD}
RUN bun run build

FROM caddy:2-alpine
COPY --from=build /app/dist /srv
COPY Caddyfile /etc/caddy/Caddyfile
EXPOSE 8080
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]

Construir y ejecutar:

bash
docker build -t futuros --build-arg VITE_GATE_PASSWORD=your-gate-pass .
docker run -p 8080:8080 -e PORT=8080 futuros
# → http://localhost:8080

El Caddyfile escucha en :{$PORT:8080}, comprime con gzip/zstd, y se comporta, matcher por matcher:

MatcherComportamiento
/assets/*Cache-Control: public, max-age=31536000, immutable — bundles con hash
/data/*max-age=600, s-maxage=3600 — el corpus horneado se renueva a minutos de un redespliegue
/atlas/*, /geo/*max-age=86400, immutable — bundles geográficos estáticos
/embed/*elimina X-Frame-Options, fija Content-Security-Policy: frame-ancestors * — los embeds cross-origin funcionan desde un espejo
todo lo demásX-Frame-Options: SAMEORIGIN, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy denegando cámara/micrófono/geolocalización, y el header Server eliminado

El orden de resolución es try_files {path} {path}/ /spa.html: un archivo real siempre gana sobre el fallback de la SPA. Por eso los artefactos JSON horneados de /api/v1/* se sirven incluso en el Nivel 1 (son archivos planos en dist/), mientras que una ruta dinámica como /api/chat no tiene handler ahí y cae al shell de la SPA. Esto refleja el rewrite de vercel.json de toda ruta sin extensión y no reservada hacia /spa.html, con una diferencia deliberada: el patrón de Vercel también excluye /api/*, porque en Vercel las funciones viven ahí.

El objetivo del fallback importa: postbuild renombra dist/index.htmlspa.html y dist/landing.htmlindex.html (de modo que /index.html es la landing, no el shell de la app), luego prerrenderiza la landing (scripts/prerender-landing.mjs) y elimina cualquier CLAUDE.md que se haya filtrado a dist/. railway.json conecta el mismo Dockerfile para Railway: builder DOCKERFILE, health check /intro (timeout de 30 s), política de reinicio ON_FAILURE con 3 reintentos.

Este nivel es un espejo de soberanía de datos completo y capaz de operar offline: la UI completa del atlas y todo el corpus citado, alojados donde sea que ejecutes el contenedor. Todo lo que necesita un servidor (abajo) se degrada con honestidad — la app sigue usable, las funciones interactivas anuncian que no están configuradas en lugar de fingir un resultado.

Nivel 2 — plataforma completa (funciones serverless + inferencia soberana)

Las superficies interactivas viven en api/* como nueve funciones serverless de Node (chat, mcp, contribute, revoke, consensus, intake, alerts-subscribe, alerts-digest, ointake recibe propuestas de pilotos, pedidos de introducción y reportes de inconsistencias de los visitantes, persistiendo en una cola de Upstash con notificación por Mailgun, honesta-cuando-no-configurada como el resto). Despliégalas en un runtime que las ejecute (Vercel es el objetivo de referencia) y fija las variables de entorno de abajo. Los assets estáticos y el corpus son el mismo build que el Nivel 1.

Variables de entorno

Agrupadas por funcionalidad. Todo es opcional — cada clave sin fijar degrada con honestidad una funcionalidad específica (ver la tabla más abajo). Las claves de cliente llevan prefijo VITE_ y se hornean en el build; el resto son solo del lado del servidor.

Nota: el env.example del repo documenta solo las claves de mapa, analítica, chat/recuperación y la sal del ledger de confianza. Las claves de Upstash, alertas (Mailgun / webhooks) y Perplexity de abajo son leídas por el código pero no están en env.example — la lista autoritativa es esta.

Build / cliente (horneadas, con prefijo VITE_):

VarFuncionalidadSin ella
VITE_MAPBOX_TOKENMapa base de Mapbox en el atlasEl fallback de MapLibre renderiza límites sobre un lienzo oscuro
VITE_GATE_PASSWORDCompuerta suave en rutas de preview (no en /atlas, /comparar, /datos-abiertos, /metodologia)Sin compuerta — no hay contraseña de respaldo en el código
VITE_POSTHOG_HOSTOverride de región de PostHogPor defecto US Cloud

Analítica en un autoalojamiento: la clave de proyecto de PostHog está hardcodeada en src/lib/analytics.ts (una variable de entorno VITE_POSTHOG_KEY se ignora deliberadamente), y la inicialización está restringida a los hostnames futuros.xyz / *.futuros.xyz — un espejo soberano en otro dominio no emite ninguna analítica sin importar el env. El sitio de docs (docs/.vitepress/posthog-snippet.ts) usa la misma clave hardcodeada y el mismo gate de hostname. Apuntar la analítica a tu propio proyecto de PostHog significa editar esos archivos, no fijar una variable.

Las compuertas que fallan cerrado

Antes de cualquier clave de proveedor, tres interruptores explícitos gobiernan si un endpoint dinámico existe siquiera. Todos fallan cerrado: con la variable sin fijar, la superficie devuelve un rechazo limpio en vez de quedar abierta. Un .env vacío es, por diseño, una postura correcta — nunca una fuga.

VarQué abreSin ella
CHAT_ENABLEDPOST /api/chat público (además necesita una clave de proveedor y el rate limit de Upstash)403 — el asistente no responde. Este es el paso que más se olvida al autoalojar: con ANTHROPIC_API_KEY configurada pero sin este interruptor, el chat sigue devolviendo 403
MCP_ENABLEDPOST /api/mcp público (además necesita el rate limit de Upstash)403 {"error":"mcp_disabled"}. GET /api/mcp?health=1 sigue respondiendo y lo reporta en enabled
SCRAPE_WRITE_ENABLEDLa vía de escritura del scrapingModo solo-observación: las escrituras se rechazan

Cada interruptor debe valer literalmente la cadena "true".

Estos tres viven junto a los demás en server/http/env-levers.ts, la lista canónica de 15 palancas de producción (con su id, las variables que la componen, qué abre y una sonda pública para verificarla). Esa lista se sirve por GET /api/env-levers y se publica en /confianza, de modo que un operador — o un periodista — puede auditar la postura de un despliegue sin acceso a su entorno. leverStatus() reporta únicamente un booleano set por palanca: nunca el valor del secreto.

Asistente de IA — proveedor de inferencia (lado servidor; gana la primera coincidencia):

Var(s)Funcionalidad
SOVEREIGN_INFERENCE_URL + SOVEREIGN_INFERENCE_KEYRuta soberana del Pilar 2. Apunta el mismo bucle de agente Anthropic-Messages a un gateway de pesos abiertos en la región / autoalojado (LiteLLM, o vLLM/TGI detrás de un shim compatible con Messages, sirviendo Llama/Qwen/DeepSeek/Mistral). Cuando ambas están fijadas ganan sobre OpenRouter/Anthropic y el chat muestra una insignia de "modo soberano" — la consulta + el corpus nunca salen del host.
SOVEREIGN_INFERENCE_MODELEl served-model-name de tu gateway (por defecto local-model)
SOVEREIGN_INFERENCE_LABELEtiqueta de UI para el proveedor soberano (por defecto Soberano)
SOVEREIGN_INFERENCE_REGIONDeclara la región del gateway; la única vía por la que la insignia afirma la residencia "en la región", más fuerte
OPENROUTER_API_KEYFallback gestionado: enruta vía el endpoint nativo-Anthropic de OpenRouter (modelo por defecto anthropic/claude-sonnet-4.6)
ANTHROPIC_API_KEYLlama a api.anthropic.com directamente (por defecto claude-sonnet-4-6)
CHAT_MODEL / CHAT_FOLLOWUP_MODELSobrescriben los ids de modelo de respuesta / seguimiento para el proveedor activo

Los proveedores forman una cadena ordenada de failover: soberano → OpenRouter → Anthropic. El primer proveedor configurado es el primario; cada clave adicional se convierte en un fallback transparente al que el handler cambia a mitad de petición si el primario falla (los ids de modelo se re-resuelven por proveedor; CHAT_MODEL / CHAT_FOLLOWUP_MODEL se ligan solo al primario). Una sola clave funciona bien; una segunda compra redundancia. Sin ninguna fijada, el chat emite un evento not_configured y declina en lugar de fallar. SOVEREIGN_INFERENCE_REGION (opcional) declara la región geográfica del gateway — es la única vía por la que el chat afirma la aseveración más fuerte de "en la región" detrás de la insignia soberana; la residencia nunca se infiere de la mera presencia de un endpoint.

Asistente de IA — recuperación y web externa (lado servidor):

Var(s)FuncionalidadSin ella
VOYAGE_API_KEY (+ CHAT_EMBED_MODEL, CHAT_RERANK_MODEL)search_corpus semántico sobre el índice horneado + rerank cross-encoderSe degrada a búsqueda por palabras clave/BM25 (sin fallo duro)
EXA_API_KEYFallback web de último recurso del chat (web_search/fetch_url) + ingesta de pulsoEl chat responde solo desde el corpus y declina preguntas fuera del corpus
PERPLEXITY_API_KEY (+ PERPLEXITY_MODEL)Síntesis web en vivo perplexity_search (chat + MCP)La herramienta devuelve una nota de "no configurado"; las respuestas quedan solo-corpus

Fideicomiso de Datos (Pilar 1) y consenso — Upstash Redis (lado servidor):

Var(s)FuncionalidadSin ella
UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKENLedger de confianza append-only (recibos de /contribuir, /api/revoke, /api/intake), almacén de votos de consenso (/api/consensus), y rate limiting de la API entre instanciasDevuelve un recibo válido con persisted:false; el consenso se degrada a solo-local; los límites de tasa recaen en memoria por instancia — nunca finge persistencia
TRUST_ID_SALTSal secreta para el id seudónimo de contribuyente en los recibos del ledger (contributor_id = sha256(sal:email); el correo crudo nunca se almacena)En desarrollo local recae en la sal por defecto pública, presente en el repo (cualquiera puede recomputar el hash y confirmar si un correo dado corresponde a un registro del ledger). En un despliegue real (VERCEL presente), POST /api/contribute se niega a emitir recibos (500 explícito) hasta que se configure — nunca degrada en silencio a la sal pública. Fija un secreto de alta entropía en producción
TRUST_ID_SALT_PREVIOUSRotación de la sal: mueve aquí el valor anterior (separados por comas si hay varios); la revocación empareja cada promesa antigua con su sal original vía la etiqueta pública salt_version, así que rotar nunca rompe la revocabilidadRotar TRUST_ID_SALT sin retener la sal anterior deja irrevocables las promesas escritas bajo ella

Los alias de Vercel KV KV_REST_API_URL / KV_REST_API_TOKEN son aceptados solo por el almacén de consenso y el rate limiter — el ledger del Fideicomiso de Datos lee exclusivamente UPSTASH_REDIS_REST_URL/_TOKEN. Un despliegue configurado solo con los alias KV_* obtiene consenso funcional pero recibos de confianza con persisted:false; fija los nombres nativos para cobertura completa.

Alertas y correo (lado servidor):

Var(s)FuncionalidadSin ella
MAILGUN_API_KEY + MAILGUN_DOMAIN + MAILGUN_FROMEnvío de correo para las confirmaciones de /api/alerts-subscribe y avisos de operador desde /api/contribute y /api/intake (revoke no envía correo), vía Mailgun sobre fetchalerts-subscribe devuelve un fallback mailto: en lugar de fingir la suscripción
MAILGUN_NOTIFY / ALERTS_NOTIFY_EMAILCopia interna de los avisos de suscripción/contribución/intakeSin copia interna
ALERTS_WEBHOOKSEl cron del resumen semanal (/api/alerts-digest, Mon 13:00 UTC) despacha el resumen global de anomalías a estas URLs de webhook separadas por comasEl resumen no se despacha a nadie
CRON_SECRETAutentica el disparador del cron a POST /api/alerts-digest (Vercel envía Bearer $CRON_SECRET)Fail-closed: cada POST devuelve 401 — el fan-out queda deshabilitado, no desprotegido. (GET /api/alerts-digest?lang=es|en|pt sigue siendo una vista previa pública y localizada del resumen en cualquier caso.)

El resumen semanal de alertas es por webhooks (ALERTS_WEBHOOKS), no por correo — Mailgun maneja la confirmación de suscripción y los avisos del Fideicomiso de Datos. Ambos existen; no los confundas.

Outreach hop

VarFuncionalidadSin ella
OUTREACH_REDIRECTSJSON solo servidor { "<token>": "https://…" } para GET /o/{token} → 302. El token es opaco (UUID o similar). Destino allowlist https: futuros.xyz, docs.futuros.xyz, v2.futuros.xyz. El hop identifica en PostHog con el token como distinct_id (identify_reason=hop_mint) y emite hop_redirect. Deja la cookie first-party futuros_o para re-identificar si el visitante vuelve al producto. No hay API de mint.Cada token responde 404 — no hay redirect abierto ni enumeración del mapa
OUTREACH_ROSTERJSON solo servidor { "<token>": { name?, institution?, email?, campaign?, internal? } }. Escribe propiedades de persona en el hop; el distinct_id sigue siendo el token. internal: true marca testers para el filtro de cuentas de prueba de PostHog. Vive con Data, no en el repo.El hop identifica solo con outreach_token — sin nombre, correo ni institución

Forma exacta (un objeto, no un array; claves opacas; valores https allowlist):

json
{
  "550e8400-e29b-41d4-a716-446655440000": "https://docs.futuros.xyz/plataforma/recorrido",
  "7c9e6679-7425-40de-944b-e07fc1f90ae7": "https://futuros.xyz/atlas"
}

En Vercel: Production env OUTREACH_REDIRECTS = ese JSON en una sola línea; OUTREACH_ROSTER es el mismo patrón (otro objeto). Data añade tokens sin cambio de código. Un token desconocido, malformado o con destino fuera de la allowlist es el mismo 404 (Not found) — la respuesta no revela si existen otros tokens. El identify ocurre en el hop de futuros.xyz antes del 302 (token como distinct_id; roster opcional como propiedades). El sitio de docs inicializa el mismo proyecto PostHog (snippet VitePress) y puede re-persistir el token; el hop no depende de eso. No fijar VITE_POSTHOG_KEY. El espejo Caddy de Nivel 1 no ejecuta /o/{token} (cae al shell de la SPA); el hop requiere la función api/o.

Ingesta (solo scripts de build — no se necesita para servir la app): el pipeline de refresco lee claves como OPENAQ_API_KEY, CLOUDFLARE_RADAR_TOKEN, YOUTUBE_API_KEY, REDDIT_CLIENT_ID / REDDIT_CLIENT_SECRET, SERPAPI_KEY, más las variables de ajuste EXA_/PERPLEXITY_/GDELT_. Estas importan solo cuando re-ejecutas la ingesta (abajo), nunca para servir un corpus ya horneado.

El corpus es estático, horneado en tiempo de build

No hay base de datos en la ruta de servicio. Todo el corpus bajo public/data/ se hornea en tiempo de build y se entrega como archivos estáticos (los mismos archivos que expone la API Pública v1). Servir Futuros significa servir esos archivos — por eso el Nivel 1 (Caddy sobre dist/) es un espejo de datos completo.

Para refrescar los datos re-ejecutas los scripts de ingesta/horneado y reconstruyes:

bash
bun run ingest:all     # ingesta multi-fuente → parameter-cache + citas + señales
bun run bake           # narrativas derivadas, etc.; luego los horneados específicos que necesites:
bun run bake:scores    #   índice compuesto → scores.json
bun run bake:metrics   #   registro de métricas
bun run bake:signals   #   señales citadas
bun run bake:api       #   artefactos de la API pública v1
bun run build          # tsc + vite; prebuild re-ejecuta bake:health/bake:ledger/bake:api
                       # más las compuertas de procedencia — cualquier fallo detiene el build

(No existe un bake:all agregado; el horneado está deliberadamente dividido para que un refresco pueda tocar una familia sin re-derivar todo.)

prebuild es la compuerta de honestidad, en orden (según package.json): typecheck de la capa de scripts, la suite completa de bun test, luego los scripts de verificación — build-positions-index --check, check-traced, check-signals, check-source-links, check-pilots, check-citations, check-scores, check-provenance, check-emdash-drift, check-catalog, check-search-index --warn, check-i18n — luego tres re-horneados (bake-data-health, bake-source-ledger, bake-api) sellados por check-api. Cada paso salvo la pasada --warn del índice de búsqueda hace fallar el build — una cifra que perdió su fuente no puede publicarse. Así, una reconstrucción autoalojada preserva el contrato de honestidad por construcción, y un operador soberano puede ejecutar el pipeline completo en la región: corpus horneado en la región, inferencia en la región, nada sale del host.

  1. Levanta un gateway de pesos abiertos en la región (LiteLLM frente a vLLM/TGI sirviendo Llama/Qwen/DeepSeek/Mistral) que hable la API Anthropic Messages.
  2. Despliega la SPA + el corpus horneado (contenedor del Nivel 1) y las funciones api/* en infraestructura de la región.
  3. Fija SOVEREIGN_INFERENCE_URL + SOVEREIGN_INFERENCE_KEY (+ _MODEL, _LABEL). El chat muestra la insignia de "modo soberano"; el corpus + las consultas nunca salen de tu jurisdicción.
  4. Añade UPSTASH_REDIS_REST_* solo si quieres persistir el ledger de confianza / consenso; en caso contrario esas funcionalidades se degradan con honestidad.

Cada cifra con su fuente — la trazabilidad es el contrato.